260 lines
12 KiB
JavaScript
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,
|
|
};
|