// pattern: Mixed (unavoidable) // Reason: Hono route file — JSX components, pure helpers (slugifyName, isHttpsUrl, // sanitizeTokenValue), and route handlers (I/O) must coexist per this project's // one-file-per-route-group convention (see other admin-*.tsx route files). import { Hono } from "hono"; import { BaseLayout } from "../layouts/base.js"; import { PageHeader, EmptyState } from "../components/index.js"; import { getSessionWithPermissions, canManageThemes, } from "../lib/session.js"; import { isProgrammingError } from "../lib/errors.js"; import { logger } from "../lib/logger.js"; import { tokensToCss } from "../lib/theme.js"; import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; import neobrutalDark from "../styles/presets/neobrutal-dark.json" with { type: "json" }; import cleanLight from "../styles/presets/clean-light.json" with { type: "json" }; import cleanDark from "../styles/presets/clean-dark.json" with { type: "json" }; import classicBb from "../styles/presets/classic-bb.json" with { type: "json" }; // ─── Types ───────────────────────────────────────────────────────────────── interface AdminThemeEntry { id: string; uri: string; name: string; colorScheme: string; tokens: Record; cssOverrides: string | null; fontUrls: string[] | null; createdAt: string; indexedAt: string; } interface ThemePolicy { defaultLightThemeUri: string | null; defaultDarkThemeUri: string | null; allowUserChoice: boolean; availableThemes: Array<{ uri: string; cid?: string }>; } // Preset token maps — used by POST /admin/themes to seed tokens on creation const THEME_PRESETS: Record> = { "neobrutal-light": neobrutalLight as Record, "neobrutal-dark": neobrutalDark as Record, "clean-light": cleanLight as Record, "clean-dark": cleanDark as Record, "classic-bb": classicBb as Record, "blank": {}, }; // ─── Token Group Constants ────────────────────────────────────────────────── export const COLOR_TOKENS = [ "color-bg", "color-surface", "color-text", "color-text-muted", "color-primary", "color-primary-hover", "color-secondary", "color-border", "color-shadow", "color-success", "color-warning", "color-danger", "color-code-bg", "color-code-text", ] as const; export const TYPOGRAPHY_TOKENS = [ "font-body", "font-heading", "font-mono", "font-size-base", "font-size-sm", "font-size-xs", "font-size-lg", "font-size-xl", "font-size-2xl", "font-weight-normal", "font-weight-bold", "line-height-body", "line-height-heading", ] as const; export const SPACING_TOKENS = [ "space-xs", "space-sm", "space-md", "space-lg", "space-xl", "radius", "border-width", "shadow-offset", "content-width", ] as const; export const COMPONENT_TOKENS = [ "button-radius", "button-shadow", "card-radius", "card-shadow", "btn-press-hover", "btn-press-active", "input-radius", "input-border", "nav-height", ] as const; export const ALL_KNOWN_TOKENS: readonly string[] = [ ...COLOR_TOKENS, ...TYPOGRAPHY_TOKENS, ...SPACING_TOKENS, ...COMPONENT_TOKENS, ]; // ─── Helpers ──────────────────────────────────────────────────────────────── /** * Extracts the error message from an AppView error response. * Falls back to the provided default if JSON parsing fails. */ async function extractAppviewError(res: Response, fallback: string): Promise { try { const data = (await res.json()) as { error?: string }; return data.error ?? fallback; } catch (error) { logger.error("Failed to parse AppView error response body", { operation: "extractAppviewError", status: res.status, error: error instanceof Error ? error.message : String(error), }); return fallback; } } /** Drop token values that could break the CSS style block. */ function sanitizeTokenValue(value: unknown): string | null { if (typeof value !== "string") return null; if (value.includes("<") || value.includes(";") || value.includes("}")) return null; return value; } /** Produce a URL-safe filename slug from a theme name, with a fallback. */ function slugifyName(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") || "theme"; } /** Returns true only for absolute HTTPS URLs. */ function isHttpsUrl(url: unknown): boolean { if (typeof url !== "string") return false; try { return new URL(url).protocol === "https:"; } catch { return false; } } // ─── JSX Components ───────────────────────────────────────────────────────── function ColorTokenInput({ name, value }: { name: string; value: string }) { const safeValue = !value.startsWith("var(") && !value.includes(";") && !value.includes("<") ? value : "#cccccc"; return (
); } function TextTokenInput({ name, value }: { name: string; value: string }) { return (
); } function TokenFieldset({ legend, tokens, effectiveTokens, isColor, }: { legend: string; tokens: readonly string[]; effectiveTokens: Record; isColor: boolean; }) { return (
{legend} {tokens.map((name) => isColor ? ( ) : ( ) )}
); } function ThemePreviewContent({ tokens }: { tokens: Record }) { const css = tokensToCss(tokens); return ( <>
atBB Forum Preview

Sample Thread Title

Body text showing font, color, and spacing at work.{" "} A sample link

              {`const greeting = "hello forum";`}
            
success warning danger
); } // ─── Route Factory ────────────────────────────────────────────────────────── export function createAdminThemeRoutes(appviewUrl: string) { const app = new Hono(); // ── GET /admin/themes ────────────────────────────────────────────────────── app.get("/admin/themes", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) { return c.redirect("/login"); } if (!canManageThemes(auth)) { return c.html(

