Files
Termix/src/backend/database/routes/user-settings-routes.ts
T

376 lines
12 KiB
TypeScript
Raw Normal View History

2026-06-04 15:16:53 -04:00
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import {
authLogger,
getGlobalLogLevel,
setGlobalLogLevel,
} from "../../utils/logger.js";
+2
2026-06-16 15:59:53 -05:00
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
+17
2026-09-20 14:56:39 -05:00
import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
parseNotificationAllowlist,
} from "../../utils/notification-egress.js";
2026-06-29 13:28:26 -05:00
import {
+8
2026-07-19 12:29:52 -05:00
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
2026-10-20 16:03:29 -05:00
import { isForceHttpsEnabled, setForceHttps } from "../../tls/https-policy.js";
import { getTlsConfig } from "../../tls/tls-service.js";
+8
2026-07-19 12:29:52 -05:00
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
if (!userId) return null;
const user = await createCurrentUserRepository().findById(userId);
return user?.isAdmin ? user : null;
}
2026-06-04 15:16:53 -04:00
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 {
+8
2026-07-19 12:29:52 -05:00
const level = await createCurrentSettingsRepository().get("log_level");
2026-06-04 15:16:53 -04:00
res.json({
+8
2026-07-19 12:29:52 -05:00
level: level ?? getGlobalLogLevel(),
2026-06-04 15:16:53 -04:00
});
} 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 {
+8
2026-07-19 12:29:52 -05:00
const actor = await getAdminActor(userId);
if (!actor) {
2026-06-04 15:16:53 -04:00
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" });
}
+8
2026-07-19 12:29:52 -05:00
await createCurrentSettingsRepository().set("log_level", level);
2026-06-04 15:16:53 -04:00
setGlobalLogLevel(level);
+2
2026-06-16 15:59:53 -05:00
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
+8
2026-07-19 12:29:52 -05:00
username: actor.username ?? userId,
+2
2026-06-16 15:59:53 -05:00
action: "update_log_level",
resourceType: "setting",
details: JSON.stringify({ level }),
ipAddress,
userAgent,
success: true,
});
2026-06-04 15:16:53 -04:00
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 {
+8
2026-07-19 12:29:52 -05:00
const value = await createCurrentSettingsRepository().get(
"session_timeout_hours",
);
2026-06-04 15:16:53 -04:00
res.json({
+8
2026-07-19 12:29:52 -05:00
timeoutHours: value ? parseInt(value, 10) : 24,
2026-06-04 15:16:53 -04:00
});
} 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 {
+8
2026-07-19 12:29:52 -05:00
const actor = await getAdminActor(userId);
if (!actor) {
2026-06-04 15:16:53 -04:00
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" });
}
+8
2026-07-19 12:29:52 -05:00
await createCurrentSettingsRepository().set(
"session_timeout_hours",
String(timeoutHours),
);
+2
2026-06-16 15:59:53 -05:00
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
+8
2026-07-19 12:29:52 -05:00
username: actor.username ?? userId,
+2
2026-06-16 15:59:53 -05:00
action: "update_session_timeout",
resourceType: "setting",
details: JSON.stringify({ timeoutHours }),
ipAddress,
userAgent,
success: true,
});
2026-06-04 15:16:53 -04:00
res.json({ timeoutHours });
} catch (err) {
authLogger.error("Failed to set session timeout", err);
res.status(500).json({ error: "Failed to set session timeout" });
}
});
+2
2026-06-16 15:59:53 -05:00
+17
2026-09-20 14:56:39 -05:00
/**
* @openapi
2026-10-20 16:03:29 -05:00
* /users/https-settings:
+17
2026-09-20 14:56:39 -05:00
* get:
2026-10-20 16:03:29 -05:00
* 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.
+17
2026-09-20 14:56:39 -05:00
* tags:
* - Users
* responses:
* 200:
2026-10-20 16:03:29 -05:00
* description: forceHttps and tlsEnabled.
* 403:
* description: Not authorized.
+17
2026-09-20 14:56:39 -05:00
*/
2026-10-20 16:03:29 -05:00
router.get("/https-settings", authenticateJWT, async (req, res) => {
+17
2026-09-20 14:56:39 -05:00
try {
2026-10-20 16:03:29 -05:00
if (!(await getAdminActor((req as AuthenticatedRequest).userId))) {
+17
2026-09-20 14:56:39 -05:00
return res.status(403).json({ error: "Not authorized" });
}
res.json({
2026-10-20 16:03:29 -05:00
forceHttps: await isForceHttpsEnabled(),
tlsEnabled: getTlsConfig().enabled,
+17
2026-09-20 14:56:39 -05:00
});
} catch (err) {
2026-10-20 16:03:29 -05:00
authLogger.error("Failed to get HTTPS settings", err);
res.status(500).json({ error: "Failed to get HTTPS settings" });
+17
2026-09-20 14:56:39 -05:00
}
});
/**
* @openapi
2026-10-20 16:03:29 -05:00
* /users/https-settings:
+17
2026-09-20 14:56:39 -05:00
* patch:
2026-10-20 16:03:29 -05:00
* 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.
+17
2026-09-20 14:56:39 -05:00
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
2026-10-20 16:03:29 -05:00
* forceHttps:
* type: boolean
+17
2026-09-20 14:56:39 -05:00
* responses:
* 200:
* description: Setting updated.
2026-10-20 16:03:29 -05:00
* 400:
* description: Invalid value.
+17
2026-09-20 14:56:39 -05:00
* 403:
* description: Not authorized.
*/
2026-10-20 16:03:29 -05:00
router.patch("/https-settings", authenticateJWT, async (req, res) => {
+17
2026-09-20 14:56:39 -05:00
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
2026-10-20 16:03:29 -05:00
const { forceHttps } = req.body ?? {};
if (typeof forceHttps !== "boolean") {
return res.status(400).json({ error: "forceHttps must be a boolean" });
+17
2026-09-20 14:56:39 -05:00
}
2026-10-20 16:03:29 -05:00
await setForceHttps(forceHttps);
+17
2026-09-20 14:56:39 -05:00
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
2026-10-20 16:03:29 -05:00
action: "update_force_https",
+17
2026-09-20 14:56:39 -05:00
resourceType: "setting",
2026-10-20 16:03:29 -05:00
details: JSON.stringify({ forceHttps }),
+17
2026-09-20 14:56:39 -05:00
ipAddress,
userAgent,
success: true,
});
2026-10-20 16:03:29 -05:00
res.json({ forceHttps, tlsEnabled: getTlsConfig().enabled });
+17
2026-09-20 14:56:39 -05:00
} catch (err) {
2026-10-20 16:03:29 -05:00
authLogger.error("Failed to set HTTPS settings", err);
res.status(500).json({ error: "Failed to set HTTPS settings" });
+17
2026-09-20 14:56:39 -05:00
}
});
/**
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
2026-10-01 16:20:16 -05:00
* One route pair per private endpoint allowlist, each with its own setting key.
+17
2026-09-20 14:56:39 -05:00
*/
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",
);
2026-06-04 15:16:53 -04:00
}