import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; import { authLogger, getGlobalLogLevel, setGlobalLogLevel, } from "../../utils/logger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import { NOTIFICATION_PRIVATE_ALLOWLIST_KEY, parseNotificationAllowlist, } from "../../utils/notification-egress.js"; import { createCurrentSettingsRepository, createCurrentUserRepository, } from "../repositories/factory.js"; import type { UserRecord } from "../repositories/user-repository.js"; import { isForceHttpsEnabled, setForceHttps } from "../../tls/https-policy.js"; import { getTlsConfig } from "../../tls/tls-service.js"; async function getAdminActor( userId: string | undefined, ): Promise { if (!userId) return null; const user = await createCurrentUserRepository().findById(userId); return user?.isAdmin ? user : null; } export function registerUserSettingsRoutes( router: Router, authenticateJWT: RequestHandler, ): void { /** * @openapi * /users/log-level: * get: * summary: Get log level setting * description: Returns the configured log verbosity level. * tags: * - Users * responses: * 200: * description: Current log level. */ router.get("/log-level", authenticateJWT, async (_req, res) => { try { const level = await createCurrentSettingsRepository().get("log_level"); res.json({ level: level ?? getGlobalLogLevel(), }); } catch (err) { authLogger.error("Failed to get log level", err); res.status(500).json({ error: "Failed to get log level" }); } }); /** * @openapi * /users/log-level: * patch: * summary: Update log level setting (admin only) * description: Sets the log verbosity level. * tags: * - Users * responses: * 200: * description: Log level updated. * 400: * description: Invalid log level. * 403: * description: Not authorized. */ router.patch("/log-level", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { level } = req.body; const validLevels = ["debug", "info", "warn", "error"]; if (typeof level !== "string" || !validLevels.includes(level)) { return res .status(400) .json({ error: "level must be one of: debug, info, warn, error" }); } await createCurrentSettingsRepository().set("log_level", level); setGlobalLogLevel(level); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_log_level", resourceType: "setting", details: JSON.stringify({ level }), ipAddress, userAgent, success: true, }); res.json({ level }); } catch (err) { authLogger.error("Failed to set log level", err); res.status(500).json({ error: "Failed to set log level" }); } }); /** * @openapi * /users/session-timeout: * get: * summary: Get session timeout setting * description: Returns the configured session timeout in hours. * tags: * - Users * responses: * 200: * description: Current session timeout hours. */ router.get("/session-timeout", authenticateJWT, async (_req, res) => { try { const value = await createCurrentSettingsRepository().get( "session_timeout_hours", ); res.json({ timeoutHours: value ? parseInt(value, 10) : 24, }); } catch (err) { authLogger.error("Failed to get session timeout", err); res.status(500).json({ error: "Failed to get session timeout" }); } }); /** * @openapi * /users/session-timeout: * patch: * summary: Update session timeout setting (admin only) * description: Sets the session timeout in hours. * tags: * - Users * responses: * 200: * description: Session timeout updated. * 400: * description: Invalid value. * 403: * description: Not authorized. */ router.patch("/session-timeout", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { timeoutHours } = req.body; if ( typeof timeoutHours !== "number" || timeoutHours < 1 || timeoutHours > 720 ) { return res .status(400) .json({ error: "timeoutHours must be between 1 and 720" }); } await createCurrentSettingsRepository().set( "session_timeout_hours", String(timeoutHours), ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_session_timeout", resourceType: "setting", details: JSON.stringify({ timeoutHours }), ipAddress, userAgent, success: true, }); res.json({ timeoutHours }); } catch (err) { authLogger.error("Failed to set session timeout", err); res.status(500).json({ error: "Failed to set session timeout" }); } }); /** * @openapi * /users/https-settings: * get: * summary: Get the HTTPS redirect setting (admin only) * description: Whether plain HTTP is redirected to HTTPS with HSTS, and whether HTTPS is on at all. The redirect only applies while HTTPS is on. * tags: * - Users * responses: * 200: * description: forceHttps and tlsEnabled. * 403: * description: Not authorized. */ router.get("/https-settings", authenticateJWT, async (req, res) => { try { if (!(await getAdminActor((req as AuthenticatedRequest).userId))) { return res.status(403).json({ error: "Not authorized" }); } res.json({ forceHttps: await isForceHttpsEnabled(), tlsEnabled: getTlsConfig().enabled, }); } catch (err) { authLogger.error("Failed to get HTTPS settings", err); res.status(500).json({ error: "Failed to get HTTPS settings" }); } }); /** * @openapi * /users/https-settings: * patch: * summary: Update the HTTPS redirect setting (admin only) * description: Turns the redirect from HTTP to HTTPS and the HSTS header on or off. Off by default so reverse proxies keep working. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * forceHttps: * type: boolean * responses: * 200: * description: Setting updated. * 400: * description: Invalid value. * 403: * description: Not authorized. */ router.patch("/https-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { forceHttps } = req.body ?? {}; if (typeof forceHttps !== "boolean") { return res.status(400).json({ error: "forceHttps must be a boolean" }); } await setForceHttps(forceHttps); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_force_https", resourceType: "setting", details: JSON.stringify({ forceHttps }), ipAddress, userAgent, success: true, }); res.json({ forceHttps, tlsEnabled: getTlsConfig().enabled }); } catch (err) { authLogger.error("Failed to set HTTPS settings", err); res.status(500).json({ error: "Failed to set HTTPS settings" }); } }); /** * GET/PATCH a comma-list of private hosts an outbound feature may reach. * One route pair per private endpoint allowlist, each with its own setting key. */ const registerPrivateEndpointAllowlist = ( path: string, settingKey: string, auditAction: string, label: string, ) => { router.get(path, authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { if (!(await getAdminActor(userId))) { return res.status(403).json({ error: "Not authorized" }); } const raw = await createCurrentSettingsRepository().get(settingKey); res.json({ hosts: parseNotificationAllowlist(raw) }); } catch (err) { authLogger.error(`Failed to get ${label} allowlist`, err); res.status(500).json({ error: "Failed to get the allowlist" }); } }); router.patch(path, authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { hosts } = req.body; if (!Array.isArray(hosts)) { return res.status(400).json({ error: "hosts must be an array" }); } if (hosts.length > 50) { return res .status(400) .json({ error: "At most 50 hosts are allowed" }); } const cleaned: string[] = []; for (const entry of hosts) { if (typeof entry !== "string") { return res .status(400) .json({ error: "Each host must be a string" }); } const host = entry.trim().toLowerCase(); if (!host) continue; if (!/^[a-z0-9._:-]+$/.test(host)) { return res .status(400) .json({ error: `${entry} is not a valid hostname` }); } if (!cleaned.includes(host)) cleaned.push(host); } await createCurrentSettingsRepository().set( settingKey, JSON.stringify(cleaned), ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: auditAction, resourceType: "setting", details: JSON.stringify({ hosts: cleaned }), ipAddress, userAgent, success: true, }); res.json({ hosts: cleaned }); } catch (err) { authLogger.error(`Failed to update ${label} allowlist`, err); res.status(500).json({ error: "Failed to update the allowlist" }); } }); }; /** * @openapi * /users/notification-private-endpoints: * get: * summary: Get the private hosts notification channels may contact (admin only) * tags: * - Users * patch: * summary: Replace that allowlist (admin only) * tags: * - Users */ registerPrivateEndpointAllowlist( "/notification-private-endpoints", NOTIFICATION_PRIVATE_ALLOWLIST_KEY, "update_notification_private_endpoints", "notification endpoint", ); }