You don't have permission to manage themes.

, 403 ); } const cookie = c.req.header("cookie") ?? ""; const errorMsg = c.req.query("error") ?? null; let adminThemes: AdminThemeEntry[] = []; let policy: ThemePolicy | null = null; try { const [themesRes, policyRes] = await Promise.all([ fetch(`${appviewUrl}/api/admin/themes`, { headers: { Cookie: cookie } }), fetch(`${appviewUrl}/api/theme-policy`, { headers: { Cookie: cookie } }), ]); if (themesRes.ok) { try { const data = (await themesRes.json()) as { themes: AdminThemeEntry[] }; adminThemes = data.themes; } catch { logger.error("Failed to parse admin themes response", { operation: "GET /admin/themes", status: themesRes.status, }); } } else { logger.error("Failed to fetch admin themes list", { operation: "GET /admin/themes", status: themesRes.status, }); } if (policyRes.ok) { try { policy = (await policyRes.json()) as ThemePolicy; } catch { logger.error("Failed to parse theme policy response", { operation: "GET /admin/themes", status: policyRes.status, }); } } else if (policyRes.status !== 404) { logger.error("Failed to fetch theme policy", { operation: "GET /admin/themes", status: policyRes.status, }); } // 404 = no policy yet — render page with empty policy (not an error) } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error fetching themes data", { operation: "GET /admin/themes", error: error instanceof Error ? error.message : String(error), }); } const availableUris = new Set((policy?.availableThemes ?? []).map((t) => t.uri)); const lightThemes = adminThemes.filter((t) => t.colorScheme === "light"); const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark"); return c.html( {errorMsg &&
{errorMsg}
} {adminThemes.length === 0 ? ( ) : (
{adminThemes.map((theme) => { const themeRkey = theme.uri.split("/").pop() ?? theme.id; const dialogId = `confirm-delete-theme-${themeRkey}`; const swatchTokens = [ "color-bg", "color-surface", "color-primary", "color-secondary", "color-border", ] as const; return (
{theme.colorScheme}
Edit Export

Delete theme "{theme.name}"? This cannot be undone.

); })}
)} {/* Policy form — availability checkboxes on cards associate via form="policy-form" */}

Theme Policy

Check themes above to make them available to users.

{/* Create new theme */}
+ Create New Theme
{/* Import theme from JSON */}
↑ Import Theme from JSON

Imports name, colorScheme, tokens, and fontUrls. CSS overrides and unknown token keys are ignored.

); }); // ── GET /admin/themes/:rkey/export ──────────────────────────────────────── // Distinct from /:rkey — the 4-segment path cannot match the 3-segment /:rkey route. app.get("/admin/themes/:rkey/export", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html(

You don't have permission to manage themes.

, 403 ); } const themeRkey = c.req.param("rkey"); let theme: AdminThemeEntry | null = null; try { const res = await fetch(`${appviewUrl}/api/themes/${themeRkey}`); if (res.status === 404) { return c.html(

This theme does not exist.

← Back to themes
, 404 ); } if (res.ok) { try { theme = (await res.json()) as AdminThemeEntry; } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Failed to parse theme response for export", { operation: "GET /admin/themes/:rkey/export", themeRkey, error: error instanceof Error ? error.message : String(error), }); } } else { logger.error("AppView returned error loading theme for export", { operation: "GET /admin/themes/:rkey/export", themeRkey, status: res.status, }); } } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error loading theme for export", { operation: "GET /admin/themes/:rkey/export", themeRkey, error: error instanceof Error ? error.message : String(error), }); } if (!theme) { return c.html(

Unable to load theme data. Please try again.

← Back to themes
, 500 ); } // cssOverrides excluded from export — it contains raw CSS that may reference // external resources and is tied to this forum's sanitization config. const exportData = { name: theme.name, colorScheme: theme.colorScheme, tokens: theme.tokens, fontUrls: theme.fontUrls ?? [], }; const filename = `${slugifyName(theme.name)}-${slugifyName(theme.colorScheme)}.json`; c.header("Content-Type", "application/json"); c.header("Content-Disposition", `attachment; filename="${filename}"`); return c.body(JSON.stringify(exportData, null, 2), 200); }); // ── GET /admin/themes/:rkey ──────────────────────────────────────────────── app.get("/admin/themes/:rkey", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html(

