diff --git a/.env.example b/.env.example index c1733ba8..c2f2b40c 100644 --- a/.env.example +++ b/.env.example @@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Admin Dashboard Data # ============================================================================= -# Directory for admin dashboard state: config overrides, admin password hash, -# installed plugins/themes, and audit logs (default: ./data/admin). -# For Docker, the default resolves to /app/data/admin - mount a persistent -# volume there (see docker-compose.yml). +# Admin data is split across two directories so the config volume can be +# mounted read-only after the setup wizard completes (see issue #226). +# +# Config dir - operator-authored state. Holds config.json, policy.json, +# admin.json (passwordHash only), plugin-config/, plugins/, themes/, and +# branding uploads. Safe to mount read-only after setup. +# Default: ./data/admin (or ADMIN_DATA_DIR if that legacy variable is set) +# ADMIN_CONFIG_DIR=./data/admin +# +# State dir - runtime mutations. Holds admin-state.json (login timestamps), +# audit.log, and the bootstrap setup token. Always read-write. +# Default: ./data/admin-state (or ADMIN_DATA_DIR/state when ADMIN_DATA_DIR +# is set, for back-compat with single-volume installs) +# ADMIN_STATE_DIR=./data/admin-state +# +# Set to "true" to enforce read-only mode at the application layer (cleaner +# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount. +# ADMIN_CONFIG_READONLY=true +# +# Legacy: a single dir containing both config and state. Honoured if neither +# of the split variables is set. New installs should use the split vars. # ADMIN_DATA_DIR=./data/admin # ============================================================================= diff --git a/Dockerfile b/Dockerfile index 20084be2..8e3a3e68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,7 @@ RUN apk upgrade --no-cache && \ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data +RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data USER nextjs EXPOSE 3000 ENV PORT=3000 diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx index c1e1e752..9f57f3b3 100644 --- a/app/admin/login/page.tsx +++ b/app/admin/login/page.tsx @@ -47,13 +47,13 @@ export default function AdminLoginPage() {
-
- {logoUrl ? ( - - ) : ( + {logoUrl ? ( + + ) : ( +
- )} -
+
+ )}

Admin Dashboard

Enter your admin password to continue

diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 766e2110..dca051d1 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -7,14 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange'; import { discoverOAuth } from '@/lib/oauth/discovery'; import { OAUTH_SCOPES } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; -import { readFileEnv } from '@/lib/read-file-env'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_MAX_AGE = 300; // 5 minutes export async function POST(request: NextRequest) { try { - if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) { + if (!hasSessionSecret()) { return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 }); } diff --git a/app/api/config/route.ts b/app/api/config/route.ts index c6f9f4f0..2640c2be 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { configManager } from '@/lib/admin/config-manager'; -import { readFileEnv } from '@/lib/read-file-env'; import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; /** * Runtime configuration endpoint @@ -35,8 +35,8 @@ export async function GET() { oauthOnly, oauthClientId: configManager.get('oauthClientId', ''), oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''), - rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE), - settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)), + rememberMeEnabled: hasSessionSecret(), + settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && hasSessionSecret(), stalwartFeaturesEnabled, devMode: configManager.get('devMode', false), faviconUrl: configManager.get('faviconUrl', '/branding/Bulwark_Favicon.svg'), diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index a6f6da6b..8d853a74 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -6,7 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { configManager } from '@/lib/admin/config-manager'; -import { readFileEnv } from '@/lib/read-file-env'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; function classifyError(error: unknown): { message: string; status: number } { @@ -50,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } { } function isEnabled(): boolean { - return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)); + const flagOn = + process.env.SETTINGS_SYNC_ENABLED === 'true' || + configManager.get('settingsSyncEnabled', false); + return flagOn && hasSessionSecret(); } /** Strip trailing slashes so differently-formatted URLs still match. */ diff --git a/app/api/setup/finish/route.ts b/app/api/setup/finish/route.ts new file mode 100644 index 00000000..4c0d11df --- /dev/null +++ b/app/api/setup/finish/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { writeFile } from 'node:fs/promises'; +import { detectSetupState } from '@/lib/setup/state'; +import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session'; +import { configManager } from '@/lib/admin/config-manager'; +import { setInitialAdminPassword } from '@/lib/admin/password'; +import { clearSetupToken } from '@/lib/setup/token'; +import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/setup/finish + * + * Final wizard step. Validates that required config is in place, hashes the + * admin password, marks setup complete, deletes the setup token (which + * invalidates the wizard cookie), and optionally drops a `.config-locked` + * marker so the operator remembers they intended to mount :ro. + * + * Body: { adminPassword: string, lockConfig?: boolean } + */ +export async function POST(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + if (!(await authenticateWizardRequest())) { + return NextResponse.json({ error: 'Wizard session required' }, { status: 401 }); + } + + let body: { adminPassword?: unknown; lockConfig?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const adminPassword = + typeof body?.adminPassword === 'string' ? body.adminPassword : ''; + if (adminPassword.length < 8) { + return NextResponse.json( + { error: 'Admin password must be at least 8 characters' }, + { status: 400 }, + ); + } + + const lockConfig = body?.lockConfig === true; + + // Validate required config is present. + await configManager.ensureLoaded(); + const jmapUrl = configManager.get('jmapServerUrl', ''); + if (!jmapUrl || typeof jmapUrl !== 'string') { + return NextResponse.json( + { error: 'JMAP server URL is required (run the Server step first)' }, + { status: 400 }, + ); + } + + try { + // 1. Provision the admin account. Aborts cleanly if one already exists + // (defence in depth - should be impossible in bootstrap state). + const created = await setInitialAdminPassword(adminPassword); + if (!created) { + return NextResponse.json( + { error: 'Admin account already exists; cannot finish setup again' }, + { status: 409 }, + ); + } + + // 2. Persist setupComplete flag. After this, detectSetupState() flips + // to 'configured' and middleware starts 404'ing /setup paths. + await configManager.markSetupComplete(); + + // 3. Optional advisory lock marker. + if (lockConfig) { + await ensureConfigDir(); + await writeFile( + getConfigPath('.config-locked'), + new Date().toISOString(), + 'utf-8', + ); + } + + // 4. Destroy the setup token. Any other browser holding the cookie is + // now unauthenticated. + await clearSetupToken(); + + await auditLog( + 'setup.finish', + { lockConfig, jmapServerUrl: jmapUrl }, + request.headers.get('x-forwarded-for') ?? 'unknown', + ); + + const response = NextResponse.json({ ok: true, lockConfig }); + response.cookies.delete(SETUP_COOKIE); + return response; + } catch (error) { + logger.error('Wizard finish failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return NextResponse.json( + { error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' }, + { status: 500 }, + ); + } +} diff --git a/app/api/setup/status/route.ts b/app/api/setup/status/route.ts new file mode 100644 index 00000000..aa2f77f9 --- /dev/null +++ b/app/api/setup/status/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { detectSetupState } from '@/lib/setup/state'; +import { authenticateWizardRequest } from '@/lib/setup/session'; +import { configManager } from '@/lib/admin/config-manager'; +import { isConfigReadOnly } from '@/lib/admin/paths'; +import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/setup/status - public endpoint that returns the wizard state + * and (if authenticated) the partial config saved by previous steps. The + * wizard polls this on load so a refresh resumes with prior values. + * + * Sensitive values (OAuth client secret, session secret) are NEVER sent + * back to the client - only a `HasValue` boolean. Re-entering them + * after refresh is the price of not exposing them. + */ +export async function GET() { + await configManager.ensureLoaded(); + const state = detectSetupState(); + const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false; + + let partialConfig: Record | null = null; + if (state === 'bootstrap' && authenticated) { + // Only echo back values the operator has actually saved during the + // wizard (admin overrides). System defaults must not flow back here, + // because the wizard has its own opinionated defaults (e.g. settings + // sync on by default) that we'd otherwise stomp. + const sources = configManager.getAllWithSources(); + const safe: Record = {}; + for (const [key, info] of Object.entries(sources)) { + if (info.source !== 'admin') continue; + if (SENSITIVE_CONFIG_KEYS.has(key)) { + safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0; + } else { + safe[key] = info.value; + } + } + partialConfig = safe; + } + + return NextResponse.json( + { + state, + authenticated, + readOnly: isConfigReadOnly(), + partialConfig, + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); +} diff --git a/app/api/setup/step/route.ts b/app/api/setup/step/route.ts new file mode 100644 index 00000000..2dc08f67 --- /dev/null +++ b/app/api/setup/step/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { detectSetupState } from '@/lib/setup/state'; +import { authenticateWizardRequest } from '@/lib/setup/session'; +import { configManager } from '@/lib/admin/config-manager'; +import { CONFIG_ENV_MAP } from '@/lib/admin/types'; +import { parseJmapServers } from '@/lib/admin/jmap-servers'; +import { logger } from '@/lib/logger'; + +export const dynamic = 'force-dynamic'; + +/** + * Mapping of wizard-friendly step keys to the config keys they update. Each + * step's PATCH validates against this allowlist so a compromised wizard + * client can't slip in arbitrary config keys. + */ +const STEP_KEYS: Record = { + server: [ + 'appName', + 'jmapServerUrl', + 'stalwartFeaturesEnabled', + 'jmapServers', + 'jmapServerAutoPickByDomain', + ], + auth: [ + 'oauthEnabled', + 'oauthOnly', + 'oauthClientId', + 'oauthClientSecret', + 'oauthIssuerUrl', + ], + security: ['sessionSecret', 'settingsSyncEnabled'], + logging: ['logFormat', 'logLevel'], + branding: [ + 'faviconUrl', + 'appLogoLightUrl', + 'appLogoDarkUrl', + 'loginLogoLightUrl', + 'loginLogoDarkUrl', + 'loginCompanyName', + 'loginImprintUrl', + 'loginPrivacyPolicyUrl', + 'loginWebsiteUrl', + ], +}; + +/** + * POST /api/setup/step + * Body: { step: 'server' | 'auth' | ..., values: Record } + * + * Persists partial config under the admin override (config.json). Each + * step's allowed keys are restricted by STEP_KEYS so the client can only + * touch what the corresponding screen owns. + */ +export async function POST(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + if (!(await authenticateWizardRequest())) { + return NextResponse.json({ error: 'Wizard session required' }, { status: 401 }); + } + + let body: { step?: unknown; values?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const step = typeof body?.step === 'string' ? body.step : ''; + const values = body?.values; + const allowedKeys = STEP_KEYS[step]; + if (!allowedKeys) { + return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 }); + } + if (!values || typeof values !== 'object' || Array.isArray(values)) { + return NextResponse.json({ error: 'values must be an object' }, { status: 400 }); + } + + const updates: Record = {}; + for (const [key, value] of Object.entries(values as Record)) { + if (!allowedKeys.includes(key)) { + return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 }); + } + if (!(key in CONFIG_ENV_MAP)) { + return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 }); + } + if (key === 'jmapServers') { + // Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs + // before they're persisted. Mirrors the admin config PATCH route. + if (value != null && !Array.isArray(value)) { + return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 }); + } + const sanitized = parseJmapServers(value); + const incomingCount = Array.isArray(value) ? value.length : 0; + if (sanitized.length !== incomingCount) { + return NextResponse.json( + { error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` }, + { status: 400 }, + ); + } + updates[key] = sanitized; + continue; + } + updates[key] = value; + } + + try { + await configManager.ensureLoaded(); + await configManager.setAdminConfig(updates); + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Wizard step save failed', { + step, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return NextResponse.json({ error: 'Failed to save step' }, { status: 500 }); + } +} diff --git a/app/api/setup/test-jmap/route.ts b/app/api/setup/test-jmap/route.ts new file mode 100644 index 00000000..76262c8f --- /dev/null +++ b/app/api/setup/test-jmap/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { detectSetupState } from '@/lib/setup/state'; +import { authenticateWizardRequest } from '@/lib/setup/session'; + +export const dynamic = 'force-dynamic'; + +const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap']; +const FETCH_TIMEOUT_MS = 5000; + +/** + * POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors + * the check_jmap_server() helper in setup.sh: we hit a few common session + * endpoints and look for capability strings to confirm the URL is actually + * a JMAP server (vs. a generic HTTP 200 page). + * + * Body: { url: string } + */ +export async function POST(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + if (!(await authenticateWizardRequest())) { + return NextResponse.json({ error: 'Wizard session required' }, { status: 401 }); + } + + let body: { url?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const raw = typeof body?.url === 'string' ? body.url.trim() : ''; + if (!raw) { + return NextResponse.json({ error: 'url required' }, { status: 400 }); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' }); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' }); + } + + const base = raw.replace(/\/+$/, ''); + + for (const endpoint of JMAP_ENDPOINTS) { + const target = base + endpoint; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(target, { + method: 'GET', + redirect: 'follow', + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) continue; + const text = await res.text(); + if (looksLikeJmapSession(text)) { + return NextResponse.json({ + status: 'jmap_detected', + endpoint, + httpStatus: res.status, + }); + } + } catch { + // Try the next endpoint; we'll fall through to a final reachability + // check below if none match. + } + } + + // No JMAP session found. Was the server even reachable? + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(base, { + method: 'HEAD', + redirect: 'follow', + signal: controller.signal, + }); + clearTimeout(timer); + return NextResponse.json({ + status: 'reachable_no_jmap', + httpStatus: res.status, + message: + 'Server responded but no JMAP session was found at standard paths. ' + + 'This is OK if a reverse proxy routes JMAP separately.', + }); + } catch (error) { + return NextResponse.json({ + status: 'unreachable', + message: error instanceof Error ? error.message : 'Connection failed', + }); + } +} + +function looksLikeJmapSession(body: string): boolean { + return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body); +} diff --git a/app/api/setup/token/route.ts b/app/api/setup/token/route.ts new file mode 100644 index 00000000..4b22b794 --- /dev/null +++ b/app/api/setup/token/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { detectSetupState } from '@/lib/setup/state'; +import { verifySetupToken } from '@/lib/setup/token'; +import { buildSessionCookieAttributes } from '@/lib/setup/session'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/setup/token - exchange the bootstrap token (printed to logs at + * startup) for a wizard session cookie. After this, subsequent step calls + * authenticate via the cookie instead of pasting the token every time. + * + * Body: { token: string } + */ +export async function POST(request: NextRequest) { + if (detectSetupState() !== 'bootstrap') { + return NextResponse.json({ error: 'Setup is not active' }, { status: 404 }); + } + + let body: { token?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const submitted = typeof body?.token === 'string' ? body.token.trim() : ''; + if (!submitted) { + return NextResponse.json({ error: 'Token required' }, { status: 400 }); + } + + const ok = await verifySetupToken(submitted); + if (!ok) { + // Don't differentiate between "wrong token" and "no token issued" - the + // operator either has it from the logs or they don't. + return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }); + } + + const response = NextResponse.json({ ok: true }); + const attrs = buildSessionCookieAttributes(); + response.cookies.set(attrs.name, submitted, { + httpOnly: attrs.httpOnly, + sameSite: attrs.sameSite, + secure: attrs.secure, + path: attrs.path, + maxAge: attrs.maxAge, + }); + return response; +} diff --git a/app/setup/layout.tsx b/app/setup/layout.tsx new file mode 100644 index 00000000..61e6465b --- /dev/null +++ b/app/setup/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export default function SetupLayout({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/app/setup/page.tsx b/app/setup/page.tsx new file mode 100644 index 00000000..52952cab --- /dev/null +++ b/app/setup/page.tsx @@ -0,0 +1,1288 @@ +'use client'; + +import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { apiFetch } from '@/lib/browser-navigation'; + +type State = 'bootstrap' | 'configured' | 'env-managed'; + +interface StatusResponse { + state: State; + authenticated: boolean; + readOnly: boolean; + partialConfig: Record | null; +} + +interface JmapServerRow { + id: string; + label: string; + url: string; + /** comma-separated, parsed before save */ + domains: string; +} + +interface WizardConfig { + // Server + appName: string; + jmapServerUrl: string; + stalwartFeaturesEnabled: boolean; + jmapServers: JmapServerRow[]; + jmapServerAutoPickByDomain: boolean; + // Auth + oauthEnabled: boolean; + oauthOnly: boolean; + oauthClientId: string; + oauthClientSecret: string; + oauthIssuerUrl: string; + // Security + sessionSecret: string; + settingsSyncEnabled: boolean; + // Logging + logFormat: 'text' | 'json'; + logLevel: 'error' | 'warn' | 'info' | 'debug'; + // Branding + faviconUrl: string; + appLogoLightUrl: string; + appLogoDarkUrl: string; + loginLogoLightUrl: string; + loginLogoDarkUrl: string; + loginCompanyName: string; + loginImprintUrl: string; + loginPrivacyPolicyUrl: string; + loginWebsiteUrl: string; +} + +const EMPTY_CONFIG: WizardConfig = { + appName: 'Bulwark Webmail', + jmapServerUrl: '', + stalwartFeaturesEnabled: true, + jmapServers: [], + jmapServerAutoPickByDomain: false, + oauthEnabled: false, + oauthOnly: false, + oauthClientId: '', + oauthClientSecret: '', + oauthIssuerUrl: '', + sessionSecret: '', + settingsSyncEnabled: true, + logFormat: 'text', + logLevel: 'info', + faviconUrl: '', + appLogoLightUrl: '', + appLogoDarkUrl: '', + loginLogoLightUrl: '', + loginLogoDarkUrl: '', + loginCompanyName: '', + loginImprintUrl: '', + loginPrivacyPolicyUrl: '', + loginWebsiteUrl: '', +}; + +const STEPS = [ + { id: 'welcome', label: 'Welcome' }, + { id: 'server', label: 'Server' }, + { id: 'auth', label: 'Auth' }, + { id: 'security', label: 'Security' }, + { id: 'logging', label: 'Logging' }, + { id: 'branding', label: 'Branding' }, + { id: 'review', label: 'Review' }, +] as const; + +export default function SetupWizardPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [bootstrapping, setBootstrapping] = useState(true); + const [error, setError] = useState(null); + const [state, setState] = useState('bootstrap'); + const [authenticated, setAuthenticated] = useState(false); + const [readOnly, setReadOnly] = useState(false); + const [config, setConfig] = useState(EMPTY_CONFIG); + const [stepIndex, setStepIndex] = useState(0); + const [completed, setCompleted] = useState(false); + + // ─── Initial status load ──────────────────────────────────────────────── + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await apiFetch('/api/setup/status', { cache: 'no-store' }); + const data = (await res.json()) as StatusResponse; + if (cancelled) return; + + setState(data.state); + setReadOnly(data.readOnly); + + if (data.state === 'configured' || data.state === 'env-managed') { + // Wizard not active - bounce to login. Middleware will 404 us + // before we get here in practice, but defensive. + router.replace('/'); + return; + } + + setAuthenticated(data.authenticated); + if (data.partialConfig) { + setConfig((prev) => mergePartial(prev, data.partialConfig!)); + // If auth is OK and we already have a JMAP URL persisted, jump + // ahead to the next unfilled step. + if (data.authenticated && data.partialConfig.jmapServerUrl) { + setStepIndex(2); + } else if (data.authenticated) { + setStepIndex(1); + } + } + } catch (e) { + if (!cancelled) setError(humanError(e)); + } finally { + if (!cancelled) setBootstrapping(false); + } + })(); + return () => { + cancelled = true; + }; + }, [router]); + + // ─── Token submit (welcome step) ──────────────────────────────────────── + async function submitToken(token: string) { + setError(null); + const res = await apiFetch('/api/setup/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error ?? `Token rejected (HTTP ${res.status})`); + } + setAuthenticated(true); + setStepIndex(1); + } + + // ─── Step persistence ─────────────────────────────────────────────────── + async function saveStep(step: string, values: Record) { + const res = await apiFetch('/api/setup/step', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ step, values }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error ?? `Step save failed (HTTP ${res.status})`); + } + } + + // ─── Render shell ─────────────────────────────────────────────────────── + if (bootstrapping) { + return

Loading…

; + } + + if (completed) { + return ; + } + + if (state !== 'bootstrap') { + return ; + } + + if (readOnly) { + return ( + +

Configuration is read-only

+

+ The config volume is mounted read-only or ADMIN_CONFIG_READONLY is set. + Remount it read-write or unset that variable, then restart the container. +

+
+ ); + } + + return ( +
+
+
+ + +
+ {error && setError(null)} />} + + {!authenticated ? ( + { + try { + await submitToken(t); + } catch (e) { + setError(humanError(e)); + } + }} + /> + ) : ( + { + try { + await saveStep(step, values); + setStepIndex((i) => Math.min(i + 1, STEPS.length - 1)); + } catch (e) { + setError(humanError(e)); + } + }} + onBack={() => setStepIndex((i) => Math.max(i - 1, 1))} + onFinish={() => { + setCompleted(true); + // Hard navigation after a beat — gives the user a moment + // to see the success screen and works around any router + // edge cases that swallow client-side replaces after the + // setupComplete flag flips. + setTimeout(() => { + window.location.assign('/admin/login'); + }, 1500); + }} + /> + )} +
+
+
+ ); +} + +// ─── Layout helpers ─────────────────────────────────────────────────────── + +function Header() { + return ( +
+

Bulwark Webmail Setup

+

+ Configure your webmail instance from the browser. +

+
+ ); +} + +function ProgressBar({ stepIndex }: { stepIndex: number }) { + return ( +
+
+ {STEPS.map((step, i) => ( +
+
+
+ {step.label} +
+
+ ))} +
+
+ ); +} + +function CompletedScreen() { + return ( + +
+
+ + + +
+

You're all set!

+

+ Bulwark Webmail is configured and ready to use. +

+
+ +

+ Taking you to the admin dashboard… +

+
+ ); +} + +function AlreadyConfiguredScreen() { + return ( + +
+
+ + + +
+

Setup is already complete

+

+ Bulwark Webmail is configured. Sign in to continue. +

+
+ +
+ ); +} + +function CenteredCard({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function ErrorBanner({ error, onDismiss }: { error: string; onDismiss: () => void }) { + return ( +
+ {error} + +
+ ); +} + +// ─── Welcome / token step ──────────────────────────────────────────────── + +function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmit: (t: string) => Promise }) { + const [token, setToken] = useState(tokenFromUrl); + const [submitting, setSubmitting] = useState(false); + + // Auto-submit if token came in via URL. + useEffect(() => { + if (tokenFromUrl && !submitting) { + setSubmitting(true); + onSubmit(tokenFromUrl).finally(() => setSubmitting(false)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tokenFromUrl]); + + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + await onSubmit(token.trim()); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Welcome

+

+ Paste the setup token printed in the container logs to continue. The token expires after 1 hour. +

+ + setToken(v)} autoFocus required placeholder="32-byte hex token" /> + + + {submitting ? 'Verifying…' : 'Continue'} + +
+ ); +} + +// ─── Step router ───────────────────────────────────────────────────────── + +interface StepProps { + stepIndex: number; + config: WizardConfig; + setConfig: React.Dispatch>; + onNext: (step: string, values: Record) => Promise; + onBack: () => void; + onFinish: () => void; +} + +function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: StepProps) { + switch (stepIndex) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + case 5: + return ; + case 6: + return ; + default: + return

