Files

124 lines
6.4 KiB
TypeScript

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 { getOauthResource, getOauthScopes } from '@/lib/oauth/tokens';
import { httpOrMailtoOrEmpty, httpUrlOrEmpty } from '@/lib/config-validation';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* 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<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 stalwartJmapPassthroughEnabled =
stalwartFeaturesEnabled && configManager.get<boolean>('stalwartJmapPassthroughEnabled', true);
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
const autoPickByDomain = configManager.get<boolean>('jmapServerAutoPickByDomain', false);
return NextResponse.json(
{
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
oauthResource: getOauthResource(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
stalwartJmapPassthroughEnabled,
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', ''),
loginUsernamePlaceholder: branded<string>('loginUsernamePlaceholder', ''),
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
loginShowTokenLogin: configManager.get<boolean>('loginShowTokenLogin', false),
loginShowRememberMe: configManager.get<boolean>('loginShowRememberMe', false),
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('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<unknown>('jmapServers', [])))
.map((server) => (autoPickByDomain ? server : { ...server, domains: [] })),
jmapServerAutoPickByDomain: autoPickByDomain,
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
sourceCodeUrl: httpUrlOrEmpty(configManager.get<string>('sourceCodeUrl', '')),
// Accessibility and help links, per domain like the legal links.
accessibilityStatementUrl: httpUrlOrEmpty(branded<string>('accessibilityStatementUrl', '')),
accessibilityFeedbackUrl: httpOrMailtoOrEmpty(branded<string>('accessibilityFeedbackUrl', '')),
helpUrl: httpUrlOrEmpty(branded<string>('helpUrl', '')),
easyReadUrl: httpUrlOrEmpty(branded<string>('easyReadUrl', '')),
signLanguageUrl: httpUrlOrEmpty(branded<string>('signLanguageUrl', '')),
// Browser-side switches of lib/outgoing-hosts.ts read this (#903).
noThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
},
{
// 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',
},
},
);
}