You don't have permission to manage themes.

, 403 ); } const themeRkey = c.req.param("rkey"); const presetParam = c.req.query("preset") ?? null; const successMsg = c.req.query("success") === "1" ? "Theme saved successfully." : null; const errorMsg = c.req.query("error") ?? null; // Fetch theme from AppView let theme: AdminThemeEntry | null = null; try { const res = await fetch(`${appviewUrl}/api/themes/${themeRkey}`); if (res.status === 404) { return c.html(

This theme does not exist.

← Back to themes
, 404 ); } if (res.ok) { try { theme = (await res.json()) as AdminThemeEntry; } catch { logger.error("Failed to parse theme response", { operation: "GET /admin/themes/:rkey", themeRkey, }); } } else { logger.error("AppView returned error loading theme", { operation: "GET /admin/themes/:rkey", themeRkey, status: res.status, }); } } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error loading theme", { operation: "GET /admin/themes/:rkey", themeRkey, error: error instanceof Error ? error.message : String(error), }); } if (!theme) { return c.html(

Unable to load theme data. Please try again.

← Back to themes
, 500 ); } // If ?preset is set, override DB tokens with preset tokens const presetTokens = presetParam ? (THEME_PRESETS[presetParam] ?? null) : null; const effectiveTokens: Record = presetTokens ? { ...theme.tokens, ...presetTokens } : { ...theme.tokens }; const fontUrlsText = (theme.fontUrls ?? []).join("\n"); return c.html( {successMsg &&
{successMsg}
} {errorMsg &&
{errorMsg}
} ← Back to themes {/* Metadata + tokens form */}
{/* Metadata */}
Theme Metadata
{/* Token editor + live preview layout */}
{/* Left: token controls */}
{/* CSS overrides */}
CSS Overrides

Raw CSS for structural changes. Dangerous constructs (external URLs, @import, expression()) are stripped automatically on save.

{/* Right: live preview */}

Live Preview

{/* Actions */}
{/* Reset to preset dialog */}

Reset all token values to a built-in preset? Your unsaved changes will be lost.

); }); // ── POST /admin/themes ──────────────────────────────────────────────────── app.post("/admin/themes", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html(

Access denied.

