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

441 lines
15 KiB
TypeScript

import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { createCurrentUserPreferenceRepository } from "../repositories/factory.js";
import type {
UserPreferenceRecord,
UserPreferenceUpdate,
} from "../repositories/user-preference-repository.js";
import { isValidKeybinding } from "./keybinding-validation.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const pickPreferences = (row?: UserPreferenceRecord | null) => ({
reopenTabsOnLogin: row?.reopenTabsOnLogin ?? false,
theme: row?.theme ?? null,
fontSize: row?.fontSize ?? null,
accentColor: row?.accentColor ?? null,
language: row?.language ?? null,
storageMode: row?.storageMode ?? "cloud",
commandAutocomplete: row?.commandAutocomplete ?? null,
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
showHostTags: row?.showHostTags ?? null,
hostTrayOnClick: row?.hostTrayOnClick ?? null,
pinAppRail: row?.pinAppRail ?? null,
expandAppRailOnHover: row?.expandAppRailOnHover ?? null,
showPinAppRailButton: row?.showPinAppRailButton ?? null,
foldersCollapsed: row?.foldersCollapsed ?? null,
confirmSnippetExecution: row?.confirmSnippetExecution ?? null,
disableUpdateCheck: row?.disableUpdateCheck ?? null,
confirmTabClose: row?.confirmTabClose ?? null,
hiddenRailTabs: row?.hiddenRailTabs ?? null,
compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null,
customThemes: row?.customThemes ?? null,
customKeybindings: row?.customKeybindings ?? null,
terminalDefaults: row?.terminalDefaults ?? null,
terminalMacros: row?.terminalMacros ?? null,
});
const connectionDefaultFields = ["terminalDefaults"] as const;
export function validateDefaultsJson(value: string): boolean {
if (value.length > 32_768) return false;
try {
const parsed = JSON.parse(value);
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
return false;
}
}
/**
* @openapi
* /user-preferences:
* get:
* summary: Get preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences. foldersCollapsed is likewise legacy and read-only; its authoritative copy is the snippets plugin's own user settings.
* tags:
* - User Preferences
* responses:
* 200:
* description: User preferences.
* content:
* application/json:
* schema:
* type: object
* properties:
* reopenTabsOnLogin:
* type: boolean
* theme:
* type: string
* nullable: true
* fontSize:
* type: string
* nullable: true
* accentColor:
* type: string
* nullable: true
* language:
* type: string
* nullable: true
* storageMode:
* type: string
* nullable: true
* commandAutocomplete:
* type: boolean
* nullable: true
* commandPaletteEnabled:
* type: boolean
* nullable: true
* showHostTags:
* type: boolean
* nullable: true
* hostTrayOnClick:
* type: boolean
* nullable: true
* pinAppRail:
* type: boolean
* nullable: true
* expandAppRailOnHover:
* type: boolean
* nullable: true
* showPinAppRailButton:
* type: boolean
* nullable: true
* foldersCollapsed:
* type: boolean
* nullable: true
* confirmSnippetExecution:
* type: boolean
* nullable: true
* disableUpdateCheck:
* type: boolean
* nullable: true
* confirmTabClose:
* type: boolean
* nullable: true
* hiddenRailTabs:
* type: string
* nullable: true
* compactHostView:
* type: boolean
* nullable: true
* statusColorScheme:
* type: string
* nullable: true
* customThemes:
* type: string
* nullable: true
* description: JSON-encoded array of the user's saved global custom terminal themes.
* customKeybindings:
* type: string
* nullable: true
* description: JSON-encoded array of the user's custom terminal keybindings.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
return res.json(pickPreferences(preferences));
} catch (e) {
databaseLogger.error("Failed to get user preferences", e, {
operation: "get_user_preferences",
userId,
});
return res.status(500).json({ error: "Failed to get user preferences" });
}
});
/**
* @openapi
* /user-preferences:
* put:
* summary: Update preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. foldersCollapsed is no longer accepted either, now that it is a snippets plugin user setting.
* tags:
* - User Preferences
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* reopenTabsOnLogin:
* type: boolean
* theme:
* type: string
* fontSize:
* type: string
* accentColor:
* type: string
* language:
* type: string
* storageMode:
* type: string
* commandAutocomplete:
* type: boolean
* commandPaletteEnabled:
* type: boolean
* pinAppRail:
* type: boolean
* expandAppRailOnHover:
* type: boolean
* showPinAppRailButton:
* type: boolean
* confirmSnippetExecution:
* type: boolean
* disableUpdateCheck:
* type: boolean
* confirmTabClose:
* type: boolean
* hiddenRailTabs:
* type: string
* customThemes:
* type: string
* description: JSON-encoded array of the user's saved global custom terminal themes.
* customKeybindings:
* type: string
* description: JSON-encoded array of the user's custom terminal keybindings.
* responses:
* 200:
* description: Preferences updated successfully.
*/
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
reopenTabsOnLogin,
theme,
fontSize,
accentColor,
language,
storageMode,
commandAutocomplete,
commandPaletteEnabled,
pinAppRail,
expandAppRailOnHover,
showPinAppRailButton,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
hiddenRailTabs,
customThemes,
customKeybindings,
terminalDefaults,
terminalMacros,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
fontSize?: string | null;
accentColor?: string | null;
language?: string | null;
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
showPinAppRailButton?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
customThemes?: string | null;
customKeybindings?: string | null;
terminalDefaults?: string | null;
terminalMacros?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
// sidebar redesign. The columns stay in the table (read once as a
// migration seed by that route) but this endpoint silently ignores them if
// a stale client still sends them. foldersCollapsed similarly moved out,
// to the snippets plugin's own user settings.
const updates: UserPreferenceUpdate = {
updatedAt: new Date().toISOString(),
};
if (reopenTabsOnLogin !== undefined) {
if (typeof reopenTabsOnLogin !== "boolean") {
return res
.status(400)
.json({ error: "reopenTabsOnLogin must be a boolean" });
}
updates.reopenTabsOnLogin = reopenTabsOnLogin;
}
for (const [key, value] of Object.entries({
theme,
fontSize,
accentColor,
language,
storageMode,
hiddenRailTabs,
customThemes,
customKeybindings,
terminalDefaults,
terminalMacros,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a string` });
}
}
const connectionDefaults = {
terminalDefaults,
};
for (const key of connectionDefaultFields) {
const value = connectionDefaults[key];
if (value !== undefined && value !== null && !validateDefaultsJson(value)) {
return res.status(400).json({
error: `${key} must be a JSON-encoded object of at most 32 KiB`,
});
}
}
if (customThemes !== undefined && customThemes !== null) {
let parsedThemes: unknown;
try {
parsedThemes = JSON.parse(customThemes);
} catch {
return res
.status(400)
.json({ error: "customThemes must be a JSON-encoded array" });
}
if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
return res.status(400).json({
error: "customThemes must be a JSON array of at most 100 themes",
});
}
const isValidTheme = (entry: unknown): boolean =>
!!entry &&
typeof entry === "object" &&
typeof (entry as { id?: unknown }).id === "string" &&
typeof (entry as { name?: unknown }).name === "string" &&
!!(entry as { colors?: unknown }).colors &&
typeof (entry as { colors?: unknown }).colors === "object";
if (!parsedThemes.every(isValidTheme)) {
return res.status(400).json({
error: "Each custom theme must have an id, name, and colors object",
});
}
}
if (customKeybindings !== undefined && customKeybindings !== null) {
let parsedKeybindings: unknown;
try {
parsedKeybindings = JSON.parse(customKeybindings);
} catch {
return res
.status(400)
.json({ error: "customKeybindings must be a JSON-encoded array" });
}
if (!Array.isArray(parsedKeybindings) || parsedKeybindings.length > 200) {
return res.status(400).json({
error: "customKeybindings must be a JSON array of at most 200 bindings",
});
}
if (!parsedKeybindings.every(isValidKeybinding)) {
return res.status(400).json({
error:
"Each custom keybinding must have an id, enabled flag, valid combo, and valid action",
});
}
}
if (terminalMacros !== undefined && terminalMacros !== null) {
let parsedMacros: unknown;
try {
parsedMacros = JSON.parse(terminalMacros);
} catch {
return res
.status(400)
.json({ error: "terminalMacros must be a JSON-encoded array" });
}
if (
terminalMacros.length > 512 * 1024 ||
!Array.isArray(parsedMacros) ||
parsedMacros.length > 100 ||
!parsedMacros.every(
(macro) =>
!!macro &&
typeof macro === "object" &&
typeof (macro as { id?: unknown }).id === "string" &&
typeof (macro as { name?: unknown }).name === "string" &&
Array.isArray((macro as { steps?: unknown }).steps),
)
) {
return res.status(400).json({
error: "terminalMacros must contain at most 100 valid macros",
});
}
}
const boolFields: Record<string, boolean | null | undefined> = {
commandAutocomplete,
commandPaletteEnabled,
pinAppRail,
expandAppRailOnHover,
showPinAppRailButton,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
};
for (const [key, value] of Object.entries(boolFields)) {
if (value !== undefined && value !== null && typeof value !== "boolean") {
return res.status(400).json({ error: `${key} must be a boolean` });
}
}
if (theme !== undefined) updates.theme = theme;
if (fontSize !== undefined) updates.fontSize = fontSize;
if (accentColor !== undefined) updates.accentColor = accentColor;
if (language !== undefined) updates.language = language;
if (storageMode !== undefined) updates.storageMode = storageMode;
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
if (commandAutocomplete !== undefined)
updates.commandAutocomplete = commandAutocomplete;
if (commandPaletteEnabled !== undefined)
updates.commandPaletteEnabled = commandPaletteEnabled;
if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail;
if (expandAppRailOnHover !== undefined)
updates.expandAppRailOnHover = expandAppRailOnHover;
if (showPinAppRailButton !== undefined)
updates.showPinAppRailButton = showPinAppRailButton;
if (confirmSnippetExecution !== undefined)
updates.confirmSnippetExecution = confirmSnippetExecution;
if (disableUpdateCheck !== undefined)
updates.disableUpdateCheck = disableUpdateCheck;
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
if (customThemes !== undefined) updates.customThemes = customThemes;
if (customKeybindings !== undefined)
updates.customKeybindings = customKeybindings;
if (terminalDefaults !== undefined)
updates.terminalDefaults = terminalDefaults;
if (terminalMacros !== undefined) updates.terminalMacros = terminalMacros;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
}
try {
await createCurrentUserPreferenceRepository().upsert(userId, updates);
return res.json({ success: true, ...updates });
} catch (e) {
databaseLogger.error("Failed to update user preferences", e, {
operation: "update_user_preferences",
userId,
});
return res.status(500).json({ error: "Failed to update user preferences" });
}
});
export default router;