feat: per-domain branding overrides on /api/config, manifest, pwa-icon #332

This commit is contained in:
Linus Rath
2026-05-28 20:06:47 +02:00
parent 8ae5ecba41
commit 1da04c254b
8 changed files with 509 additions and 49 deletions
+20
View File
@@ -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<string, unknown> = {};
for (const key of Object.keys(updates)) {
+66 -36
View File
@@ -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<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const host = pickRequestHost(request);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('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 = <T,>(key: BrandingOverrideKey, fallback: T): T => {
const override = domainOverrides[key];
if (typeof override === 'string' && override.length > 0) return override as T;
return configManager.get<T>(key, fallback);
};
const appName =
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
return NextResponse.json({
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
});
return NextResponse.json(
{
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: branded<string>('loginCompanyName', ''),
loginImprintUrl: branded<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('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' },
},
);
}
+14 -1
View File
@@ -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<Buffer> {
}
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<unknown>('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}`;
+32 -10
View File
@@ -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<ExtendedManifest> {
await configManager.ensureLoaded();
const host = pickRequestHost(await headers());
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
);
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
const override = domainOverrides[key];
if (typeof override === "string" && override.length > 0) return override as T;
return configManager.get<T>(key, fallback);
};
const appName =
configManager.get<string>("appName") ||
branded<string>("appName", "") ||
process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail";
const shortName = configManager.get<string>("appShortName") || appName;
const shortName = branded<string>("appShortName", "") || appName;
const description =
configManager.get<string>("appDescription") ||
branded<string>("appDescription", "") ||
"A modern webmail client built for Stalwart Mail Server";
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
const backgroundColor = branded<string>("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
? [