, 403); } const cookie = c.req.header("cookie") ?? ""; let body: Record; try { body = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; return c.redirect( `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 302 ); } const name = typeof body.name === "string" ? body.name.trim() : ""; const colorScheme = typeof body.colorScheme === "string" ? body.colorScheme : "light"; const preset = typeof body.preset === "string" ? body.preset : "blank"; if (!name) { return c.redirect( `/admin/themes?error=${encodeURIComponent("Theme name is required.")}`, 302 ); } const tokens = THEME_PRESETS[preset] ?? {}; let apiRes: Response; try { apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name, colorScheme, tokens }), }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error creating theme", { operation: "POST /admin/themes", error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!apiRes.ok) { const msg = await extractAppviewError(apiRes, "Failed to create theme. Please try again."); return c.redirect( `/admin/themes?error=${encodeURIComponent(msg)}`, 302 ); } return c.redirect("/admin/themes", 302); }); // ── POST /admin/themes/import ───────────────────────────────────────────── // File upload: reads the JSON file, validates structure, strips unknown tokens // and cssOverrides, then delegates to POST /api/admin/themes. app.post("/admin/themes/import", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html(

Access denied.

, 403 ); } const cookie = c.req.header("cookie") ?? ""; let rawBody: Record; try { rawBody = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Failed to parse import form body", { operation: "POST /admin/themes/import", error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 302 ); } const uploaded = rawBody.themeFile; if (!uploaded || typeof uploaded === "string" || uploaded.size === 0) { return c.redirect( `/admin/themes?error=${encodeURIComponent("Please select a JSON file to import.")}`, 302 ); } const MAX_IMPORT_BYTES = 100 * 1024; // 100 KB if (uploaded.size > MAX_IMPORT_BYTES) { return c.redirect( `/admin/themes?error=${encodeURIComponent("Import failed: file exceeds the 100 KB size limit.")}`, 302 ); } // Read the file text and parse as JSON — two separate try blocks so encoding // failures and JSON syntax errors produce distinct log entries. let text: string; try { text = await uploaded.text(); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Failed to read uploaded theme file", { operation: "POST /admin/themes/import", error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/themes?error=${encodeURIComponent("Import failed: could not read the uploaded file.")}`, 302 ); } let parsed: unknown; try { parsed = JSON.parse(text); } catch { return c.redirect( `/admin/themes?error=${encodeURIComponent("Import failed: file is not valid JSON.")}`, 302 ); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return c.redirect( `/admin/themes?error=${encodeURIComponent("Import failed: file must be a JSON object.")}`, 302 ); } const obj = parsed as Record; // Validate required: name const name = typeof obj.name === "string" ? obj.name.trim() : ""; if (!name) { return c.redirect( `/admin/themes?error=${encodeURIComponent('Import failed: missing required field "name".')}`, 302 ); } // Validate required: colorScheme if (obj.colorScheme !== "light" && obj.colorScheme !== "dark") { return c.redirect( `/admin/themes?error=${encodeURIComponent( 'Import failed: colorScheme must be "light" or "dark".' )}`, 302 ); } const colorScheme = obj.colorScheme; // Validate required: tokens (must be a plain object) if (typeof obj.tokens !== "object" || obj.tokens === null || Array.isArray(obj.tokens)) { return c.redirect( `/admin/themes?error=${encodeURIComponent('Import failed: missing required field "tokens".')}`, 302 ); } // Strip unknown token keys — only recognized tokens pass through const rawTokens = obj.tokens as Record; const tokens: Record = {}; for (const key of ALL_KNOWN_TOKENS) { const val = rawTokens[key]; if (typeof val === "string") { tokens[key] = val; } } // Validate fontUrls — each must be an HTTPS URL let fontUrls: string[] | undefined; if (obj.fontUrls !== undefined) { if (!Array.isArray(obj.fontUrls)) { return c.redirect( `/admin/themes?error=${encodeURIComponent("Import failed: fontUrls must be an array.")}`, 302 ); } for (const url of obj.fontUrls) { if (!isHttpsUrl(url)) { return c.redirect( `/admin/themes?error=${encodeURIComponent( `Import failed: font URL must be HTTPS: ${String(url)}` )}`, 302 ); } } // Safe: every element has passed isHttpsUrl(), which verifies typeof === "string" fontUrls = obj.fontUrls as string[]; } // cssOverrides is silently dropped — keeps the shared JSON schema portable // (no forum-specific CSS bleeding in) and avoids importing structural overrides // that may not make sense in the target forum's layout. let apiRes: Response; try { apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name, colorScheme, tokens, ...(fontUrls !== undefined && { fontUrls }), }), }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error importing theme", { operation: "POST /admin/themes/import", error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!apiRes.ok) { const msg = await extractAppviewError(apiRes, "Failed to import theme. Please try again."); return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); } return c.redirect("/admin/themes", 302); }); // ── POST /admin/themes/:rkey/duplicate ──────────────────────────────────── app.post("/admin/themes/:rkey/duplicate", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html(

Access denied.

