import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import type { PiWebConfigValues } from "./shared/apiTypes.js"; import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; export type PiWebConfig = PiWebConfigValues; export interface LoadedPiWebConfig { path: string; exists: boolean; config: PiWebConfig; } export interface LoadOptions { env?: NodeJS.ProcessEnv; cwd?: string; } export function defaultPiWebConfigPath(env: NodeJS.ProcessEnv = process.env): string { const xdgConfigHome = env["XDG_CONFIG_HOME"]; return join(xdgConfigHome !== undefined && xdgConfigHome !== "" ? xdgConfigHome : join(homedir(), ".config"), "pi-web", "config.json"); } export function defaultPiWebDataDir(): string { return join(homedir(), ".pi-web"); } /** * Default maximum HTTP body size (bytes) for the web/API and session daemon. * Generous headroom for base64 image attachments (well above pi's 4.5MB * per-image inline limit so several images fit in one request). */ export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; export function maxUploadBytes(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): number { const fromEnv = env["PI_WEB_MAX_UPLOAD_BYTES"]; if (fromEnv !== undefined && fromEnv !== "") { const parsed = Number(fromEnv); if (Number.isInteger(parsed) && parsed > 0) return parsed; } if (config.maxUploadBytes !== undefined) return config.maxUploadBytes; return DEFAULT_MAX_UPLOAD_BYTES; } export function piWebDataDir(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string { const configured = env["PI_WEB_DATA_DIR"]; if (configured === undefined || configured === "") return defaultPiWebDataDir(); return resolve(cwd, configured); } export function piWebConfigPath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string { const configured = env["PI_WEB_CONFIG"]; if (configured === undefined || configured === "") return defaultPiWebConfigPath(env); return resolve(cwd, configured); } export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { const env = options.env ?? process.env; const path = piWebConfigPath(env, options.cwd ?? process.cwd()); if (!existsSync(path)) return { path, exists: false, config: {} }; const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`); return { path, exists: true, config: parsePiWebConfig(parsed, path) }; } export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { const loaded = loadPiWebConfig(options); const env = options.env ?? process.env; const host = env["PI_WEB_HOST"]; const port = env["PI_WEB_PORT"] ?? env["PORT"]; const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"]; const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"]; return { ...loaded, config: { ...loaded.config, ...(host !== undefined && host !== "" ? { host } : {}), ...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}), ...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}), ...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}), }, }; } export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): LoadedPiWebConfig { const env = options.env ?? process.env; const path = piWebConfigPath(env, options.cwd ?? process.cwd()); const normalized = parsePiWebConfig(piWebConfigRecord(config), path); const existing = readExistingConfigObject(path); delete existing["host"]; delete existing["port"]; delete existing["allowedHosts"]; delete existing["shortcuts"]; delete existing["plugins"]; delete existing["maxUploadBytes"]; const merged = { ...existing, ...piWebConfigRecord(normalized) }; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); return { path, exists: true, config: normalized }; } function readExistingConfigObject(path: string): Record { if (!existsSync(path)) return {}; const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`); return parsed; } function piWebConfigRecord(config: PiWebConfig): Record { return { ...(config.host !== undefined ? { host: config.host } : {}), ...(config.port !== undefined ? { port: config.port } : {}), ...(config.allowedHosts !== undefined ? { allowedHosts: config.allowedHosts } : {}), ...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}), ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), }; } function parsePiWebConfig(value: Record, path: string): PiWebConfig { return { ...(value["host"] !== undefined ? { host: parseString(value["host"], "host", path) } : {}), ...(value["port"] !== undefined ? { port: parsePort(value["port"], "port", path) } : {}), ...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}), ...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}), ...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}), ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), }; } function parseMaxUploadBytes(value: unknown, key: string, path = "environment"): number { const bytes = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN; if (!Number.isInteger(bytes) || bytes < 1) throw new Error(`PI WEB config ${key} must be a positive integer: ${path}`); return bytes; } function parseString(value: unknown, key: string, path: string): string { if (typeof value !== "string" || value === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`); return value; } function parsePort(value: unknown, key: string, path = "environment"): number { const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN; if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`PI WEB config ${key} must be an integer from 1 to 65535: ${path}`); return port; } function parseAllowedHosts(value: unknown, path: string): string[] | true { if (value === true) return true; if (!isNonEmptyStringArray(value)) { throw new Error(`PI WEB config allowedHosts must be true or an array of non-empty strings: ${path}`); } return value; } function parseAllowedHostsEnv(value: string): string[] | true { if (value === "true") return true; return value.split(",").map((host) => host.trim()).filter((host) => host !== ""); } function parseShortcuts(value: unknown, path: string): Record { if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`); return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { if (shortcut !== null && (typeof shortcut !== "string" || shortcut === "")) { throw new Error(`PI WEB config shortcut values must be non-empty strings or null: ${path}`); } return [actionId, shortcut]; })); } function parsePlugins(value: unknown, path: string): NonNullable { if (!isRecord(value) || Array.isArray(value)) throw new Error(`PI WEB config plugins must be an object: ${path}`); return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => { if (!isPiWebPluginId(pluginId)) throw new Error(`PI WEB config plugin ids must match ${piWebPluginIdPattern.source}: ${path}`); if (!isRecord(config) || Array.isArray(config)) throw new Error(`PI WEB config plugin entries must be objects: ${path}`); const enabled = config["enabled"]; if (enabled !== undefined && typeof enabled !== "boolean") throw new Error(`PI WEB config plugin enabled values must be booleans: ${path}`); const settings = config["settings"]; if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error(`PI WEB config plugin settings must be objects: ${path}`); return [pluginId, config]; })); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isNonEmptyStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== ""); } export function examplePiWebConfig(config: PiWebConfig = {}): string { return `${JSON.stringify({ host: config.host ?? "127.0.0.1", port: config.port ?? 8504, allowedHosts: config.allowedHosts ?? [] }, null, 2)}\n`; } export function piWebConfigDir(env: NodeJS.ProcessEnv = process.env): string { return dirname(defaultPiWebConfigPath(env)); }