sc-theme-builder/lib/apiHandlers.js
2026-07-01 20:07:28 -05:00

260 lines
12 KiB
JavaScript

// lib/apiHandlers.js
// The HTTP handlers referenced by routes(cfg) (ARCHITECTURE.md 6.5, 6.6, 9.5).
// Each handler speaks the canonical ThemeDTO and the uniform error envelope
// (httpUtils.jsonError). The public theme.css route NEVER 5xx's (degrades to
// fallback CSS); every other route is admin-guarded and answers JSON.
//
// express req/res are the per-tenant router's standard objects (server/app.js
// mounts plugin routes via plugin_routes_handler.js / error_catcher). Multipart
// import uploads arrive on req.files (express-fileupload, useTempFiles) -- see
// importTheme for the empty-truthy-Buffer fix.
const fs = require("fs");
const themeStore = require("./themeStore");
const activePointer = require("./activePointer");
const cssCache = require("./cssCache");
const { activeThemeIdForRole } = require("./cfgReaders");
const activateEngine = require("./activate"); // activateEngine.activateTheme
const { compileTheme } = require("./compile");
const { normalizeTokens } = require("./themeSchema");
const portability = require("./portability");
const builtins = require("./builtins");
const httpUtils = require("./httpUtils");
const apiState = require("./apiState");
const page = require("./page"); // renderEditorShell
const { ROLE_PUBLIC, MAX_IMPORT_BYTES } = require("./constants");
const { guardAdmin, jsonError } = httpUtils;
// --- PUBLIC: GET /theme.css (ARCHITECTURE.md 6.5, folded cache-poisoning fix) ---
async function getThemeCss(req, res, cfg) {
const role = req.query.role ? Number(req.query.role) : (req.user?.role_id ?? ROLE_PUBLIC);
const themeId = activeThemeIdForRole(cfg, role);
res.set("Content-Type", "text/css; charset=utf-8");
if (!themeId) { res.set("Cache-Control", "no-store"); res.send("/* theme-builder: no active theme */"); return; }
const roleKey = (cfg.activeByRole && cfg.activeByRole[role]) ? role : null;
let entry = cssCache.getCached(themeId, roleKey);
let isReal = !!entry;
if (!entry) { // lazy compile-on-miss
const theme = await themeStore.getById(themeId);
if (theme) {
const compiled = compileTheme(theme.tokens, { engine: theme.engine });
entry = { ...compiled, themeId, role: roleKey, builtAt: Date.now() };
cssCache.setCached(entry);
isReal = true;
}
}
if (!entry) { // theme missing mid-activate, transient
res.set("Cache-Control", "no-store"); // FOLDED FIX: never memoize fallback under live hash
res.send(builtins.fallbackCss());
return;
}
// ETag always reflects the per-entry CONTENT hash, so the If-None-Match/304 path can correct
// stale CSS even when a ?v cache-buster failed to change. We keep `immutable` ONLY when the
// emitted link's ?v is guaranteed to change on re-activation. With the COMPOSITE activeHash
// (4.4/7.8) that guarantee now holds for the per-role link too, so `immutable` is safe here;
// were that guarantee ever weakened, drop `immutable` (keep ETag + must-revalidate) so the
// 304/If-None-Match path can still correct stale role CSS. (folded review fix)
res.set("ETag", `"${entry.hash}"`);
res.set("Cache-Control", isReal ? "public, max-age=31536000, immutable" : "no-store");
if (req.headers["if-none-match"] === `"${entry.hash}"`) { res.status(304).end(); return; }
res.send(entry.css);
}
// --- GET /editor: SPA shell ---
async function getEditor(req, res) {
if (!guardAdmin(req, res)) return; // GET -> redirect to login on failure
// page.renderEditorShell builds the SPA shell HTML (embeds the /state boot blob +
// csrf) and sends it via res.sendWrap (ARCHITECTURE.md 8.2).
return page.renderEditorShell(req, res);
}
// --- GET /api/state ---
async function getState_(req, res) {
if (!guardAdmin(req, res)) return;
res.json(await apiState.buildState(req));
}
// --- GET /api/themes/:id ---
async function loadTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
const theme = await themeStore.getById(id); // resolves rows AND builtins (SEAM FIX)
if (!theme) return jsonError(res, 404, "not_found", "Unknown theme");
// builtins are read-only: surface the flag so the SPA edits via duplicate-to-edit.
res.json({ theme, readOnly: !!theme.builtin });
}
// --- POST /api/themes: create blank, or clone a template (builtin/theme) ---
async function createTheme(req, res) {
if (!guardAdmin(req, res)) return;
const { name, engine, fromTemplate } = req.body || {};
let theme;
if (fromTemplate) {
// fromTemplate names a builtin/theme id to seed from -> duplicate to a fresh row.
theme = await themeStore.duplicate(fromTemplate, name);
if (!theme) return jsonError(res, 404, "not_found", "Unknown template");
} else {
theme = await themeStore.create({ name, engine, tokens: {}, layoutTree: null });
}
res.json({ theme });
}
// --- POST /api/themes/:id/duplicate ---
async function duplicateTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
const name = req.body?.name;
const theme = await themeStore.duplicate(id, name); // resolves rows AND builtins
if (!theme) return jsonError(res, 404, "not_found", "Unknown theme");
res.json({ theme });
}
// --- POST /api/themes/:id/save: optimistic CAS on version (ARCHITECTURE.md 6.6) ---
async function saveTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Built-in themes are read-only");
const { tokens, layoutTree = null, baseVersion, force } = req.body || {};
if (tokens == null) return jsonError(res, 400, "bad_request", "Missing tokens");
if (baseVersion == null && !force) return jsonError(res, 400, "bad_request", "Missing baseVersion");
const updated = force
? await themeStore.forceUpdate(id, { tokens, layoutTree })
: await themeStore.casUpdate(id, baseVersion, { tokens, layoutTree }); // UPDATE ... WHERE theme_id=$ AND version=$base, version=version+1
if (!updated) {
const cur = await themeStore.getById(id);
if (!cur) return jsonError(res, 404, "not_found", "Unknown theme");
return jsonError(res, 409, "version_conflict", "Theme was modified by another session", { currentVersion: cur.version });
}
res.json({ theme: updated }); // SAVE does NOT touch the live site
}
// --- POST /api/themes/:id/rename: name only, builtins blocked, de-dups (no 409) ---
async function renameTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Built-in themes are read-only");
const name = req.body?.name;
if (name == null || String(name).trim() === "") return jsonError(res, 400, "bad_request", "Missing name");
const exists = await themeStore.getById(id);
if (!exists) return jsonError(res, 404, "not_found", "Unknown theme");
// store.rename de-dups ("Name (2)") rather than rejecting, so rename always succeeds.
const theme = await themeStore.rename(id, name);
res.json({ theme });
}
// --- POST /api/themes/:id/delete (ARCHITECTURE.md 6.6) ---
async function deleteTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Cannot delete built-in");
const r = await themeStore.getById(id);
if (!r) return jsonError(res, 404, "not_found", "Unknown theme");
const { activeThemeId, activeByRole } = activePointer.getActivePointer();
const isActive = activeThemeId === id || Object.values(activeByRole).includes(id);
if (isActive) {
if (!req.body?.autoSwitch) return jsonError(res, 409, "active_theme", "Theme is active; pass autoSwitch to reassign first");
const successor = (await themeStore.firstOther(id))?.id || builtins.defaultId();
await activateEngine.activateTheme(successor); // recompile + flip to successor FIRST
await activePointer.dropRoleRefs(id); // scrub activeByRole entries == id
}
await themeStore.remove(id);
res.json({ ok: true, switchedActiveTo: isActive ? undefined : null });
}
// --- POST /api/themes/:id/activate: the only live-mutating op (ARCHITECTURE.md 6.6) ---
async function activateTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
const exists = builtins.isBuiltinId(id) ? !!builtins.get(id) : !!(await themeStore.getById(id));
if (!exists) return jsonError(res, 404, "not_found", "Unknown theme");
try {
const out = await activateEngine.activateTheme(id, req.body?.role);
res.json({ ok: true, ...out });
} catch (e) {
if (e.stage === "compile") return jsonError(res, 500, "compile_failed", e.message); // pointer untouched
return jsonError(res, 500, "activation_failed", e.message); // pointer write/propagate failed
}
}
// --- GET /api/themes/:id/export: JSON envelope as a download (works for builtins) ---
async function exportTheme(req, res) {
if (!guardAdmin(req, res)) return;
const id = req.params.id;
const theme = await themeStore.getById(id); // resolves rows AND builtins
if (!theme) return jsonError(res, 404, "not_found", "Unknown theme");
const envelope = portability.buildEnvelope(theme);
const slug = String(theme.name || "theme").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64) || "theme";
res.set("Content-Type", "application/json; charset=utf-8");
res.set("Content-Disposition", `attachment; filename="${slug}.theme.json"`);
res.send(JSON.stringify(envelope, null, 2));
}
// --- POST /api/import (ARCHITECTURE.md 9.5, multipart source + fresh-id fixes) ---
async function importTheme(req, res) {
if (!guardAdmin(req, res)) return;
let raw;
if (req.files && req.files.file) {
const f = req.files.file;
if (f.size > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme file too large");
// FOLDED FIX: with useTempFiles, f.data is an EMPTY (truthy) Buffer -> branch on LENGTH, read tempFilePath.
raw = (f.data && f.data.length) ? f.data.toString("utf8") : fs.readFileSync(f.tempFilePath, "utf8");
} else if (req.body && (req.body.$schema || req.body.envelope)) {
raw = typeof req.body.envelope === "string" ? req.body.envelope : JSON.stringify(req.body.envelope || req.body);
if (Buffer.byteLength(raw) > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme too large");
} else return jsonError(res, 400, "bad_request", "No theme file provided");
const parsed = await portability.parseEnvelope(raw);
if (!parsed.ok) return jsonError(res, parsed.error.includes("compile") ? 422 : 400,
parsed.error.includes("compile") ? "uncompilable" : "bad_format", parsed.error);
// (b) FRESH id + de-dup name via create() -> import NEVER overwrites
const theme = await themeStore.create({ name: parsed.draft.name, engine: parsed.draft.engine,
tokens: parsed.draft.tokens, layoutTree: parsed.draft.layoutTree });
res.json({ theme });
}
// --- POST /api/preview-css: compile DRAFT tokens for the editor's live preview ---
// Uses the SAME server compiler as production (single source of truth -> the
// preview's buttons/badges match exactly, including the deep overlay). Does NOT
// save or activate; returns text/css with no-store.
async function previewCss(req, res) {
if (!guardAdmin(req, res)) return;
const tokens = normalizeTokens((req.body && req.body.tokens) || {});
const engine = (req.body && req.body.engine) || undefined;
const compiled = compileTheme(tokens, { engine });
res.set("Content-Type", "text/css; charset=utf-8");
res.set("Cache-Control", "no-store");
res.send(compiled.css);
}
module.exports = {
getThemeCss,
getEditor,
getState_,
loadTheme,
createTheme,
duplicateTheme,
saveTheme,
renameTheme,
deleteTheme,
activateTheme,
exportTheme,
importTheme,
previewCss,
};