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 { httpUrlOrEmpty } from '@/lib/config-validation'; import { matchDomainBranding, parseDomainBranding, pickRequestHost, type BrandingOverrideKey, } from '@/lib/admin/domain-branding'; /** * Runtime configuration endpoint * * This endpoint serves configuration values that can be set at runtime * via environment variables or admin dashboard overrides, enabling * post-build configuration for Docker deployments. * * Priority order: * 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(request: NextRequest) { logger.debug('Config requested'); await configManager.ensureLoaded(); 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 stalwartJmapPassthroughEnabled = stalwartFeaturesEnabled && configManager.get('stalwartJmapPassthroughEnabled', true); const allowedFrameAncestors = configManager.get('allowedFrameAncestors', ''); const autoPickByDomain = configManager.get('jmapServerAutoPickByDomain', false); 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, stalwartJmapPassthroughEnabled, 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', ''), loginLogoMaxHeight: configManager.get('loginLogoMaxHeight', ''), loginLogoMaxWidth: configManager.get('loginLogoMaxWidth', ''), loginShowHeading: configManager.get('loginShowHeading', true), loginShowSubtitle: configManager.get('loginShowSubtitle', true), loginShowTotp: configManager.get('loginShowTotp', true), loginShowTokenLogin: configManager.get('loginShowTokenLogin', false), loginShowRememberMe: configManager.get('loginShowRememberMe', false), loginShowVersion: configManager.get('loginShowVersion', true), demoMode: configManager.get('demoMode', false), allowCustomJmapEndpoint: configManager.get('allowCustomJmapEndpoint', false), // The domain lists name every organisation served here; the login page // only needs them to pick the server by the typed address. jmapServers: redactJmapServers(parseJmapServers(configManager.get('jmapServers', []))) .map((server) => (autoPickByDomain ? server : { ...server, domains: [] })), jmapServerAutoPickByDomain: autoPickByDomain, autoSsoEnabled: configManager.get('autoSsoEnabled', false), embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", parentOrigin: configManager.get('parentOrigin', ''), sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')), }, { // Runtime settings can change without new asset URLs. Prevent stale // browser/proxy responses, retaining Vary for host-specific branding. headers: { Vary: 'Host, X-Forwarded-Host', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'CDN-Cache-Control': 'no-store', }, }, ); }