Files
EvoScientist-WebUI/src/lib/server/systemConfig.ts
T
2026-08-11 08:35:52 +08:00

259 lines
7.9 KiB
TypeScript

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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function merge(raw: unknown): SystemConfig {
const source = isRecord(raw) ? raw : {};
const pick = <K extends keyof SystemConfig>(key: K): Partial<SystemConfig[K]> =>
isRecord(source[key]) ? (source[key] as Partial<SystemConfig[K]>) : {};
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<string, unknown> | undefined)?.schedule)
? ((source.backup as Record<string, unknown>).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 : "",
},
};
}