144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
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 { effectiveConsent, loadState, saveState, reschedule } from '@/lib/telemetry';
|
|
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<string, string[]> = {
|
|
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<string, unknown> }
|
|
*
|
|
* 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 valuesObj = { ...(values as Record<string, unknown>) };
|
|
|
|
// Telemetry consent lives in the telemetry state file, not admin config, so
|
|
// it has no CONFIG_ENV_MAP entry. Pull it out of the security step and
|
|
// persist it directly, mirroring POST /api/admin/telemetry (set-consent).
|
|
if (step === 'security' && 'telemetryConsent' in valuesObj) {
|
|
const consent = valuesObj.telemetryConsent;
|
|
delete valuesObj.telemetryConsent;
|
|
if (consent !== 'on' && consent !== 'off') {
|
|
return NextResponse.json({ error: 'telemetryConsent must be "on" or "off"' }, { status: 400 });
|
|
}
|
|
// A BULWARK_TELEMETRY env var hard-locks the choice; don't fight it.
|
|
const { source } = await effectiveConsent();
|
|
if (source !== 'env') {
|
|
const tstate = await loadState();
|
|
tstate.consent = consent;
|
|
if (consent === 'on' && !tstate.consentedAt) {
|
|
tstate.consentedAt = new Date().toISOString();
|
|
}
|
|
await saveState(tstate);
|
|
await reschedule();
|
|
}
|
|
}
|
|
|
|
const updates: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(valuesObj)) {
|
|
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 });
|
|
}
|
|
}
|