, 403); } const cookie = c.req.header("cookie") ?? ""; const themeRkey = c.req.param("rkey"); let apiRes: Response; try { apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}/duplicate`, { method: "POST", headers: { Cookie: cookie }, }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error duplicating theme", { operation: "POST /admin/themes/:rkey/duplicate", themeRkey, error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!apiRes.ok) { const msg = await extractAppviewError(apiRes, "Failed to duplicate theme. Please try again."); return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); } return c.redirect("/admin/themes", 302); }); // ── POST /admin/themes/:rkey/delete ────────────────────────────────────── app.post("/admin/themes/:rkey/delete", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html(

Access denied.

, 403); } const cookie = c.req.header("cookie") ?? ""; const themeRkey = c.req.param("rkey"); let apiRes: Response; try { apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { method: "DELETE", headers: { Cookie: cookie }, }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error deleting theme", { operation: "POST /admin/themes/:rkey/delete", themeRkey, error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!apiRes.ok) { if (apiRes.status === 409) { return c.redirect( `/admin/themes?error=${encodeURIComponent("Cannot delete a theme that is currently set as a default.")}`, 302 ); } const msg = await extractAppviewError(apiRes, "Failed to delete theme. Please try again."); return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); } return c.redirect("/admin/themes", 302); }); // ── POST /admin/theme-policy ────────────────────────────────────────────── app.post("/admin/theme-policy", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html(

Access denied.

, 403); } const cookie = c.req.header("cookie") ?? ""; let rawBody: Record; try { rawBody = await c.req.parseBody({ all: true }); } catch (error) { if (isProgrammingError(error)) throw error; return c.redirect( `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 302 ); } const defaultLightThemeUri = typeof rawBody.defaultLightThemeUri === "string" ? rawBody.defaultLightThemeUri : ""; const defaultDarkThemeUri = typeof rawBody.defaultDarkThemeUri === "string" ? rawBody.defaultDarkThemeUri : ""; // Checkbox: present with value "on" when checked, absent when unchecked const allowUserChoice = rawBody.allowUserChoice === "on"; // availableThemes may be a single string, an array, or absent const rawAvailable = rawBody.availableThemes; const availableThemes = rawAvailable === undefined ? [] : Array.isArray(rawAvailable) ? rawAvailable.filter((v): v is string => typeof v === "string") : typeof rawAvailable === "string" ? [rawAvailable] : []; let apiRes: Response; try { apiRes = await fetch(`${appviewUrl}/api/admin/theme-policy`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ defaultLightThemeUri, defaultDarkThemeUri, allowUserChoice, availableThemes: availableThemes.map((uri) => ({ uri })), }), }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error updating theme policy", { operation: "POST /admin/theme-policy", error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!apiRes.ok) { const msg = await extractAppviewError(apiRes, "Failed to update theme policy. Please try again."); return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); } return c.redirect("/admin/themes", 302); }); // ── POST /admin/themes/:rkey/preview ───────────────────────────────────── app.post("/admin/themes/:rkey/preview", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html("", 403); } let rawBody: Record; try { rawBody = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; // Return empty preview on parse error — don't break the HTMX swap return c.html(); } // Only accept known token names — ignore metadata fields like name/colorScheme const tokens: Record = {}; for (const tokenName of ALL_KNOWN_TOKENS) { const raw = rawBody[tokenName]; if (typeof raw !== "string") continue; const safe = sanitizeTokenValue(raw); if (safe !== null) { tokens[tokenName] = safe; } } return c.html(); }); // ── POST /admin/themes/:rkey/save ───────────────────────────────────────── app.post("/admin/themes/:rkey/save", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html(

Access denied.

, 403 ); } const themeRkey = c.req.param("rkey"); const cookie = c.req.header("cookie") ?? ""; let rawBody: Record; try { rawBody = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; return c.redirect( `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`, 302 ); } const name = typeof rawBody.name === "string" ? rawBody.name.trim() : ""; if (!name) { return c.redirect( `/admin/themes/${themeRkey}?error=${encodeURIComponent("Theme name is required.")}`, 302 ); } const colorScheme = typeof rawBody.colorScheme === "string" ? rawBody.colorScheme : "light"; const fontUrlsRaw = typeof rawBody.fontUrls === "string" ? rawBody.fontUrls : ""; const fontUrls = fontUrlsRaw .split("\n") .map((u) => u.trim()) .filter(Boolean); const cssOverrides = typeof rawBody.cssOverrides === "string" ? rawBody.cssOverrides : undefined; // Extract token values from form fields const tokens: Record = {}; for (const tokenName of ALL_KNOWN_TOKENS) { const raw = rawBody[tokenName]; if (typeof raw !== "string") continue; const safe = sanitizeTokenValue(raw.trim()); if (safe !== null && safe !== "") { tokens[tokenName] = safe; } } let apiRes: Response; try { apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name, colorScheme, tokens, fontUrls, cssOverrides }), }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error saving theme", { operation: "POST /admin/themes/:rkey/save", themeRkey, error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/themes/${themeRkey}?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!apiRes.ok) { const msg = await extractAppviewError(apiRes, "Failed to save theme. Please try again."); return c.redirect( `/admin/themes/${themeRkey}?error=${encodeURIComponent(msg)}`, 302 ); } return c.redirect(`/admin/themes/${themeRkey}?success=1`, 302); }); // ── POST /admin/themes/:rkey/reset-to-preset ────────────────────────────── app.post("/admin/themes/:rkey/reset-to-preset", async (c) => { const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageThemes(auth)) { return c.html(

Access denied.

, 403 ); } const themeRkey = c.req.param("rkey"); let body: Record; try { body = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; return c.redirect( `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`, 302 ); } const preset = typeof body.preset === "string" ? body.preset : ""; if (!(preset in THEME_PRESETS)) { return c.redirect( `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid preset name.")}`, 302 ); } return c.redirect(`/admin/themes/${themeRkey}?preset=${encodeURIComponent(preset)}`, 302); }); return app; }