import "server-only"; import fs from "node:fs"; import path from "node:path"; import { sessionTtlSeconds } from "@/lib/auth"; import { webuiDataDir } from "./dataDir"; export interface SystemConfig { branding: { wordmark: string; logoFile: string | null; faviconFile: string | null }; loginTerms: { enabled: boolean; markdown: string }; security: { authEnabled: boolean | null; sessionTtlHours: number | null; passwordMinLength: number | null; captchaEnabled: boolean | null; captchaThreshold: number | null; }; backup: { backendDataDir: string; schedule: { enabled: boolean; intervalHours: number; keepCount: number }; }; } export class SystemConfigError extends Error {} const DEFAULTS: SystemConfig = { branding: { wordmark: "AI4Scientist", logoFile: null, faviconFile: null }, loginTerms: { enabled: false, markdown: "" }, security: { authEnabled: null, sessionTtlHours: null, passwordMinLength: null, captchaEnabled: null, captchaThreshold: null, }, backup: { backendDataDir: "", schedule: { enabled: false, intervalHours: 24, keepCount: 7 }, }, }; const globalCache = globalThis as { __evoscientistSystemConfig?: { mtimeMs: number; config: SystemConfig }; }; export function systemConfigPath(): string { return path.join(webuiDataDir(), "system-config.json"); } export function brandingDir(): string { return path.join(webuiDataDir(), "branding"); } export function resetSystemConfigCacheForTests(): void { globalCache.__evoscientistSystemConfig = undefined; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function merge(raw: unknown): SystemConfig { const source = isRecord(raw) ? raw : {}; const pick = (key: K): Partial => isRecord(source[key]) ? (source[key] as Partial) : {}; return { branding: { ...DEFAULTS.branding, ...pick("branding") }, loginTerms: { ...DEFAULTS.loginTerms, ...pick("loginTerms") }, security: { ...DEFAULTS.security, ...pick("security") }, backup: { ...DEFAULTS.backup, ...pick("backup"), schedule: { ...DEFAULTS.backup.schedule, ...(isRecord((source.backup as Record | undefined)?.schedule) ? ((source.backup as Record).schedule as Partial< SystemConfig["backup"]["schedule"] >) : {}), }, }, }; } export function getSystemConfig(): SystemConfig { const file = systemConfigPath(); let mtimeMs = 0; try { mtimeMs = fs.statSync(file).mtimeMs; } catch { // Absent config file: defaults. } const cached = globalCache.__evoscientistSystemConfig; if (cached && cached.mtimeMs === mtimeMs) return cached.config; let raw: unknown = null; if (mtimeMs > 0) { try { raw = JSON.parse(fs.readFileSync(file, "utf8")); } catch { raw = null; // Corrupt file: defaults, never crash the page. } } const config = merge(raw); globalCache.__evoscientistSystemConfig = { mtimeMs, config }; return config; } export function saveSystemConfig(config: SystemConfig): void { const file = systemConfigPath(); fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 }); fs.renameSync(tmp, file); resetSystemConfigCacheForTests(); } function optBool(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } function optInt( value: unknown, min: number, max: number, label: string ): number | null { if (value === undefined || value === null) return null; if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) { throw new SystemConfigError(`${label} must be an integer between ${min} and ${max}.`); } return value as number; } export function validateSystemConfig(input: unknown): SystemConfig { const source = isRecord(input) ? input : {}; const merged = merge(source); const wordmark = merged.branding.wordmark; if ( typeof wordmark !== "string" || wordmark.trim().length === 0 || wordmark.length > 30 ) { throw new SystemConfigError("Wordmark must be 1-30 characters."); } merged.branding.wordmark = wordmark.trim(); for (const key of ["logoFile", "faviconFile"] as const) { const value = merged.branding[key]; if (value !== null && typeof value !== "string") { throw new SystemConfigError(`branding.${key} must be a string or null.`); } } merged.loginTerms.enabled = merged.loginTerms.enabled === true; if ( typeof merged.loginTerms.markdown !== "string" || merged.loginTerms.markdown.length > 20000 ) { throw new SystemConfigError("Login terms must be text of at most 20000 characters."); } const rawSecurity = isRecord(source.security) ? source.security : {}; merged.security = { authEnabled: optBool(rawSecurity.authEnabled), sessionTtlHours: optInt(rawSecurity.sessionTtlHours, 1, 720, "Session TTL"), passwordMinLength: optInt(rawSecurity.passwordMinLength, 6, 64, "Password minimum length"), captchaEnabled: optBool(rawSecurity.captchaEnabled), captchaThreshold: optInt(rawSecurity.captchaThreshold, 1, 10, "Captcha threshold"), }; const rawBackup = isRecord(source.backup) ? source.backup : {}; const backendDataDir = typeof rawBackup.backendDataDir === "string" ? rawBackup.backendDataDir.trim() : ""; if (backendDataDir.length > 500) { throw new SystemConfigError("Backend data dir is too long."); } if (backendDataDir) { let stat: fs.Stats; try { stat = fs.statSync(backendDataDir); } catch { throw new SystemConfigError( `Backend data dir does not exist: ${backendDataDir}` ); } if (!stat.isDirectory()) { throw new SystemConfigError( `Backend data dir is not a directory: ${backendDataDir}` ); } } const rawSchedule = isRecord(rawBackup.schedule) ? rawBackup.schedule : {}; merged.backup = { backendDataDir, schedule: { enabled: rawSchedule.enabled === true, intervalHours: optInt(rawSchedule.intervalHours, 1, 168, "Backup interval") ?? DEFAULTS.backup.schedule.intervalHours, keepCount: optInt(rawSchedule.keepCount, 1, 100, "Backup keep count") ?? DEFAULTS.backup.schedule.keepCount, }, }; return merged; } export interface EffectiveSecurity { authEnabled: boolean; sessionTtlSeconds: number; passwordMinLength: number; captchaEnabled: boolean; captchaThreshold: number; } export function effectiveSecurity(): EffectiveSecurity { const security = getSystemConfig().security; return { authEnabled: security.authEnabled ?? process.env.WEBUI_AUTH_ENABLED === "true", sessionTtlSeconds: security.sessionTtlHours ? security.sessionTtlHours * 60 * 60 : sessionTtlSeconds(), passwordMinLength: security.passwordMinLength ?? 8, captchaEnabled: security.captchaEnabled ?? true, captchaThreshold: security.captchaThreshold ?? 3, }; } export function brandingVersion(kind: "logo" | "favicon"): number { const file = kind === "logo" ? getSystemConfig().branding.logoFile : getSystemConfig().branding.faviconFile; if (!file) return 0; try { return fs.statSync(path.join(brandingDir(), file)).mtimeMs; } catch { return 0; } } export interface PublicSystemConfig { wordmark: string; logoVersion: number; faviconVersion: number; loginTerms: { enabled: boolean; markdown: string }; } export function publicSystemConfig(): PublicSystemConfig { const config = getSystemConfig(); return { wordmark: config.branding.wordmark, logoVersion: brandingVersion("logo"), faviconVersion: brandingVersion("favicon"), loginTerms: { enabled: config.loginTerms.enabled, markdown: config.loginTerms.enabled ? config.loginTerms.markdown : "", }, }; }