WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(web): POST /admin/themes/:rkey/preview — HTMX live preview endpoint (ATB-59)

Adds the live-preview fragment endpoint used by the theme editor's HTMX
integration. Sanitizes token values via sanitizeTokenValue() before
rendering ThemePreviewContent, dropping any value containing '<', ';',
or '}' to prevent CSS injection.

+33 -3
+33 -3
apps/web/src/routes/admin-themes.tsx
··· 192 192 <input 193 193 type="text" 194 194 placeholder="Reply…" 195 - style="font-family:var(--font-body);font-size:var(--font-size-base);border:var(--input-border);border-radius:var(--input-radius);padding:var(--space-sm) var(--space-md);width:100%;box-sizing:border-box;background:var(--color-bg);color:var(--color-text);margin-bottom:var(--space-sm);" 195 + style="font-family:var(--font-body);font-size:var(--font-size-base);border:var(--input-border);border-radius:var(--input-radius);padding:var(--space-sm) var(--space-md);width:100%;box-sizing:border-box;background:var(--color-surface);color:var(--color-text);margin-bottom:var(--space-sm);" 196 196 /> 197 197 <div style="display:flex;gap:var(--space-sm);flex-wrap:wrap;"> 198 198 <button ··· 230 230 ); 231 231 } 232 232 233 - // Suppress unused warning — sanitizeTokenValue will be used in Task 6 (preview endpoint) 234 - void sanitizeTokenValue; 235 233 236 234 // ─── Route Factory ────────────────────────────────────────────────────────── 237 235 ··· 971 969 } 972 970 973 971 return c.redirect("/admin/themes", 302); 972 + }); 973 + 974 + // ── POST /admin/themes/:rkey/preview ───────────────────────────────────── 975 + 976 + app.post("/admin/themes/:rkey/preview", async (c) => { 977 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 978 + if (!auth.authenticated) return c.redirect("/login"); 979 + if (!canManageThemes(auth)) { 980 + return c.html("", 403); 981 + } 982 + 983 + let rawBody: Record<string, string | File>; 984 + try { 985 + rawBody = await c.req.parseBody(); 986 + } catch (error) { 987 + if (isProgrammingError(error)) throw error; 988 + // Return empty preview on parse error — don't break the HTMX swap 989 + return c.html(<ThemePreviewContent tokens={{}} />); 990 + } 991 + 992 + // Only accept known token names — ignore metadata fields like name/colorScheme 993 + const tokens: Record<string, string> = {}; 994 + for (const tokenName of ALL_KNOWN_TOKENS) { 995 + const raw = rawBody[tokenName]; 996 + if (typeof raw !== "string") continue; 997 + const safe = sanitizeTokenValue(raw); 998 + if (safe !== null) { 999 + tokens[tokenName] = safe; 1000 + } 1001 + } 1002 + 1003 + return c.html(<ThemePreviewContent tokens={tokens} />); 974 1004 }); 975 1005 976 1006 return app;