Loading…

; + } +} + +// ─── Server step ───────────────────────────────────────────────────────── + +function ServerStep({ config, setConfig, onNext }: Pick) { + const [submitting, setSubmitting] = useState(false); + const [probe, setProbe] = useState(null); + const [probing, setProbing] = useState(false); + + async function testJmap() { + setProbe(null); + setProbing(true); + try { + const res = await apiFetch('/api/setup/test-jmap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: config.jmapServerUrl }), + }); + const data = await res.json(); + if (data.status === 'jmap_detected') { + setProbe(`JMAP server confirmed at ${data.endpoint}`); + } else if (data.status === 'reachable_no_jmap') { + setProbe(`Server reachable (HTTP ${data.httpStatus}) but no JMAP session found at standard paths.`); + } else { + setProbe(data.message ?? 'Could not reach server.'); + } + } catch (e) { + setProbe(humanError(e)); + } finally { + setProbing(false); + } + } + + const [showAdditional, setShowAdditional] = useState(config.jmapServers.length > 0); + + function updateRow(index: number, patch: Partial) { + setConfig({ + ...config, + jmapServers: config.jmapServers.map((row, i) => (i === index ? { ...row, ...patch } : row)), + }); + } + + function addRow() { + setConfig({ + ...config, + jmapServers: [...config.jmapServers, { id: '', label: '', url: '', domains: '' }], + }); + setShowAdditional(true); + } + + function removeRow(index: number) { + setConfig({ + ...config, + jmapServers: config.jmapServers.filter((_, i) => i !== index), + }); + } + + // Validate the multi-server rows: each must have a unique id matching the + // schema, a usable URL, and no collision with the primary server. + const rowErrors: string[] = []; + const seenIds = new Set(); + for (let i = 0; i < config.jmapServers.length; i++) { + const r = config.jmapServers[i]; + const id = r.id.trim(); + if (!id) { + rowErrors.push(`Server #${i + 1}: id is required`); + } else if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) { + rowErrors.push(`Server #${i + 1}: id must be alphanumeric (with - or _), starting with a letter or digit`); + } else if (seenIds.has(id)) { + rowErrors.push(`Server #${i + 1}: id "${id}" is duplicated`); + } else { + seenIds.add(id); + } + const url = r.url.trim(); + if (!url) { + rowErrors.push(`Server #${i + 1}: url is required`); + } else if (!/^https?:\/\//i.test(url)) { + rowErrors.push(`Server #${i + 1}: url must start with http:// or https://`); + } + } + const hasRowErrors = rowErrors.length > 0; + + async function handle(e: FormEvent) { + e.preventDefault(); + if (hasRowErrors) return; + setSubmitting(true); + try { + await onNext('server', { + appName: config.appName, + jmapServerUrl: config.jmapServerUrl, + stalwartFeaturesEnabled: config.stalwartFeaturesEnabled, + // The API route runs parseJmapServers on this; we pre-canonicalize + // here so the round-trip is clean. + jmapServers: rowsToCanonical(config.jmapServers), + jmapServerAutoPickByDomain: config.jmapServerAutoPickByDomain, + }); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + + setConfig({ ...config, appName: v })} required /> + + +
+ setConfig({ ...config, jmapServerUrl: v })} + required + placeholder="https://" + type="url" + /> + +
+ {probe &&

{probe}

} +
+ + {/* Additional servers (optional) */} +
+
+
+
Additional JMAP servers
+

+ Optional. Surface multiple servers in the login dropdown - useful for hosts running several Stalwart instances. +

