0a9148d178
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
259 lines
7.9 KiB
TypeScript
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 : "",
|
|
},
|
|
};
|
|
}
|