feat: web setup wizard + admin config/state dir split (#226)
This commit is contained in:
+21
-4
@@ -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
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
|
||||
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
||||
</div>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
|
||||
@@ -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<boolean>('settingsSyncEnabled', false);
|
||||
return flagOn && hasSessionSecret();
|
||||
}
|
||||
|
||||
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||
|
||||
@@ -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<string>('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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 `<key>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<string, unknown> | 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<string, unknown> = {};
|
||||
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' } },
|
||||
);
|
||||
}
|
||||
@@ -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<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 updates: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function SetupLayout({ children }: { children: ReactNode }) {
|
||||
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
|
||||
}
|
||||
+1288
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <br> 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;
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
+7
-1
@@ -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:
|
||||
|
||||
+31
-3
@@ -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://<host>:${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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+7
-14
@@ -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<string, unknown>, ip: string): Promise<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
|
||||
+36
-14
@@ -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<string, unknown>): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
assertWritable('update settings policy');
|
||||
this.policyCache = {
|
||||
...DEFAULT_POLICY,
|
||||
...policy,
|
||||
@@ -167,7 +183,7 @@ class ConfigManager {
|
||||
}
|
||||
|
||||
private async readJsonFile(filename: string): Promise<Record<string, unknown> | 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<string, unknown>): Promise<void> {
|
||||
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());
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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 };
|
||||
}
|
||||
+113
-82
@@ -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<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -33,10 +33,8 @@ function hashPassword(password: string): Promise<string> {
|
||||
|
||||
function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<AdminData | null> {
|
||||
const filePath = getAdminJsonPath();
|
||||
// ─── Disk I/O ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function readJson<T>(filePath: string): Promise<T | null> {
|
||||
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<void> {
|
||||
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<AdminConfigData | null> {
|
||||
return readJson<AdminConfigData>(getConfigPath(ADMIN_CONFIG_FILE));
|
||||
}
|
||||
|
||||
let cachedAdminData: AdminData | null = null;
|
||||
async function readStateData(): Promise<AdminStateData | null> {
|
||||
return readJson<AdminStateData>(getStatePath(ADMIN_STATE_FILE));
|
||||
}
|
||||
|
||||
async function writeConfigData(data: AdminConfigData): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
|
||||
cachedConfig = { passwordHash: hash };
|
||||
cachedState = freshState();
|
||||
await writeConfigData(cachedConfig);
|
||||
await writeStateData(cachedState);
|
||||
initialized = true;
|
||||
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;
|
||||
initialized = true;
|
||||
} 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<boolean> {
|
||||
* Verify a password against the stored admin hash.
|
||||
*/
|
||||
export async function verifyAdminPassword(password: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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. <cwd>/data/admin
|
||||
*
|
||||
* getStateDir()
|
||||
* 1. ADMIN_STATE_DIR
|
||||
* 2. <ADMIN_CONFIG_DIR>/state - if config dir was set explicitly
|
||||
* 3. <ADMIN_DATA_DIR>/state - back-compat: stays on the legacy volume
|
||||
* 4. <cwd>/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<void> {
|
||||
const dir = getConfigDir();
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureStateDir(): Promise<void> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
@@ -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<Record<string,
|
||||
* Set a single config key for a plugin.
|
||||
*/
|
||||
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
assertWritable('delete plugin config');
|
||||
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
|
||||
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<Pick<ServerP
|
||||
}
|
||||
|
||||
export async function deletePlugin(id: string): Promise<boolean> {
|
||||
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<void> {
|
||||
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<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
|
||||
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<Pick<ServerTh
|
||||
}
|
||||
|
||||
export async function deleteTheme(id: string): Promise<boolean> {
|
||||
assertWritable('delete theme');
|
||||
const registry = await getThemeRegistry();
|
||||
const idx = registry.themes.findIndex(t => t.id === id);
|
||||
if (idx < 0) return false;
|
||||
|
||||
@@ -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(
|
||||
|
||||
+19
-1
@@ -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;
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
@@ -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<string>('sessionSecret', '');
|
||||
return fromAdmin || '';
|
||||
}
|
||||
|
||||
export function hasSessionSecret(): boolean {
|
||||
return getSessionSecret().length > 0;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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<TokenPayload | null> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<typeof intlMiddleware> | null = null;
|
||||
if (!isAdminRoute && !hasLocalePrefix) {
|
||||
if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) {
|
||||
try {
|
||||
intlResponse = intlMiddleware(request);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user