diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index cf89f033..a5c8a774 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types'; import { parseJmapServers } from '@/lib/admin/jmap-servers'; +import { parseDomainBranding } from '@/lib/admin/domain-branding'; import { logger } from '@/lib/logger'; // Strings that count as "no real secret configured" - used so the dashboard @@ -88,6 +89,25 @@ export async function PATCH(request: NextRequest) { updates.jmapServers = sanitized; } + // Normalize domainBranding: drop entries with an invalid/missing host or + // duplicate hosts before persisting. Each entry's branding field strings + // are passed through unchanged (URL/string content is the operator's + // responsibility, same as the flat branding fields). + if ('domainBranding' in updates) { + const incoming = updates.domainBranding; + if (incoming != null && !Array.isArray(incoming)) { + return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 }); + } + const sanitized = parseDomainBranding(incoming); + const incomingCount = Array.isArray(incoming) ? incoming.length : 0; + if (sanitized.length !== incomingCount) { + return NextResponse.json({ + error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).', + }, { status: 400 }); + } + updates.domainBranding = sanitized; + } + // Get old values for audit const oldValues: Record = {}; for (const key of Object.keys(updates)) { diff --git a/app/api/config/route.ts b/app/api/config/route.ts index bef6b80a..d0c79255 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -1,9 +1,15 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { configManager } from '@/lib/admin/config-manager'; import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { getOauthScopes } from '@/lib/oauth/tokens'; +import { + matchDomainBranding, + parseDomainBranding, + pickRequestHost, + type BrandingOverrideKey, +} from '@/lib/admin/domain-branding'; /** * Runtime configuration endpoint @@ -13,49 +19,73 @@ import { getOauthScopes } from '@/lib/oauth/tokens'; * post-build configuration for Docker deployments. * * Priority order: - * 1. Admin dashboard overrides (data/admin/config.json) - * 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL) - * 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL) - * 4. Default values + * 1. Per-domain branding override (admin-configured, matched on request host) + * 2. Admin dashboard overrides (data/admin/config.json) + * 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL) + * 4. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL) + * 5. Default values */ -export async function GET() { +export async function GET(request: NextRequest) { logger.debug('Config requested'); await configManager.ensureLoaded(); - const appName = configManager.get('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail'; + const host = pickRequestHost(request); + const domainOverrides = matchDomainBranding( + host, + parseDomainBranding(configManager.get('domainBranding', [])), + ); + + // Per-domain override wins over the global value, but only when the + // entry explicitly sets that key. Otherwise we fall through to the + // global admin/env/default chain. + const branded = (key: BrandingOverrideKey, fallback: T): T => { + const override = domainOverrides[key]; + if (typeof override === 'string' && override.length > 0) return override as T; + return configManager.get(key, fallback); + }; + + const appName = + branded('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail'; const jmapServerUrl = configManager.get('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || ''; const oauthEnabled = configManager.get('oauthEnabled', false); const oauthOnly = oauthEnabled && configManager.get('oauthOnly', false); const stalwartFeaturesEnabled = configManager.get('stalwartFeaturesEnabled', true); const allowedFrameAncestors = configManager.get('allowedFrameAncestors', ''); - return NextResponse.json({ - appName, - jmapServerUrl, - oauthEnabled, - oauthOnly, - oauthClientId: configManager.get('oauthClientId', ''), - oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''), - oauthScopes: getOauthScopes(), - rememberMeEnabled: hasSessionSecret(), - settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && hasSessionSecret(), - stalwartFeaturesEnabled, - devMode: configManager.get('devMode', false), - faviconUrl: configManager.get('faviconUrl', '/branding/Bulwark_Favicon.svg'), - appLogoLightUrl: configManager.get('appLogoLightUrl', ''), - appLogoDarkUrl: configManager.get('appLogoDarkUrl', ''), - loginLogoLightUrl: configManager.get('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'), - loginLogoDarkUrl: configManager.get('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'), - loginCompanyName: configManager.get('loginCompanyName', ''), - loginImprintUrl: configManager.get('loginImprintUrl', ''), - loginPrivacyPolicyUrl: configManager.get('loginPrivacyPolicyUrl', ''), - loginWebsiteUrl: configManager.get('loginWebsiteUrl', ''), - demoMode: configManager.get('demoMode', false), - allowCustomJmapEndpoint: configManager.get('allowCustomJmapEndpoint', false), - jmapServers: redactJmapServers(parseJmapServers(configManager.get('jmapServers', []))), - jmapServerAutoPickByDomain: configManager.get('jmapServerAutoPickByDomain', false), - autoSsoEnabled: configManager.get('autoSsoEnabled', false), - embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", - parentOrigin: configManager.get('parentOrigin', ''), - }); + return NextResponse.json( + { + appName, + jmapServerUrl, + oauthEnabled, + oauthOnly, + oauthClientId: configManager.get('oauthClientId', ''), + oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''), + oauthScopes: getOauthScopes(), + rememberMeEnabled: hasSessionSecret(), + settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && hasSessionSecret(), + stalwartFeaturesEnabled, + devMode: configManager.get('devMode', false), + faviconUrl: branded('faviconUrl', '/branding/Bulwark_Favicon.svg'), + appLogoLightUrl: branded('appLogoLightUrl', ''), + appLogoDarkUrl: branded('appLogoDarkUrl', ''), + loginLogoLightUrl: branded('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'), + loginLogoDarkUrl: branded('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'), + loginCompanyName: branded('loginCompanyName', ''), + loginImprintUrl: branded('loginImprintUrl', ''), + loginPrivacyPolicyUrl: branded('loginPrivacyPolicyUrl', ''), + loginWebsiteUrl: branded('loginWebsiteUrl', ''), + demoMode: configManager.get('demoMode', false), + allowCustomJmapEndpoint: configManager.get('allowCustomJmapEndpoint', false), + jmapServers: redactJmapServers(parseJmapServers(configManager.get('jmapServers', []))), + jmapServerAutoPickByDomain: configManager.get('jmapServerAutoPickByDomain', false), + autoSsoEnabled: configManager.get('autoSsoEnabled', false), + embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", + parentOrigin: configManager.get('parentOrigin', ''), + }, + { + // Branding varies by host, so any cache between us and the browser + // must key its entry by the host headers we consulted. + headers: { Vary: 'Host, X-Forwarded-Host' }, + }, + ); } diff --git a/app/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts index 54ad6c4a..52870b18 100644 --- a/app/api/pwa-icon/[size]/route.ts +++ b/app/api/pwa-icon/[size]/route.ts @@ -4,6 +4,11 @@ import path from 'node:path'; import { readFile } from 'node:fs/promises'; import { configManager } from '@/lib/admin/config-manager'; import { getConfigDir } from '@/lib/admin/paths'; +import { + matchDomainBranding, + parseDomainBranding, + pickRequestHost, +} from '@/lib/admin/domain-branding'; const VALID_SIZES = new Set([192, 512]); @@ -33,7 +38,7 @@ async function fetchSourceImage(iconUrl: string): Promise { } export async function GET( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ size: string }> } ) { const { size: sizeParam } = await params; @@ -44,8 +49,15 @@ export async function GET( } await configManager.ensureLoaded(); + const host = pickRequestHost(req); + const domainOverrides = matchDomainBranding( + host, + parseDomainBranding(configManager.get('domainBranding', [])), + ); const sources = configManager.getAllWithSources(); const iconUrl = + domainOverrides.pwaIconUrl || + domainOverrides.faviconUrl || (sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') || (sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : ''); if (!iconUrl) { @@ -55,6 +67,7 @@ export async function GET( const pngHeaders = { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400', + Vary: 'Host, X-Forwarded-Host', }; const cacheKey = `${size}|${iconUrl}`; diff --git a/app/manifest.ts b/app/manifest.ts index 16c86881..d7046d5e 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -1,5 +1,12 @@ import type { MetadataRoute } from "next"; +import { headers } from "next/headers"; import { configManager } from "@/lib/admin/config-manager"; +import { + matchDomainBranding, + parseDomainBranding, + pickRequestHost, + type BrandingOverrideKey, +} from "@/lib/admin/domain-branding"; export const dynamic = "force-dynamic"; @@ -25,25 +32,40 @@ const withBase = (p: string) => `${BASE_PATH}${p}`; export default async function manifest(): Promise { await configManager.ensureLoaded(); + const host = pickRequestHost(await headers()); + const domainOverrides = matchDomainBranding( + host, + parseDomainBranding(configManager.get("domainBranding", [])), + ); + const branded = (key: BrandingOverrideKey, fallback: T): T => { + const override = domainOverrides[key]; + if (typeof override === "string" && override.length > 0) return override as T; + return configManager.get(key, fallback); + }; + const appName = - configManager.get("appName") || + branded("appName", "") || process.env.NEXT_PUBLIC_APP_NAME || "Bulwark Webmail"; - const shortName = configManager.get("appShortName") || appName; + const shortName = branded("appShortName", "") || appName; const description = - configManager.get("appDescription") || + branded("appDescription", "") || "A modern webmail client built for Stalwart Mail Server"; - const themeColor = configManager.get("pwaThemeColor") || "#ffffff"; - const backgroundColor = configManager.get("pwaBackgroundColor") || "#ffffff"; + const themeColor = branded("pwaThemeColor", "") || "#ffffff"; + const backgroundColor = branded("pwaBackgroundColor", "") || "#ffffff"; - // If pwaIconUrl or faviconUrl was explicitly configured (admin override or - // env var), serve dynamically resized PNGs via /api/pwa-icon/[size]. - // Otherwise fall back to the static Bulwark PNGs - sources marked "default" - // are the built-in placeholder paths and not real custom icons. + // If pwaIconUrl or faviconUrl was explicitly configured (admin override, + // env var, or per-domain override), serve dynamically resized PNGs via + // /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs - + // sources marked "default" are the built-in placeholder paths and not + // real custom icons. const sources = configManager.getAllWithSources(); const hasCustomIcon = - sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default"; + !!domainOverrides.pwaIconUrl || + !!domainOverrides.faviconUrl || + sources.pwaIconUrl?.source !== "default" || + sources.faviconUrl?.source !== "default"; const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon ? [ diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts index 414ae7ae..7787ec1d 100644 --- a/lib/__tests__/config-route.test.ts +++ b/lib/__tests__/config-route.test.ts @@ -36,16 +36,29 @@ describe('config API route', () => { delete process.env.LOGIN_IMPRINT_URL; delete process.env.LOGIN_PRIVACY_POLICY_URL; delete process.env.LOGIN_WEBSITE_URL; + delete process.env.DOMAIN_BRANDING; }); afterEach(() => { process.env = { ...originalEnv }; }); - async function getConfig() { + function mockRequest(headers: Record = {}): unknown { + const lc: Record = {}; + for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v; + return { + headers: { + get(name: string) { + return lc[name.toLowerCase()] ?? null; + }, + }, + }; + } + + async function getConfig(headers?: Record) { // Re-import to pick up env changes const { GET } = await import('@/app/api/config/route'); - const response = await GET(); + const response = await GET(mockRequest(headers) as Parameters[0]); return response.json(); } @@ -192,4 +205,70 @@ describe('config API route', () => { expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg'); expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg'); }); + + describe('per-domain branding overrides', () => { + it('applies overrides for the matching host', async () => { + process.env.LOGIN_COMPANY_NAME = 'Default Co'; + process.env.LOGIN_WEBSITE_URL = 'https://default.example'; + process.env.DOMAIN_BRANDING = JSON.stringify([ + { + host: 'mail1.example.com', + loginCompanyName: 'Brand One', + loginWebsiteUrl: 'https://one.example', + }, + ]); + + const config = await getConfig({ host: 'mail1.example.com' }); + + expect(config.loginCompanyName).toBe('Brand One'); + expect(config.loginWebsiteUrl).toBe('https://one.example'); + }); + + it('falls through to the global value when the host has no entry', async () => { + process.env.LOGIN_COMPANY_NAME = 'Default Co'; + process.env.DOMAIN_BRANDING = JSON.stringify([ + { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, + ]); + + const config = await getConfig({ host: 'unmapped.example.com' }); + + expect(config.loginCompanyName).toBe('Default Co'); + }); + + it('falls through field-by-field when the matching entry omits a field', async () => { + process.env.LOGIN_COMPANY_NAME = 'Default Co'; + process.env.LOGIN_WEBSITE_URL = 'https://default.example'; + process.env.DOMAIN_BRANDING = JSON.stringify([ + { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, + ]); + + const config = await getConfig({ host: 'mail1.example.com' }); + + expect(config.loginCompanyName).toBe('Brand One'); + expect(config.loginWebsiteUrl).toBe('https://default.example'); + }); + + it('prefers X-Forwarded-Host over Host', async () => { + process.env.DOMAIN_BRANDING = JSON.stringify([ + { host: 'public.example.com', loginCompanyName: 'Public' }, + ]); + + const config = await getConfig({ + host: 'internal.example.com', + 'x-forwarded-host': 'public.example.com', + }); + + expect(config.loginCompanyName).toBe('Public'); + }); + + it('strips the port from the host header before matching', async () => { + process.env.DOMAIN_BRANDING = JSON.stringify([ + { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, + ]); + + const config = await getConfig({ host: 'mail1.example.com:8443' }); + + expect(config.loginCompanyName).toBe('Brand One'); + }); + }); }); diff --git a/lib/__tests__/domain-branding.test.ts b/lib/__tests__/domain-branding.test.ts new file mode 100644 index 00000000..6672a2cb --- /dev/null +++ b/lib/__tests__/domain-branding.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest'; +import { + matchDomainBranding, + parseDomainBranding, + pickRequestHost, + type DomainBrandingEntry, +} from '@/lib/admin/domain-branding'; + +function mockHeaders(map: Record): Headers { + const lc: Record = {}; + for (const [k, v] of Object.entries(map)) lc[k.toLowerCase()] = v; + return { + get(name: string) { + return lc[name.toLowerCase()] ?? null; + }, + } as unknown as Headers; +} + +describe('parseDomainBranding', () => { + it('returns [] for null/undefined/empty', () => { + expect(parseDomainBranding(null)).toEqual([]); + expect(parseDomainBranding(undefined)).toEqual([]); + expect(parseDomainBranding('')).toEqual([]); + expect(parseDomainBranding([])).toEqual([]); + }); + + it('parses a stringified JSON array', () => { + const raw = JSON.stringify([{ host: 'mail.example.com', loginCompanyName: 'Acme' }]); + expect(parseDomainBranding(raw)).toEqual([ + { host: 'mail.example.com', loginCompanyName: 'Acme' }, + ]); + }); + + it('lower-cases hosts and strips trailing dots', () => { + expect(parseDomainBranding([{ host: 'Mail.Example.COM.' }])).toEqual([ + { host: 'mail.example.com' }, + ]); + }); + + it('accepts wildcard hosts', () => { + expect(parseDomainBranding([{ host: '*.example.com', loginCompanyName: 'Wild' }])).toEqual([ + { host: '*.example.com', loginCompanyName: 'Wild' }, + ]); + }); + + it('drops entries with invalid hosts', () => { + const out = parseDomainBranding([ + { host: '' }, + { host: 'has space.com' }, + { host: 'foo..bar' }, + { host: '*.*.example.com' }, // embedded wildcard not allowed + { host: 'good.example.com' }, + ]); + expect(out.map(e => e.host)).toEqual(['good.example.com']); + }); + + it('drops duplicate hosts, keeping the first', () => { + const out = parseDomainBranding([ + { host: 'foo.com', loginCompanyName: 'First' }, + { host: 'FOO.com', loginCompanyName: 'Second' }, + ]); + expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'First' }]); + }); + + it('ignores non-string and empty-string override fields', () => { + const out = parseDomainBranding([ + { + host: 'foo.com', + loginCompanyName: '', + loginImprintUrl: 42, + loginWebsiteUrl: 'https://foo.com', + }, + ]); + expect(out).toEqual([{ host: 'foo.com', loginWebsiteUrl: 'https://foo.com' }]); + }); + + it('ignores unknown fields', () => { + const out = parseDomainBranding([ + { host: 'foo.com', notARealField: 'x', loginCompanyName: 'OK' }, + ]); + expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'OK' }]); + }); +}); + +describe('pickRequestHost', () => { + it('returns null when no host headers are set', () => { + expect(pickRequestHost(mockHeaders({}))).toBeNull(); + }); + + it('prefers X-Forwarded-Host over Host', () => { + expect(pickRequestHost(mockHeaders({ + 'x-forwarded-host': 'forwarded.example.com', + host: 'origin.example.com', + }))).toBe('forwarded.example.com'); + }); + + it('falls back to Host when X-Forwarded-Host is absent', () => { + expect(pickRequestHost(mockHeaders({ host: 'origin.example.com' }))).toBe('origin.example.com'); + }); + + it('strips the port', () => { + expect(pickRequestHost(mockHeaders({ host: 'example.com:8080' }))).toBe('example.com'); + }); + + it('takes the first entry of a comma-separated X-Forwarded-Host', () => { + expect(pickRequestHost(mockHeaders({ + 'x-forwarded-host': 'first.example.com, second.example.com', + }))).toBe('first.example.com'); + }); + + it('lower-cases the result', () => { + expect(pickRequestHost(mockHeaders({ host: 'EXAMPLE.com' }))).toBe('example.com'); + }); +}); + +describe('matchDomainBranding', () => { + const entries: DomainBrandingEntry[] = [ + { host: 'mail.example.com', loginCompanyName: 'Exact' }, + { host: '*.example.com', loginCompanyName: 'Wildcard' }, + { host: '*.dev.example.com', loginCompanyName: 'Specific Wildcard' }, + { host: 'other.com', loginCompanyName: 'Other' }, + ]; + + it('returns {} when host is null', () => { + expect(matchDomainBranding(null, entries)).toEqual({}); + }); + + it('returns {} when no entries match', () => { + expect(matchDomainBranding('unknown.org', entries)).toEqual({}); + }); + + it('prefers exact match over wildcard', () => { + expect(matchDomainBranding('mail.example.com', entries).loginCompanyName).toBe('Exact'); + }); + + it('matches wildcards on subdomains', () => { + expect(matchDomainBranding('foo.example.com', entries).loginCompanyName).toBe('Wildcard'); + }); + + it('prefers the longest wildcard suffix', () => { + expect(matchDomainBranding('app.dev.example.com', entries).loginCompanyName).toBe('Specific Wildcard'); + }); + + it('does not match the wildcard host against the apex domain', () => { + expect(matchDomainBranding('example.com', entries)).toEqual({}); + }); + + it('is case-insensitive on the request host', () => { + expect(matchDomainBranding('Mail.Example.COM', entries).loginCompanyName).toBe('Exact'); + }); +}); diff --git a/lib/admin/domain-branding.ts b/lib/admin/domain-branding.ts new file mode 100644 index 00000000..d5efe8ec --- /dev/null +++ b/lib/admin/domain-branding.ts @@ -0,0 +1,144 @@ +/** + * Per-domain branding overrides: schema, parsing, host extraction, and match. + * + * The webmail can be served on multiple hostnames (e.g. mail1.example.com, + * mail2.other.com). Each hostname can override a subset of branding fields; + * unset fields fall back to the global admin/env/default value. + */ + +import type { NextRequest } from 'next/server'; + +/** Config keys that can be overridden per domain. */ +export const BRANDING_OVERRIDE_KEYS = [ + 'appName', + 'appShortName', + 'appDescription', + 'faviconUrl', + 'pwaIconUrl', + 'pwaThemeColor', + 'pwaBackgroundColor', + 'appLogoLightUrl', + 'appLogoDarkUrl', + 'loginLogoLightUrl', + 'loginLogoDarkUrl', + 'loginCompanyName', + 'loginImprintUrl', + 'loginPrivacyPolicyUrl', + 'loginWebsiteUrl', +] as const; + +export type BrandingOverrideKey = (typeof BRANDING_OVERRIDE_KEYS)[number]; + +export interface DomainBrandingEntry { + /** + * Hostname this entry applies to. Either an exact host like + * "mail.example.com" or a wildcard like "*.example.com" (matches any + * direct or deeper subdomain). Case-insensitive; trailing dots are + * stripped on parse. + */ + host: string; + appName?: string; + appShortName?: string; + appDescription?: string; + faviconUrl?: string; + pwaIconUrl?: string; + pwaThemeColor?: string; + pwaBackgroundColor?: string; + appLogoLightUrl?: string; + appLogoDarkUrl?: string; + loginLogoLightUrl?: string; + loginLogoDarkUrl?: string; + loginCompanyName?: string; + loginImprintUrl?: string; + loginPrivacyPolicyUrl?: string; + loginWebsiteUrl?: string; +} + +// Accepts plain hostnames (foo, foo.bar, foo.bar.baz) and one-level wildcards +// at the leftmost label (*.example.com). Rejects IPs, scheme/path/userinfo, +// and embedded wildcards. +const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; + +function normalizeHost(host: string): string { + return host.trim().toLowerCase().replace(/\.+$/, ''); +} + +/** Parse the raw config value (array of entries, or string-JSON). Invalid entries are dropped. */ +export function parseDomainBranding(raw: unknown): DomainBrandingEntry[] { + if (!raw) return []; + let value = raw; + if (typeof value === 'string') { + if (!value.trim()) return []; + try { + value = JSON.parse(value); + } catch { + return []; + } + } + if (!Array.isArray(value)) return []; + + const seen = new Set(); + const out: DomainBrandingEntry[] = []; + for (const item of value) { + if (!item || typeof item !== 'object') continue; + const rec = item as Record; + const rawHost = typeof rec.host === 'string' ? rec.host : ''; + const host = normalizeHost(rawHost); + if (!host || !HOST_RE.test(host)) continue; + if (seen.has(host)) continue; + seen.add(host); + + const entry: DomainBrandingEntry = { host }; + const writable = entry as unknown as Record; + for (const key of BRANDING_OVERRIDE_KEYS) { + const v = rec[key]; + if (typeof v === 'string' && v.length > 0) { + writable[key] = v; + } + } + out.push(entry); + } + return out; +} + +type HeadersLike = Headers | { get(name: string): string | null }; + +/** + * Pick the request's host, preferring X-Forwarded-Host (first entry if + * comma-separated) over Host. Strips the port. Returns null when no usable + * host header is set. + */ +export function pickRequestHost(headersOrReq: NextRequest | HeadersLike): string | null { + const headers: HeadersLike = 'headers' in headersOrReq ? (headersOrReq as NextRequest).headers : headersOrReq; + const raw = headers.get('x-forwarded-host') || headers.get('host'); + if (!raw) return null; + const first = raw.split(',')[0]?.trim(); + if (!first) return null; + return normalizeHost(first.split(':')[0]); +} + +/** + * Find the entry whose host matches `host`. Exact match always wins; among + * wildcards the longest (most-specific) suffix wins. Returns {} when no + * entry matches. + */ +export function matchDomainBranding( + host: string | null, + entries: DomainBrandingEntry[], +): Partial { + if (!host || entries.length === 0) return {}; + const lower = normalizeHost(host); + let wildcardMatch: DomainBrandingEntry | undefined; + for (const entry of entries) { + if (entry.host === lower) return entry; + if (entry.host.startsWith('*.')) { + const suffix = entry.host.slice(1); // ".example.com" + if (lower.endsWith(suffix) && lower.length > suffix.length) { + if (!wildcardMatch || entry.host.length > wildcardMatch.host.length) { + wildcardMatch = entry; + } + } + } + } + return wildcardMatch ?? {}; +} diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 09d496c9..988c2b4a 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -163,6 +163,7 @@ export const CONFIG_ENV_MAP: Record