+
+ +
+ + {showAdditional && ( +
+ {config.jmapServers.map((row, i) => ( +
+
+ Server #{i + 1} + +
+
+ + updateRow(i, { id: v })} placeholder="eu-1" required /> + + + updateRow(i, { label: v })} placeholder="Europe (primary)" /> + +
+ + updateRow(i, { url: v })} placeholder="https://" type="url" required /> + + + updateRow(i, { domains: v })} placeholder="example.com, mail.example.com" /> + +
+ ))} + + + + {config.jmapServers.length > 0 && ( + setConfig({ ...config, jmapServerAutoPickByDomain: v })} + label="Auto-pick server by email domain" + hint="When a user types their email, automatically select the matching server from the list above." + /> + )} + + {hasRowErrors && ( +
    + {rowErrors.map((err, i) => ( +
  • {err}
  • + ))} +
+ )} +
+ )} +
+ + setConfig({ ...config, stalwartFeaturesEnabled: v })} + label="Enable Stalwart-specific features" + hint="Adds password change and Sieve filter management. Safe to enable on non-Stalwart servers." + /> + +
+ + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Auth step ─────────────────────────────────────────────────────────── + +function AuthStep({ config, setConfig, onNext, onBack }: Pick) { + const [submitting, setSubmitting] = useState(false); + + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + const values: Partial = { oauthEnabled: config.oauthEnabled }; + if (config.oauthEnabled) { + values.oauthOnly = config.oauthOnly; + values.oauthClientId = config.oauthClientId; + values.oauthIssuerUrl = config.oauthIssuerUrl; + if (config.oauthClientSecret) { + values.oauthClientSecret = config.oauthClientSecret; + } + } + await onNext('auth', values); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + setConfig({ ...config, oauthEnabled: v })} + label="Enable OAuth2 / OpenID Connect" + /> + {config.oauthEnabled && ( + <> + setConfig({ ...config, oauthOnly: v })} + label="OAuth-only mode (hide password form)" + /> + + setConfig({ ...config, oauthClientId: v })} required /> + + + setConfig({ ...config, oauthClientSecret: v })} + type="password" + placeholder="paste secret" + /> + + + setConfig({ ...config, oauthIssuerUrl: v })} type="url" /> + + + )} +
+ Back + + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Security step ─────────────────────────────────────────────────────── + +function generateSessionSecret(): string { + // 32 random bytes, base64-encoded - same shape as `openssl rand -base64 32`. + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +function SecurityStep({ config, setConfig, onNext, onBack }: Pick) { + const [submitting, setSubmitting] = useState(false); + const [reveal, setReveal] = useState(false); + const [customize, setCustomize] = useState(false); + + // Auto-generate on first render so the operator doesn't have to click a + // button for the recommended path. They can still regenerate or paste + // their own via the "Customize" toggle. + useEffect(() => { + if (!config.sessionSecret) { + setConfig((prev) => ({ ...prev, sessionSecret: generateSessionSecret() })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + const values: Partial = { + settingsSyncEnabled: config.settingsSyncEnabled, + }; + if (config.sessionSecret) values.sessionSecret = config.sessionSecret; + await onNext('security', values); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + +
+
+ Session secret generated + +
+

+ A 32-byte secret was created for you. You only need to change this if you have a specific reason. +

+
+ + {customize && ( + +
+ setConfig({ ...config, sessionSecret: v })} + type={reveal ? 'text' : 'password'} + /> + + +
+
+ )} + + setConfig({ ...config, settingsSyncEnabled: v })} + label="Sync user settings across devices" + hint="Stores user preferences server-side, encrypted with the session secret." + disabled={!config.sessionSecret} + /> +
+ Back + + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Logging step ──────────────────────────────────────────────────────── + +function LoggingStep({ config, setConfig, onNext, onBack }: Pick) { + const [submitting, setSubmitting] = useState(false); + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + await onNext('logging', { logFormat: config.logFormat, logLevel: config.logLevel }); + } finally { + setSubmitting(false); + } + } + return ( +
+ + + setConfig({ ...config, logLevel: v as WizardConfig['logLevel'] })} + options={[ + { value: 'error', label: 'error' }, + { value: 'warn', label: 'warn' }, + { value: 'info', label: 'info (recommended)' }, + { value: 'debug', label: 'debug' }, + ]} + /> + +
+ Back + + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Branding step ─────────────────────────────────────────────────────── + +function BrandingStep({ config, setConfig, onNext, onBack }: Pick) { + const [submitting, setSubmitting] = useState(false); + async function handle(e: FormEvent) { + e.preventDefault(); + setSubmitting(true); + try { + // Only send fields the operator actually filled in. Saving an empty + // string would create an admin override that shadows the system + // default — a blank "Login logo" field would suppress the default + // Bulwark logo on the login page, which is never what we want from + // the wizard. + const allFields = { + faviconUrl: config.faviconUrl, + appLogoLightUrl: config.appLogoLightUrl, + appLogoDarkUrl: config.appLogoDarkUrl, + loginLogoLightUrl: config.loginLogoLightUrl, + loginLogoDarkUrl: config.loginLogoDarkUrl, + loginCompanyName: config.loginCompanyName, + loginImprintUrl: config.loginImprintUrl, + loginPrivacyPolicyUrl: config.loginPrivacyPolicyUrl, + loginWebsiteUrl: config.loginWebsiteUrl, + }; + const values: Record = {}; + for (const [k, v] of Object.entries(allFields)) { + if (v.trim() !== '') values[k] = v.trim(); + } + await onNext('branding', values); + } finally { + setSubmitting(false); + } + } + return ( +
+ + + setConfig({ ...config, loginCompanyName: v })} /> + + + setConfig({ ...config, faviconUrl: v })} /> + +
+ + setConfig({ ...config, loginLogoLightUrl: v })} /> + + + setConfig({ ...config, loginLogoDarkUrl: v })} /> + + + setConfig({ ...config, appLogoLightUrl: v })} /> + + + setConfig({ ...config, appLogoDarkUrl: v })} /> + +
+ + setConfig({ ...config, loginWebsiteUrl: v })} type="url" /> + + + setConfig({ ...config, loginImprintUrl: v })} type="url" /> + + + setConfig({ ...config, loginPrivacyPolicyUrl: v })} type="url" /> + +
+ Back + + {submitting ? 'Saving…' : 'Next'} + +
+ + ); +} + +// ─── Review / finish step ───────────────────────────────────────────────── + +function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) { + const [adminPassword, setAdminPassword] = useState(''); + const [adminConfirm, setAdminConfirm] = useState(''); + const [lockConfig, setLockConfig] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [localError, setLocalError] = useState(null); + + async function handle(e: FormEvent) { + e.preventDefault(); + setLocalError(null); + if (adminPassword.length < 8) { + setLocalError('Admin password must be at least 8 characters.'); + return; + } + if (adminPassword !== adminConfirm) { + setLocalError('Passwords do not match.'); + return; + } + setSubmitting(true); + try { + const res = await apiFetch('/api/setup/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ adminPassword, lockConfig }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setLocalError(data.error ?? `Finish failed (HTTP ${res.status})`); + return; + } + onFinish(); + } catch (e) { + setLocalError(humanError(e)); + } finally { + setSubmitting(false); + } + } + + return ( +
+ +
+ + + {config.jmapServers.length > 0 && ( + s.id).join(', ') + + (config.jmapServerAutoPickByDomain ? ' (auto-pick by domain)' : '') + } + /> + )} + + + + + + +
+ + + + + + + + + + + {localError && ( +

{localError}

+ )} + +
+ Back + + {submitting ? 'Applying…' : 'Apply & Finish'} + +
+ + ); +} + +// ─── Atoms ──────────────────────────────────────────────────────────────── + +function StepHeader({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +function Input({ + value, + onChange, + type = 'text', + placeholder, + required, + autoFocus, +}: { + value: string; + onChange: (v: string) => void; + type?: string; + placeholder?: string; + required?: boolean; + autoFocus?: boolean; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + required={required} + autoFocus={autoFocus} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + ); +} + +function Select({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) { + return ( + + ); +} + +function Toggle({ + checked, + onChange, + label, + hint, + disabled, +}: { + checked: boolean; + onChange: (v: boolean) => void; + label: string; + hint?: string; + disabled?: boolean; +}) { + return ( + + ); +} + +function Footer({ children }: { children: ReactNode }) { + return
{children}
; +} + +function PrimaryButton({ children, ...rest }: React.ButtonHTMLAttributes) { + return ( + + ); +} + +function SecondaryButton({ children, onClick, disabled }: { children: ReactNode; onClick: () => void; disabled?: boolean }) { + return ( + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function mergePartial(prev: WizardConfig, partial: Record): WizardConfig { + const next: WizardConfig = { ...prev }; + for (const key of Object.keys(prev) as (keyof WizardConfig)[]) { + const incoming = partial[key]; + if (incoming === undefined) continue; + if (key === 'jmapServers') { + // Server stores canonical shape; wizard form uses csv domains string. + next.jmapServers = canonicalToRows(incoming); + continue; + } + if (typeof incoming === typeof prev[key] || prev[key] === '' || prev[key] === false) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (next as any)[key] = incoming; + } + } + return next; +} + +function canonicalToRows(value: unknown): JmapServerRow[] { + if (!Array.isArray(value)) return []; + return value + .map((item): JmapServerRow | null => { + if (!item || typeof item !== 'object') return null; + const e = item as Record; + const id = typeof e.id === 'string' ? e.id : ''; + const label = typeof e.label === 'string' ? e.label : ''; + const url = typeof e.url === 'string' ? e.url : ''; + const domains = Array.isArray(e.domains) + ? (e.domains as unknown[]) + .filter((d): d is string => typeof d === 'string') + .join(', ') + : ''; + if (!id || !url) return null; + return { id, label, url, domains }; + }) + .filter((r): r is JmapServerRow => r !== null); +} + +function rowsToCanonical(rows: JmapServerRow[]) { + return rows + .map((r) => { + const id = r.id.trim(); + const url = r.url.trim(); + if (!id || !url) return null; + const domains = r.domains + .split(',') + .map((d) => d.trim()) + .filter(Boolean); + return { + id, + label: r.label.trim() || id, + url, + ...(domains.length > 0 ? { domains } : {}), + }; + }) + .filter((e): e is { id: string; label: string; url: string; domains?: string[] } => e !== null); +} + +function hasAnyBranding(c: WizardConfig): boolean { + return Boolean( + c.loginCompanyName || + c.faviconUrl || + c.appLogoLightUrl || + c.appLogoDarkUrl || + c.loginLogoLightUrl || + c.loginLogoDarkUrl || + c.loginWebsiteUrl || + c.loginImprintUrl || + c.loginPrivacyPolicyUrl, + ); +} + +function humanError(e: unknown): string { + if (e instanceof Error) return e.message; + if (typeof e === 'string') return e; + return 'Unknown error'; +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 24fda38b..be57fd10 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -281,7 +281,7 @@ export function EmailComposer({ const currentIdentity = selectedIdentityId ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity : primaryIdentity; - // Alias identities often lack a configured signature — fall back to the primary + // Alias identities often lack a configured signature - fall back to the primary // identity's signature so replies (which auto-select a matching alias) still // populate the user's signature. const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 1caac5ee..5f6fd253 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2296,7 +2296,7 @@ export function EmailViewer({ htmlContent = email.bodyValues[email.htmlBody[0].partId].value; // Per RFC 8621 § 4.1.4, when a message has only one alternative the server // exposes the same part in both htmlBody and textBody. The shared part may - // actually be text/plain (plain-text-only mail) — rendering that as HTML + // actually be text/plain (plain-text-only mail) - rendering that as HTML // collapses newlines and skips linkification, so route by the part's type. const htmlPart = email.htmlBody[0]; if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') { @@ -2691,7 +2691,7 @@ export function EmailViewer({ // double re-inverting images nested inside those containers. // Nested bgcolor containers must NOT add another invert layer: each filter // toggles the inversion, so an odd number of stacked filters (e.g. body + - // outer bgcolor table + inner bgcolor table) produces an inverted result — + // outer bgcolor table + inner bgcolor table) produces an inverted result - // i.e. light-on-light. The second rule disables filter on bgcolor-like // elements that are descendants of another bgcolor-like element. const darkModeCSS = isDark && !emailHasNativeDarkMode ? ` @@ -2888,7 +2888,7 @@ export function EmailViewer({ // Re-invert emoji glyphs so they keep their original colors. The // body's invert filter flips colored emoji (yellow smiley → blue, // red heart → cyan, etc.). Wrap each emoji run in a span that - // re-inverts. Only act when the ancestor invert depth is odd — + // re-inverts. Only act when the ancestor invert depth is odd - // emojis inside a double-inverted bgcolor container already render // at their original colors. let emojiRe: RegExp; diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 1d0843f0..61577091 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -331,7 +331,7 @@ function EmailCard({ htmlContent = email.bodyValues[email.htmlBody[0].partId].value; // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. - // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody — + // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody - // in that case there is no real plain-text alternative, so always render the HTML. const textPartId = email.textBody?.[0]?.partId; const htmlPartId = email.htmlBody[0].partId; diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 48df8979..b0764e98 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -114,7 +114,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher setDefaultAccount(accountId); }; - // Show the account's own identity, not the preferred sending identity — + // Show the account's own identity, not the preferred sending identity - // primaryIdentity can be an alias (e.g. info@korazo.net) that differs from // the actually logged-in account (info@linusrath.de). const displayName = activeAccount?.displayName || activeAccount?.label || ""; diff --git a/docker-compose.yml b/docker-compose.yml index ddf1a798..928b41b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,13 @@ services: volumes: # Encrypted user settings (SETTINGS_DATA_DIR). - bulwark-settings:/app/data/settings - # Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR). + # Admin configuration: config.json, policy.json, admin.json (passwordHash), + # plugins, themes, branding uploads (ADMIN_CONFIG_DIR). Can be mounted + # read-only after running the setup wizard - append `:ro` to lock it. - bulwark-admin:/app/data/admin + # Admin runtime state: admin-state.json (login timestamps), audit.log, + # setup token (ADMIN_STATE_DIR). Always read-write. + - bulwark-admin-state:/app/data/admin-state # Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR). # Persisting this preserves the admin's consent choice and stable instance id across upgrades. - bulwark-telemetry:/app/data/telemetry @@ -35,4 +40,5 @@ services: volumes: bulwark-settings: bulwark-admin: + bulwark-admin-state: bulwark-telemetry: diff --git a/instrumentation.node.ts b/instrumentation.node.ts index d4e1525e..13438a52 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -1,6 +1,9 @@ import { readFileSync } from "fs"; import { configManager } from "./lib/admin/config-manager"; import { initAdminPassword } from "./lib/admin/password"; +import { migrateLegacyAdminLayout } from "./lib/admin/migrate"; +import { detectSetupState } from "./lib/setup/state"; +import { ensureSetupToken } from "./lib/setup/token"; const pkg = JSON.parse( readFileSync(`${process.cwd()}/package.json`, "utf-8") @@ -8,11 +11,36 @@ const pkg = JSON.parse( const current: string = pkg.version ?? "0.0.0"; console.info(`Bulwark Webmail v${current}`); -// Initialize admin config and password bootstrap -configManager.load() +// Initialize admin config and password bootstrap. Migration runs first so +// existing v1 layouts are split before anything reads admin.json. +migrateLegacyAdminLayout() + .then(() => configManager.load()) .then(() => initAdminPassword()) - .then(() => { + .then(async () => { console.info("Admin dashboard initialized"); + // If we're in bootstrap state (no JMAP_SERVER_URL env and no + // setupComplete in config.json), generate/refresh the setup token and + // print it to the logs so the operator can complete the web wizard + // without execing into the container. + if (detectSetupState() === "bootstrap") { + try { + const token = await ensureSetupToken(); + const port = process.env.PORT || "3000"; + console.info(""); + console.info("=============================================================="); + console.info(" SETUP REQUIRED"); + console.info(` Token: ${token}`); + console.info(` Open: http://:${port}/setup?token=${token}`); + console.info(" Token expires in 1 hour. Restart the container to reissue."); + console.info("=============================================================="); + console.info(""); + } catch (err) { + console.warn( + "Failed to issue setup token:", + err instanceof Error ? err.message : err, + ); + } + } }) .then(async () => { // Anonymous telemetry - on by default. Admins can disable via the diff --git a/lib/account-utils.ts b/lib/account-utils.ts index b9e95cf6..ea63020f 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -65,7 +65,7 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string /** * Hard upper bound on cookie slots. Each slot can hold up to ~3 cookies * (session, refresh token, server id, auth context), so 50 slots ≈ 125 - * cookies on average — within Firefox's per-domain limit of 150. + * cookies on average - within Firefox's per-domain limit of 150. */ export const MAX_ACCOUNT_SLOTS = 50; @@ -83,7 +83,7 @@ export const MAX_ACCOUNTS_HTTP1 = 5; * We walk recent resource-timing entries and treat a single h2/h3 sighting * as a positive signal. Cross-origin entries may report an empty * `nextHopProtocol` without `Timing-Allow-Origin`, in which case we - * under-detect and fall back to the conservative cap — that's safe. + * under-detect and fall back to the conservative cap - that's safe. */ export function isHttp2Available(): boolean { if (typeof performance === 'undefined') return false; diff --git a/lib/admin/audit.ts b/lib/admin/audit.ts index 9b3a6ded..0c0bb73e 100644 --- a/lib/admin/audit.ts +++ b/lib/admin/audit.ts @@ -1,28 +1,23 @@ -import { appendFile, stat, rename, mkdir } from 'node:fs/promises'; +import { appendFile, stat, rename, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import path from 'node:path'; import { logger } from '@/lib/logger'; +import { ensureStateDir, getStatePath } from './paths'; import type { AuditEntry } from './types'; const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB const MAX_ROTATIONS = 3; - -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} +const AUDIT_LOG_FILE = 'audit.log'; function getAuditLogPath(): string { - return path.join(getAdminDir(), 'audit.log'); + return getStatePath(AUDIT_LOG_FILE); } /** - * Append an audit entry to the admin audit log. + * Append an audit entry to the admin audit log. Stored under the state dir + * so it remains writable when the config dir is mounted read-only. */ export async function auditLog(action: string, detail: Record, ip: string): Promise { - const dir = getAdminDir(); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } + await ensureStateDir(); const entry: AuditEntry = { ts: new Date().toISOString(), @@ -64,7 +59,6 @@ async function rotateIfNeeded(logPath: string): Promise { export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> { const logPath = getAuditLogPath(); try { - const { readFile } = await import('node:fs/promises'); const content = await readFile(logPath, 'utf-8'); const lines = content.trim().split('\n').filter(Boolean); @@ -77,7 +71,6 @@ export async function readAuditLog(page: number = 1, limit: number = 50, actionF } const total = entries.length; - // Return newest first entries.reverse(); const start = (page - 1) * limit; return { entries: entries.slice(start, start + limit), total }; diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index fa7df72c..1e3e278d 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -1,13 +1,8 @@ -import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; +import { readFile, writeFile, rename } from 'node:fs/promises'; import { logger } from '@/lib/logger'; import { readFileEnv } from '@/lib/read-file-env'; import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types'; - -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} +import { ensureConfigDir, getConfigPath, assertWritable } from './paths'; function parseEnvValue(value: string, type: string): unknown { switch (type) { @@ -127,6 +122,7 @@ class ConfigManager { * Update admin config overrides. Writes to disk. */ async setAdminConfig(updates: Record): Promise { + assertWritable('update admin config'); Object.assign(this.adminConfig, updates); await this.writeJsonFile('config.json', this.adminConfig); } @@ -135,10 +131,29 @@ class ConfigManager { * Remove an admin override, reverting to env/default. */ async removeAdminOverride(key: string): Promise { + assertWritable('remove admin override'); delete this.adminConfig[key]; await this.writeJsonFile('config.json', this.adminConfig); } + /** + * Whether the setup wizard has completed. Used by middleware to gate the + * /setup routes and the rest of the app. + */ + isSetupComplete(): boolean { + return this.adminConfig.setupComplete === true; + } + + /** + * Mark setup wizard as complete. Called by the wizard's finish endpoint + * after all other config has been written. Refuses in read-only mode. + */ + async markSetupComplete(): Promise { + assertWritable('mark setup complete'); + this.adminConfig.setupComplete = true; + await this.writeJsonFile('config.json', this.adminConfig); + } + /** * Get the current settings policy. */ @@ -150,6 +165,7 @@ class ConfigManager { * Update the settings policy. Writes to disk. */ async setPolicy(policy: SettingsPolicy): Promise { + assertWritable('update settings policy'); this.policyCache = { ...DEFAULT_POLICY, ...policy, @@ -167,7 +183,7 @@ class ConfigManager { } private async readJsonFile(filename: string): Promise | null> { - const filePath = path.join(getAdminDir(), filename); + const filePath = getConfigPath(filename); try { const raw = await readFile(filePath, 'utf-8'); return JSON.parse(raw); @@ -179,15 +195,21 @@ class ConfigManager { } private async writeJsonFile(filename: string, data: Record): Promise { - const dir = getAdminDir(); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } - const targetPath = path.join(dir, filename); + await ensureConfigDir(); + const targetPath = getConfigPath(filename); const tmpPath = targetPath + '.tmp'; await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); await rename(tmpPath, targetPath); } } -export const configManager = new ConfigManager(); +// Stash the singleton on globalThis so HMR / multiple module-evaluation +// boundaries (middleware vs route handlers in dev with turbopack) all share +// the same in-memory state. Without this, marking setupComplete=true in a +// route handler is invisible to the next middleware run, and the wizard +// redirect after finish never fires. +const SINGLETON_KEY = Symbol.for('bulwark.admin.configManager'); +type GlobalWithConfig = typeof globalThis & { [SINGLETON_KEY]?: ConfigManager }; +const g = globalThis as GlobalWithConfig; +export const configManager: ConfigManager = + g[SINGLETON_KEY] ?? (g[SINGLETON_KEY] = new ConfigManager()); diff --git a/lib/admin/migrate.ts b/lib/admin/migrate.ts new file mode 100644 index 00000000..39d7b2be --- /dev/null +++ b/lib/admin/migrate.ts @@ -0,0 +1,196 @@ +import { readFile, writeFile, rename, stat, unlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { logger } from '@/lib/logger'; +import { + ensureConfigDir, + ensureStateDir, + getConfigPath, + getStatePath, + isConfigReadOnly, +} from './paths'; +import type { AdminConfigData, AdminStateData } from './types'; + +const MIGRATION_MARKER = '.migrated-v2'; + +interface LegacyAdminData { + passwordHash: string; + createdAt?: string; + lastLogin?: string | null; + passwordChangedAt?: string; +} + +/** + * One-shot migration from the v1 layout (everything mixed in `data/admin/`) + * to the v2 layout (config + state split, see lib/admin/paths.ts). + * + * Idempotent: writes a `.migrated-v2` marker into the config dir on success. + * + * Migrations performed: + * 1. admin.json with timestamps → admin.json (passwordHash only) + + * admin-state.json (createdAt, lastLogin, passwordChangedAt) + * 2. audit.log moved from config dir to state dir (by rename if same FS, + * else copy + delete). + * + * Skipped silently when the config dir is read-only - operators who already + * locked their config volume must do the migration manually before mounting + * :ro. + */ +export async function migrateLegacyAdminLayout(): Promise { + if (isConfigReadOnly()) return; + + const markerPath = getConfigPath(MIGRATION_MARKER); + if (existsSync(markerPath)) return; + + let didWork = false; + + try { + didWork = (await migrateAdminJson()) || didWork; + didWork = (await migrateAuditLog()) || didWork; + + await ensureConfigDir(); + await writeFile(markerPath, new Date().toISOString(), 'utf-8'); + if (didWork) { + logger.info('Admin layout migrated to v2 (config/state split)'); + } + } catch (error) { + logger.warn('Admin layout migration failed; will retry on next boot', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + +/** + * If the existing admin.json carries timestamp fields (legacy mixed layout), + * split them into admin-state.json and rewrite admin.json without them. + * Returns true if a migration was performed. + */ +async function migrateAdminJson(): Promise { + const adminJsonPath = getConfigPath('admin.json'); + if (!existsSync(adminJsonPath)) return false; + + let raw: string; + try { + raw = await readFile(adminJsonPath, 'utf-8'); + } catch { + return false; + } + + let data: LegacyAdminData; + try { + data = JSON.parse(raw) as LegacyAdminData; + } catch { + logger.warn('admin.json is not valid JSON; skipping migration'); + return false; + } + + const hasLegacyFields = + 'createdAt' in data || 'lastLogin' in data || 'passwordChangedAt' in data; + if (!hasLegacyFields) return false; // already in v2 shape + + if (!data.passwordHash || typeof data.passwordHash !== 'string') { + logger.warn('admin.json missing passwordHash; skipping migration'); + return false; + } + + const now = new Date().toISOString(); + const stateData: AdminStateData = { + createdAt: data.createdAt ?? now, + lastLogin: data.lastLogin ?? null, + passwordChangedAt: data.passwordChangedAt ?? now, + }; + const configData: AdminConfigData = { passwordHash: data.passwordHash }; + + await ensureStateDir(); + const statePath = getStatePath('admin-state.json'); + + // If admin-state.json already exists, prefer its values: a previous + // migration may have succeeded and recorded fresh login timestamps that + // we'd otherwise stomp. The legacy admin.json data is older by definition. + if (!existsSync(statePath)) { + const stateTmp = statePath + '.tmp'; + await writeFile(stateTmp, JSON.stringify(stateData, null, 2), 'utf-8'); + await rename(stateTmp, statePath); + } + + const configTmp = adminJsonPath + '.tmp'; + await writeFile(configTmp, JSON.stringify(configData, null, 2), 'utf-8'); + await rename(configTmp, adminJsonPath); + + logger.info('Migrated admin.json: split timestamps into admin-state.json'); + return true; +} + +/** + * Move audit.log from the config dir to the state dir if present. Returns + * true if a migration was performed. Also moves rotated copies (audit.log.1 + * through .3). + */ +async function migrateAuditLog(): Promise { + const sources = [ + 'audit.log', + 'audit.log.1', + 'audit.log.2', + 'audit.log.3', + ]; + + let moved = false; + for (const name of sources) { + const src = getConfigPath(name); + if (!existsSync(src)) continue; + + await ensureStateDir(); + const dst = getStatePath(name); + + try { + // Same-FS rename is atomic. Falls through to copy if cross-device. + await rename(src, dst); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EXDEV') { + // Cross-device: copy bytes, then delete source. + const data = await readFile(src); + await writeFile(dst, data); + await unlink(src); + } else { + throw error; + } + } + moved = true; + } + + if (moved) { + logger.info('Migrated audit.log to state dir'); + } + return moved; +} + +/** + * Returns approximate size of legacy data still mixed in the config dir + * (for diagnostics / admin UI). Always returns 0 once migration has run. + */ +export async function getLegacyDataInfo(): Promise<{ adminJsonHasTimestamps: boolean; auditLogInConfigDir: boolean }> { + let adminJsonHasTimestamps = false; + const adminJsonPath = getConfigPath('admin.json'); + if (existsSync(adminJsonPath)) { + try { + const raw = await readFile(adminJsonPath, 'utf-8'); + const parsed = JSON.parse(raw); + adminJsonHasTimestamps = + 'createdAt' in parsed || + 'lastLogin' in parsed || + 'passwordChangedAt' in parsed; + } catch { + /* ignore */ + } + } + + let auditLogInConfigDir = false; + try { + await stat(getConfigPath('audit.log')); + auditLogInConfigDir = true; + } catch { + /* not present - good */ + } + + return { adminJsonHasTimestamps, auditLogInConfigDir }; +} diff --git a/lib/admin/password.ts b/lib/admin/password.ts index d1043df4..955d81fb 100644 --- a/lib/admin/password.ts +++ b/lib/admin/password.ts @@ -1,9 +1,14 @@ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'; -import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; +import { readFile, writeFile, rename } from 'node:fs/promises'; import { logger } from '@/lib/logger'; -import type { AdminData } from './types'; +import { + ensureConfigDir, + ensureStateDir, + getConfigPath, + getStatePath, + assertWritable, +} from './paths'; +import type { AdminConfigData, AdminStateData } from './types'; const SCRYPT_KEYLEN = 64; const SCRYPT_COST = 16384; // 2^14 @@ -11,13 +16,8 @@ const SCRYPT_BLOCK_SIZE = 8; const SCRYPT_PARALLELIZATION = 1; const SALT_LENGTH = 32; -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} - -function getAdminJsonPath(): string { - return path.join(getAdminDir(), 'admin.json'); -} +const ADMIN_CONFIG_FILE = 'admin.json'; +const ADMIN_STATE_FILE = 'admin-state.json'; function hashPassword(password: string): Promise { return new Promise((resolve, reject) => { @@ -33,10 +33,8 @@ function hashPassword(password: string): Promise { function verifyPassword(password: string, stored: string): Promise { return new Promise((resolve, reject) => { - // Support both scrypt format and bcrypt-prefixed values if (stored.startsWith('$scrypt$')) { const parts = stored.split('$'); - // $scrypt$N=...,r=...,p=...$salt$hash if (parts.length !== 5) return resolve(false); const paramStr = parts[2]; const salt = Buffer.from(parts[3], 'base64'); @@ -53,7 +51,6 @@ function verifyPassword(password: string, stored: string): Promise { resolve(timingSafeEqual(derivedKey, storedHash)); }); } else { - // Unknown format resolve(false); } }); @@ -63,50 +60,84 @@ function isHashed(value: string): boolean { return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$'); } -async function readAdminData(): Promise { - const filePath = getAdminJsonPath(); +// ─── Disk I/O ─────────────────────────────────────────────────────────────── + +async function readJson(filePath: string): Promise { try { const raw = await readFile(filePath, 'utf-8'); - return JSON.parse(raw) as AdminData; + return JSON.parse(raw) as T; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - logger.warn('Failed to read admin.json', { error: error instanceof Error ? error.message : 'Unknown error' }); + logger.warn('Failed to read admin file', { + filePath, + error: error instanceof Error ? error.message : 'Unknown error', + }); return null; } } -async function writeAdminData(data: AdminData): Promise { - const dir = getAdminDir(); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } - const targetPath = getAdminJsonPath(); - const tmpPath = targetPath + '.tmp'; - await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); - await rename(tmpPath, targetPath); +async function readConfigData(): Promise { + return readJson(getConfigPath(ADMIN_CONFIG_FILE)); } -let cachedAdminData: AdminData | null = null; +async function readStateData(): Promise { + return readJson(getStatePath(ADMIN_STATE_FILE)); +} + +async function writeConfigData(data: AdminConfigData): Promise { + assertWritable('save admin password'); + await ensureConfigDir(); + const target = getConfigPath(ADMIN_CONFIG_FILE); + const tmp = target + '.tmp'; + await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8'); + await rename(tmp, target); +} + +async function writeStateData(data: AdminStateData): Promise { + await ensureStateDir(); + const target = getStatePath(ADMIN_STATE_FILE); + const tmp = target + '.tmp'; + await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8'); + await rename(tmp, target); +} + +// ─── Cache & init ─────────────────────────────────────────────────────────── + +let cachedConfig: AdminConfigData | null = null; +let cachedState: AdminStateData | null = null; let initialized = false; +function freshState(): AdminStateData { + const now = new Date().toISOString(); + return { createdAt: now, lastLogin: null, passwordChangedAt: now }; +} + /** * Initialize admin password on startup. - * If ADMIN_PASSWORD is cleartext, hash it and write to admin.json. - * Returns true if admin is enabled. + * - If admin.json exists, use it (state file may or may not exist; created on first need). + * - Otherwise, if ADMIN_PASSWORD env var is set, hash and persist it. + * - Otherwise, admin dashboard stays disabled. */ export async function initAdminPassword(): Promise { - if (initialized) return cachedAdminData !== null; + if (initialized) return cachedConfig !== null; - // Check persistent file first - const existing = await readAdminData(); - if (existing) { - cachedAdminData = existing; + const existingConfig = await readConfigData(); + if (existingConfig) { + cachedConfig = existingConfig; + cachedState = (await readStateData()) ?? freshState(); + if (!(await readStateData())) { + // No state file yet (fresh install or migration); create it. + try { + await writeStateData(cachedState); + } catch { + /* state dir may not be writable yet during early boot probes */ + } + } initialized = true; logger.info('Admin dashboard enabled (password loaded from admin.json)'); return true; } - // Check env var const envPassword = process.env.ADMIN_PASSWORD; if (!envPassword) { initialized = true; @@ -114,33 +145,17 @@ export async function initAdminPassword(): Promise { return false; } - if (isHashed(envPassword)) { - // Already hashed in env - save to file - const data: AdminData = { - passwordHash: envPassword, - createdAt: new Date().toISOString(), - lastLogin: null, - passwordChangedAt: new Date().toISOString(), - }; - await writeAdminData(data); - cachedAdminData = data; - initialized = true; - logger.info('Admin password hash saved to admin.json from environment variable'); - return true; - } - - // Cleartext - hash it - const hash = await hashPassword(envPassword); - const data: AdminData = { - passwordHash: hash, - createdAt: new Date().toISOString(), - lastLogin: null, - passwordChangedAt: new Date().toISOString(), - }; - await writeAdminData(data); - cachedAdminData = data; + const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword); + cachedConfig = { passwordHash: hash }; + cachedState = freshState(); + await writeConfigData(cachedConfig); + await writeStateData(cachedState); initialized = true; - logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env'); + if (isHashed(envPassword)) { + logger.info('Admin password hash saved to admin.json from environment variable'); + } else { + logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env'); + } return true; } @@ -148,11 +163,9 @@ export async function initAdminPassword(): Promise { * Verify a password against the stored admin hash. */ export async function verifyAdminPassword(password: string): Promise { - if (!cachedAdminData) { - cachedAdminData = await readAdminData(); - } - if (!cachedAdminData) return false; - return verifyPassword(password, cachedAdminData.passwordHash); + if (!cachedConfig) cachedConfig = await readConfigData(); + if (!cachedConfig) return false; + return verifyPassword(password, cachedConfig.passwordHash); } /** @@ -163,14 +176,30 @@ export async function changeAdminPassword(currentPassword: string, newPassword: if (!valid) return false; const hash = await hashPassword(newPassword); - if (!cachedAdminData) return false; + cachedConfig = { passwordHash: hash }; + await writeConfigData(cachedConfig); - cachedAdminData = { - ...cachedAdminData, - passwordHash: hash, + cachedState = { + ...(cachedState ?? freshState()), passwordChangedAt: new Date().toISOString(), }; - await writeAdminData(cachedAdminData); + await writeStateData(cachedState); + return true; +} + +/** + * Set the admin password without verifying a current one. Used by the setup + * wizard during initial bootstrap. Refuses to overwrite an existing password. + */ +export async function setInitialAdminPassword(newPassword: string): Promise { + const existing = await readConfigData(); + if (existing) return false; + const hash = await hashPassword(newPassword); + cachedConfig = { passwordHash: hash }; + cachedState = freshState(); + await writeConfigData(cachedConfig); + await writeStateData(cachedState); + initialized = true; return true; } @@ -178,29 +207,31 @@ export async function changeAdminPassword(currentPassword: string, newPassword: * Update the last login timestamp. */ export async function updateLastLogin(): Promise { - if (!cachedAdminData) return; - cachedAdminData = { - ...cachedAdminData, + if (!cachedConfig) return; + cachedState = { + ...(cachedState ?? freshState()), lastLogin: new Date().toISOString(), }; - await writeAdminData(cachedAdminData); + try { + await writeStateData(cachedState); + } catch (error) { + logger.warn('Failed to update admin last-login state', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } } /** * Check if admin dashboard is enabled (has a password configured). */ export function isAdminEnabled(): boolean { - return cachedAdminData !== null; + return cachedConfig !== null; } /** * Get admin metadata (without the hash). */ -export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null { - if (!cachedAdminData) return null; - return { - createdAt: cachedAdminData.createdAt, - lastLogin: cachedAdminData.lastLogin, - passwordChangedAt: cachedAdminData.passwordChangedAt, - }; +export function getAdminMeta(): AdminStateData | null { + if (!cachedConfig) return null; + return cachedState ?? freshState(); } diff --git a/lib/admin/paths.ts b/lib/admin/paths.ts new file mode 100644 index 00000000..b791ecaa --- /dev/null +++ b/lib/admin/paths.ts @@ -0,0 +1,126 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; + +/** + * Admin data directories. + * + * Two dirs intentionally split (issue #226): + * - CONFIG: holds operator-authored state (config.json, policy.json, + * admin.json passwordHash, plugins, themes, branding uploads). Can be + * mounted read-only after initial setup. + * - STATE: holds runtime mutations (admin-state.json with login timestamps, + * audit.log, .setup-token). Always read-write. + * + * Resolution order: + * getConfigDir() + * 1. ADMIN_CONFIG_DIR + * 2. ADMIN_DATA_DIR (legacy) + * 3. /data/admin + * + * getStateDir() + * 1. ADMIN_STATE_DIR + * 2. /state - if config dir was set explicitly + * 3. /state - back-compat: stays on the legacy volume + * 4. /data/admin-state - fresh-install default; matches the + * sibling mount in docker-compose.yml + * + * The legacy ADMIN_DATA_DIR keeps existing single-volume mounts working + * unchanged: everything ends up under it, with state in a `state/` subdir. + * Fresh installs and the docker-compose default keep state in a separate + * sibling dir so the config dir can be mounted :ro after setup. + */ + +export function getConfigDir(): string { + return ( + process.env.ADMIN_CONFIG_DIR || + process.env.ADMIN_DATA_DIR || + path.join(process.cwd(), 'data', 'admin') + ); +} + +export function getStateDir(): string { + if (process.env.ADMIN_STATE_DIR) return process.env.ADMIN_STATE_DIR; + if (process.env.ADMIN_CONFIG_DIR) { + return path.join(process.env.ADMIN_CONFIG_DIR, 'state'); + } + if (process.env.ADMIN_DATA_DIR) { + return path.join(process.env.ADMIN_DATA_DIR, 'state'); + } + return path.join(process.cwd(), 'data', 'admin-state'); +} + +export function getConfigPath(filename: string): string { + return path.join(getConfigDir(), filename); +} + +export function getStatePath(filename: string): string { + return path.join(getStateDir(), filename); +} + +export async function ensureConfigDir(): Promise { + const dir = getConfigDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +export async function ensureStateDir(): Promise { + const dir = getStateDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +// ─── Read-only mode ───────────────────────────────────────────────────────── + +let cachedReadOnly: boolean | null = null; + +/** + * Whether the config dir is locked. Operators set ADMIN_CONFIG_READONLY=true + * after running the setup wizard and remounting the volume :ro. + * + * When true, all writes to the config dir are refused at the application + * layer (cleaner error than a mid-request EROFS). + */ +export function isConfigReadOnly(): boolean { + if (cachedReadOnly !== null) return cachedReadOnly; + const v = (process.env.ADMIN_CONFIG_READONLY || '').toLowerCase(); + cachedReadOnly = v === 'true' || v === '1' || v === 'yes'; + return cachedReadOnly; +} + +/** + * Probe the config dir by writing a temp file. Used to auto-detect RO mounts + * when ADMIN_CONFIG_READONLY is not set explicitly. Run once at startup; + * cheap on local FS, can be slow on networked FS, hence opt-in. + */ +export async function probeConfigReadOnly(): Promise { + if (process.env.ADMIN_CONFIG_READONLY) return isConfigReadOnly(); + try { + const probe = path.join(getConfigDir(), '.rw-probe'); + await writeFile(probe, ''); + await unlink(probe); + cachedReadOnly = false; + return false; + } catch { + cachedReadOnly = true; + logger.info('Config dir is read-only (auto-detected)'); + return true; + } +} + +export class ConfigReadOnlyError extends Error { + constructor(operation: string) { + super( + `Cannot ${operation}: configuration is read-only. ` + + `Remount the config volume read-write or unset ADMIN_CONFIG_READONLY.` + ); + this.name = 'ConfigReadOnlyError'; + } +} + +export function assertWritable(operation: string): void { + if (isConfigReadOnly()) throw new ConfigReadOnlyError(operation); +} diff --git a/lib/admin/plugin-config.ts b/lib/admin/plugin-config.ts index bd2d8cfc..68c7e68d 100644 --- a/lib/admin/plugin-config.ts +++ b/lib/admin/plugin-config.ts @@ -2,13 +2,10 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { logger } from '@/lib/logger'; - -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} +import { getConfigDir, assertWritable } from './paths'; function getPluginConfigDir(): string { - return path.join(getAdminDir(), 'plugin-config'); + return path.join(getConfigDir(), 'plugin-config'); } function configPath(pluginId: string): string { @@ -41,6 +38,7 @@ export async function getPluginConfig(pluginId: string): Promise { + assertWritable('update plugin config'); const dir = getPluginConfigDir(); await ensureDir(dir); @@ -57,6 +55,7 @@ export async function setPluginConfig(pluginId: string, key: string, value: unkn * Delete a single config key for a plugin. */ export async function deletePluginConfigKey(pluginId: string, key: string): Promise { + assertWritable('delete plugin config key'); const config = await getPluginConfig(pluginId); delete config[key]; @@ -77,5 +76,6 @@ export async function deletePluginConfigKey(pluginId: string, key: string): Prom * Delete all config for a plugin (used when uninstalling). */ export async function deleteAllPluginConfig(pluginId: string): Promise { + assertWritable('delete plugin config'); try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ } } diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index e700eece..e09ca97a 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -3,17 +3,14 @@ import { existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { logger } from '@/lib/logger'; - -function getAdminDir(): string { - return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); -} +import { getConfigDir, assertWritable } from './paths'; function getPluginsDir(): string { - return path.join(getAdminDir(), 'plugins'); + return path.join(getConfigDir(), 'plugins'); } function getThemesDir(): string { - return path.join(getAdminDir(), 'themes'); + return path.join(getConfigDir(), 'themes'); } // ─── Types ─────────────────────────────────────────────────── @@ -141,6 +138,7 @@ export async function savePlugin( plugin: ServerPlugin, code: string, ): Promise { + assertWritable('install plugin'); const dir = getPluginsDir(); await ensureDir(dir); @@ -171,6 +169,7 @@ export async function savePlugin( } export async function updatePluginMeta(id: string, updates: Partial>): Promise { + assertWritable('update plugin metadata'); const registry = await getPluginRegistry(); const idx = registry.plugins.findIndex(p => p.id === id); if (idx < 0) return null; @@ -181,6 +180,7 @@ export async function updatePluginMeta(id: string, updates: Partial { + assertWritable('delete plugin'); const registry = await getPluginRegistry(); const idx = registry.plugins.findIndex(p => p.id === id); if (idx < 0) return false; @@ -221,6 +221,7 @@ export async function saveTheme( theme: ServerTheme, css: string, ): Promise { + assertWritable('install theme'); const dir = getThemesDir(); await ensureDir(dir); @@ -240,6 +241,7 @@ export async function saveTheme( } export async function updateThemeMeta(id: string, updates: Partial>): Promise { + assertWritable('update theme metadata'); const registry = await getThemeRegistry(); const idx = registry.themes.findIndex(t => t.id === id); if (idx < 0) return null; @@ -250,6 +252,7 @@ export async function updateThemeMeta(id: string, updates: Partial { + assertWritable('delete theme'); const registry = await getThemeRegistry(); const idx = registry.themes.findIndex(t => t.id === id); if (idx < 0) return false; diff --git a/lib/admin/session.ts b/lib/admin/session.ts index ecde855e..fb08468f 100644 --- a/lib/admin/session.ts +++ b/lib/admin/session.ts @@ -1,7 +1,7 @@ import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; -import { readFileEnv } from '@/lib/read-file-env'; +import { getSessionSecret } from '@/lib/auth/session-secret'; import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types'; import type { AdminSessionPayload } from './types'; @@ -12,7 +12,7 @@ const TAG_LENGTH = 16; const MIN_SECRET_LENGTH = 32; function getKey(): Buffer { - const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE); + const secret = getSessionSecret(); if (!secret) throw new Error('SESSION_SECRET not configured'); if (secret.length < MIN_SECRET_LENGTH) { throw new Error( diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 76fd247e..66ecf08d 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -1,12 +1,30 @@ // Admin dashboard types -export interface AdminData { +/** + * Operator-authored admin record. Lives in admin.json under the config dir + * and can be mounted read-only after setup. Only the password hash itself + * is config; mutable timestamps live in AdminStateData. + */ +export interface AdminConfigData { passwordHash: string; +} + +/** + * Runtime-mutable admin record. Lives in admin-state.json under the state + * dir. Updated on every login and password change, so it must stay writable. + */ +export interface AdminStateData { createdAt: string; lastLogin: string | null; passwordChangedAt: string; } +/** + * Combined view used by getAdminMeta() and tests. Constructed by merging + * admin.json + admin-state.json at read time. + */ +export interface AdminData extends AdminConfigData, AdminStateData {} + export interface AdminSessionPayload { role: 'admin'; iat: number; diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index 670bcc68..38eb24a4 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -1,6 +1,6 @@ import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { logger } from '@/lib/logger'; -import { readFileEnv } from '@/lib/read-file-env'; +import { getSessionSecret } from '@/lib/auth/session-secret'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; @@ -9,7 +9,7 @@ const TAG_LENGTH = 16; const MIN_SECRET_LENGTH = 32; function getKey(): Buffer { - const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE); + const secret = getSessionSecret(); if (!secret) throw new Error('SESSION_SECRET not configured'); if (secret.length < MIN_SECRET_LENGTH) { throw new Error( diff --git a/lib/auth/session-secret.ts b/lib/auth/session-secret.ts new file mode 100644 index 00000000..aceff84f --- /dev/null +++ b/lib/auth/session-secret.ts @@ -0,0 +1,31 @@ +import { configManager } from '@/lib/admin/config-manager'; +import { readFileEnv } from '@/lib/read-file-env'; + +/** + * Resolve the session secret from any of the supported sources, in priority + * order: + * 1. SESSION_SECRET env var + * 2. SESSION_SECRET_FILE-pointed file + * 3. Admin override in config.json (set by the setup wizard) + * + * Returns an empty string when nothing is configured. Callers must treat + * empty as "feature disabled" rather than crashing. + * + * The configManager fallback exists so the web installer can persist the + * secret without touching .env files. It only takes effect if the env vars + * aren't set, so existing deployments aren't affected. + */ +export function getSessionSecret(): string { + const fromEnv = process.env.SESSION_SECRET; + if (fromEnv) return fromEnv; + + const fromFile = readFileEnv(process.env.SESSION_SECRET_FILE); + if (fromFile) return fromFile; + + const fromAdmin = configManager.get('sessionSecret', ''); + return fromAdmin || ''; +} + +export function hasSessionSecret(): boolean { + return getSessionSecret().length > 0; +} diff --git a/lib/settings-sync.ts b/lib/settings-sync.ts index 297baa29..6a5fb1be 100644 --- a/lib/settings-sync.ts +++ b/lib/settings-sync.ts @@ -3,14 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { logger } from '@/lib/logger'; -import { readFileEnv } from '@/lib/read-file-env'; +import { getSessionSecret } from '@/lib/auth/session-secret'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; const TAG_LENGTH = 16; function getKey(): Buffer { - const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE); + const secret = getSessionSecret(); if (!secret) throw new Error('SESSION_SECRET not configured'); return createHash('sha256').update(secret).digest(); } diff --git a/lib/setup/session.ts b/lib/setup/session.ts new file mode 100644 index 00000000..e4922e4e --- /dev/null +++ b/lib/setup/session.ts @@ -0,0 +1,33 @@ +import { cookies } from 'next/headers'; +import { verifySetupToken } from './token'; + +export const SETUP_COOKIE = 'bulwark_setup_token'; +const COOKIE_MAX_AGE = 60 * 60; // 1 hour, matches token TTL + +/** + * The wizard "session" is just the setup token itself, set as an HttpOnly + * cookie after the operator pastes it into step 1. Subsequent step calls + * re-verify the cookie value against the .setup-token file. When the wizard + * finishes, the token file is deleted and any cookies become useless. + * + * No JWT, no separate signing key, no rotating session id. The lifecycle of + * the wizard maps 1:1 to the lifecycle of the token file. + */ + +export async function authenticateWizardRequest(): Promise { + const jar = await cookies(); + const token = jar.get(SETUP_COOKIE)?.value; + if (!token) return false; + return verifySetupToken(token); +} + +export function buildSessionCookieAttributes() { + return { + name: SETUP_COOKIE, + httpOnly: true, + sameSite: 'lax' as const, + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: COOKIE_MAX_AGE, + }; +} diff --git a/lib/setup/state.ts b/lib/setup/state.ts new file mode 100644 index 00000000..ee23f9b2 --- /dev/null +++ b/lib/setup/state.ts @@ -0,0 +1,53 @@ +import { existsSync } from 'node:fs'; +import { configManager } from '@/lib/admin/config-manager'; +import { getConfigPath, isConfigReadOnly } from '@/lib/admin/paths'; + +/** + * The three lifecycle states for the running container. + * + * bootstrap - no config persisted yet and no JMAP_SERVER_URL env. The + * setup wizard is served at /setup; everything else 302s + * there. + * configured - setup wizard finished (admin override config.json carries + * setupComplete=true). Normal app; /setup returns 404. + * env-managed - JMAP_SERVER_URL is set in the environment, so the + * operator is configuring via .env (legacy / CI path). The + * wizard stays disabled. + */ +export type SetupState = 'bootstrap' | 'configured' | 'env-managed'; + +/** + * Cheap to call on every request. configManager keeps `setupComplete` in + * memory after the initial load, so this is just env reads + an in-memory + * boolean check. + */ +export function detectSetupState(): SetupState { + if (configManager.isSetupComplete()) return 'configured'; + if (process.env.JMAP_SERVER_URL && process.env.JMAP_SERVER_URL.trim() !== '') { + return 'env-managed'; + } + // Read-only config dir + no setupComplete flag means the volume was + // mounted :ro before the wizard ran. Fall through to bootstrap so the + // failure (write attempt during wizard) surfaces with a clear error + // rather than silently 404'ing /setup. + if (isConfigReadOnly()) return 'bootstrap'; + return 'bootstrap'; +} + +/** + * Whether the wizard's UI and APIs should be reachable. + */ +export function isSetupActive(): boolean { + return detectSetupState() === 'bootstrap'; +} + +/** + * The persisted `.config-locked` marker the wizard drops when the operator + * checks "lock configuration after setup" on the review screen. Purely + * advisory - the actual locking is the operator's `:ro` mount or the + * ADMIN_CONFIG_READONLY env var. This file is what the admin UI uses to + * remind the operator that they intended to lock. + */ +export function lockMarkerExists(): boolean { + return existsSync(getConfigPath('.config-locked')); +} diff --git a/lib/setup/token.ts b/lib/setup/token.ts new file mode 100644 index 00000000..58fd4de8 --- /dev/null +++ b/lib/setup/token.ts @@ -0,0 +1,111 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFile, writeFile, unlink, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { logger } from '@/lib/logger'; +import { ensureStateDir, getStatePath } from '@/lib/admin/paths'; + +const TOKEN_FILE = '.setup-token'; +const TOKEN_BYTES = 32; +const DEFAULT_TTL_SECONDS = 60 * 60; // 1 hour + +interface TokenPayload { + token: string; + issuedAt: number; + ttlSeconds: number; +} + +/** + * Read the current token if one exists and hasn't expired. Stale tokens + * are deleted lazily - first stale read removes the file. + */ +async function readToken(): Promise { + const path = getStatePath(TOKEN_FILE); + if (!existsSync(path)) return null; + try { + const raw = await readFile(path, 'utf-8'); + const payload = JSON.parse(raw) as TokenPayload; + if (Date.now() / 1000 - payload.issuedAt > payload.ttlSeconds) { + try { await unlink(path); } catch { /* ok */ } + return null; + } + return payload; + } catch (error) { + logger.warn('Failed to read setup token', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return null; + } +} + +/** + * Generate (or refresh) the setup token. Called at startup when the app + * detects bootstrap state. Idempotent: returns the existing token if it's + * still valid, otherwise issues a fresh one. + * + * The token lands in a file in ADMIN_STATE_DIR (always writable, never + * read-only) and is also printed to the container logs so the operator + * can copy it without execing into the container. + */ +export async function ensureSetupToken(ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { + const existing = await readToken(); + if (existing) return existing.token; + + await ensureStateDir(); + const token = randomBytes(TOKEN_BYTES).toString('hex'); + const payload: TokenPayload = { + token, + issuedAt: Math.floor(Date.now() / 1000), + ttlSeconds, + }; + const path = getStatePath(TOKEN_FILE); + await writeFile(path, JSON.stringify(payload, null, 2), 'utf-8'); + return token; +} + +/** + * Verify a token submitted by the wizard. Constant-time comparison; never + * leak the stored token via timing. + */ +export async function verifySetupToken(submitted: string): Promise { + if (!submitted || typeof submitted !== 'string') return false; + const stored = await readToken(); + if (!stored) return false; + + const a = Buffer.from(submitted); + const b = Buffer.from(stored.token); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * Delete the token file. Called by the wizard's finish endpoint after + * setupComplete=true is persisted. + */ +export async function clearSetupToken(): Promise { + const path = getStatePath(TOKEN_FILE); + try { + await unlink(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + logger.warn('Failed to clear setup token', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + +/** + * For diagnostics / startup logging. + */ +export async function getTokenInfo(): Promise<{ exists: boolean; expiresInSeconds: number | null }> { + const path = getStatePath(TOKEN_FILE); + if (!existsSync(path)) return { exists: false, expiresInSeconds: null }; + try { + await stat(path); + const payload = await readToken(); + if (!payload) return { exists: false, expiresInSeconds: null }; + const elapsed = Date.now() / 1000 - payload.issuedAt; + return { exists: true, expiresInSeconds: Math.max(0, Math.floor(payload.ttlSeconds - elapsed)) }; + } catch { + return { exists: false, expiresInSeconds: null }; + } +} diff --git a/proxy.ts b/proxy.ts index fed44af2..f4b69f68 100644 --- a/proxy.ts +++ b/proxy.ts @@ -2,6 +2,8 @@ import { type NextRequest, NextResponse } from "next/server"; import createIntlMiddleware from "next-intl/middleware"; import { routing } from "./i18n/routing"; import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins"; +import { configManager } from "./lib/admin/config-manager"; +import { detectSetupState } from "./lib/setup/state"; const intlMiddleware = createIntlMiddleware(routing); @@ -11,8 +13,59 @@ const intlMiddleware = createIntlMiddleware(routing); // requests for API routes, Next internals and static assets. const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/; +function isSetupPath(pathname: string): boolean { + return ( + pathname === "/setup" || + pathname.startsWith("/setup/") || + pathname.startsWith("/api/setup") + ); +} + export async function proxy(request: NextRequest) { - if (PROXY_SKIP_PATTERN.test(request.nextUrl.pathname)) { + // Resolve setup state before deciding what to skip. The first call after + // boot triggers the config load; subsequent calls are in-memory. + await configManager.ensureLoaded(); + const setupState = detectSetupState(); + const pathname = request.nextUrl.pathname; + + if (setupState === "bootstrap") { + // Wizard active. Redirect HTML pages to /setup; let asset/internal + // requests through so the wizard UI can render. Block non-setup APIs + // with a 503 so cached SPA code doesn't silently call them. + const allowed = + isSetupPath(pathname) || + pathname === "/api/health" || + pathname.startsWith("/_next/") || + pathname.startsWith("/branding/") || + /\.[^/]+$/.test(pathname); + + if (!allowed) { + if (pathname.startsWith("/api/")) { + return new NextResponse( + JSON.stringify({ error: "setup_required", message: "Initial setup has not completed." }), + { status: 503, headers: { "content-type": "application/json" } }, + ); + } + const url = request.nextUrl.clone(); + url.pathname = "/setup"; + url.search = request.nextUrl.search; + return NextResponse.redirect(url); + } + } else if (isSetupPath(pathname)) { + // Configured / env-managed: wizard is no longer reachable. + // - HTML /setup pages → redirect to admin login so users who reload + // the URL after setup don't see a dead "Not Found" page. + // - /api/setup/* → 404 (no reason to expose these endpoints). + if (pathname.startsWith("/api/setup")) { + return new NextResponse("Not Found", { status: 404 }); + } + const url = request.nextUrl.clone(); + url.pathname = "/admin/login"; + url.search = ""; + return NextResponse.redirect(url); + } + + if (PROXY_SKIP_PATTERN.test(pathname)) { return NextResponse.next(); } @@ -50,9 +103,10 @@ export async function proxy(request: NextRequest) { `media-src 'self' blob:`, ].join("; "); - // Skip intl middleware for /admin routes - they have their own layout - const pathname = request.nextUrl.pathname; + // Skip intl middleware for /admin and /setup routes - they have their + // own layout outside the [locale] tree. const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/'); + const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/'); // When localePrefix is 'always', paths that already have a locale prefix // (e.g. /en/settings) should not be re-processed by the intl middleware - @@ -63,7 +117,7 @@ export async function proxy(request: NextRequest) { ); let intlResponse: ReturnType | null = null; - if (!isAdminRoute && !hasLocalePrefix) { + if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) { try { intlResponse = intlMiddleware(request); } catch (error) {