This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
pi-web/src/server/configRoutes.ts
T

167 lines
6.9 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { effectivePiWebConfig, loadPiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js";
export interface PiWebConfigService {
read: () => PiWebConfigResponse | Promise<PiWebConfigResponse>;
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
}
export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService {
return {
read: () => currentPiWebConfigResponse(options),
write: (config) => {
savePiWebConfig(config, options);
return currentPiWebConfigResponse(options);
},
};
}
export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConfigResponse {
const loaded = loadPiWebConfig(options);
const effective = effectivePiWebConfig(options);
const env = options.env ?? process.env;
return {
path: loaded.path,
exists: loaded.exists,
config: loaded.config,
effectiveConfig: effective.config,
envOverrides: piWebConfigEnvOverrides(env),
};
}
export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void {
app.get("/api/config", async (_request, reply) => {
try {
return await service.read();
} catch (error) {
return reply.code(500).send({ error: errorMessage(error) });
}
});
app.put<{ Body: { config?: unknown } | undefined }>("/api/config", async (request, reply) => {
try {
return await service.write(parseConfigRequest(request.body?.config));
} catch (error) {
const status = isConfigValidationError(error) ? 400 : 500;
return reply.code(status).send({ error: errorMessage(error) });
}
});
}
function parseConfigRequest(value: unknown): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
const config: PiWebConfig = {};
const host = value["host"];
const port = value["port"];
const allowedHosts = value["allowedHosts"];
const shortcuts = value["shortcuts"];
const plugins = value["plugins"];
const pathAccess = value["pathAccess"];
const maxUploadBytes = value["maxUploadBytes"];
const spawnSessions = value["spawnSessions"];
const subsessions = value["subsessions"];
if (host !== undefined) {
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
config.host = host;
}
if (port !== undefined) {
if (typeof port !== "number") throw new Error("PI WEB config port must be a number");
config.port = port;
}
if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts);
if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts);
if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins);
if (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess);
if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes);
if (spawnSessions !== undefined) {
if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean");
config.spawnSessions = spawnSessions;
}
if (subsessions !== undefined) {
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
config.subsessions = subsessions;
}
return config;
}
function parseAllowedHostsRequest(value: unknown): string[] | true {
if (value === true) return true;
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
throw new Error("PI WEB config allowedHosts must be true or an array of strings");
}
return value;
}
function parseShortcutsRequest(value: unknown): Record<string, string | null> {
if (!isRecord(value)) throw new Error("PI WEB config shortcuts must be an object");
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");
return [actionId, shortcut];
}));
}
function parsePathAccessRequest(value: unknown): NonNullable<PiWebConfig["pathAccess"]> {
if (!isRecord(value)) throw new Error("PI WEB config pathAccess must be an object");
const allowedPaths = value["allowedPaths"];
return {
...(allowedPaths === undefined ? {} : { allowedPaths: parseAllowedPathsRequest(allowedPaths) }),
};
}
function parseAllowedPathsRequest(value: unknown): string[] {
if (!isNonEmptyStringArray(value)) {
throw new Error("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
}
return value;
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
}
function parseMaxUploadBytesRequest(value: unknown): number {
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new Error("PI WEB config maxUploadBytes must be a positive integer");
return value;
}
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object");
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
if (!isPiWebPluginId(pluginId)) throw new Error("PI WEB config plugin ids are invalid");
if (!isRecord(config) || Array.isArray(config)) throw new Error("PI WEB config plugin entries must be objects");
const enabled = config["enabled"];
if (enabled !== undefined && typeof enabled !== "boolean") throw new Error("PI WEB config plugin enabled values must be booleans");
const settings = config["settings"];
if (settings !== undefined && (!isRecord(settings) || Array.isArray(settings))) throw new Error("PI WEB config plugin settings must be objects");
return [pluginId, config];
}));
}
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
return {
host: isEnvSet(env["PI_WEB_HOST"]),
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
};
}
function isEnvSet(value: string | undefined): boolean {
return value !== undefined && value !== "";
}
function isConfigValidationError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith("PI WEB config");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}