feat: per-domain branding overrides on /api/config, manifest, pwa-icon #332
This commit is contained in:
@@ -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<string>();
|
||||
const out: DomainBrandingEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const rec = item as Record<string, unknown>;
|
||||
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<string, string>;
|
||||
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<DomainBrandingEntry> {
|
||||
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 ?? {};
|
||||
}
|
||||
@@ -163,6 +163,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||||
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||||
domainBranding: { envVar: 'DOMAIN_BRANDING', type: 'json', defaultValue: [] },
|
||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||
|
||||
Reference in New Issue
Block a user