// Export/import as a versioned, self-describing JSON envelope. Import is // admin-only and performs NO security sanitization (an admin already owns site // CSS/JS via page_custom_css / custom headers / plugin install -- see README // SECURITY). Only four ROBUSTNESS checks run: size cap, format+version upcast, // shape validation, and a "won't white-screen" compile check. // // CONTINGENCY: if a non-admin role is ever allowed to import/manage themes, // real sanitization (CSS/selector/@import/url()/expression filtering, layoutTree // resolver allow-listing) MUST be reintroduced before that capability ships. const { SCHEMA_ID, FORMAT_VERSION, ENGINE, MAX_IMPORT_BYTES, MAX_TOKENS, } = require("./constants"); const { normalizeTokens, tokensAreValid } = require("./themeSchema"); const { compileTheme } = require("./compile"); class ImportError extends Error {} // The export envelope carries NO id, version, timestamps, or compiled CSS -- // identity is instance-local (import mints fresh), so import is structurally // non-destructive. function buildEnvelope(theme) { const env = { $schema: SCHEMA_ID, formatVersion: FORMAT_VERSION, name: theme.name, engine: theme.engine || ENGINE, tokens: theme.tokens, }; if (theme.layoutTree) { env.layoutTree = theme.layoutTree; } return env; } // Envelope-axis upcasters (formatVersion). The token-shape axis // (tokens.$tokensVersion) is handled separately by normalizeTokens. const FORMAT_UPCASTERS = { 0: (e) => ({ $schema: SCHEMA_ID, formatVersion: 1, name: e.name || "Imported", engine: e.engine || ENGINE, tokens: e.vars || e.tokens || {}, layoutTree: e.layoutTree || null, }), // future: 1 -> 2 here }; function upcastEnvelope(e) { let cur = e; let v = typeof e.formatVersion === "number" ? e.formatVersion : 0; while (v < FORMAT_VERSION) { if (!FORMAT_UPCASTERS[v]) { throw new ImportError(`no upcaster for formatVersion ${v}`); } cur = FORMAT_UPCASTERS[v](cur); v = cur.formatVersion; } if (v > FORMAT_VERSION) { throw new ImportError(`formatVersion ${v} newer than supported ${FORMAT_VERSION}`); } return cur; } async function parseEnvelope(rawText) { // (d) size cap -- operational hygiene, BEFORE parse if (Buffer.byteLength(rawText, "utf8") > MAX_IMPORT_BYTES) { return { ok: false, error: "Theme file exceeds size limit" }; } let parsed; try { parsed = JSON.parse(rawText); } catch { return { ok: false, error: "Not valid JSON" }; } // (a) format + discriminator + upcast if (!parsed || parsed.$schema !== SCHEMA_ID) { return { ok: false, error: "Not a saltcorn-theme file" }; } let env; try { env = upcastEnvelope(parsed); } catch (e) { return { ok: false, error: e.message }; } if (env.engine && env.engine !== ENGINE) { return { ok: false, error: `Unsupported engine: ${env.engine}` }; } const tokens = normalizeTokens(env.tokens); if (Object.keys(tokens).length > MAX_TOKENS) { return { ok: false, error: "Too many tokens" }; } const tv = tokensAreValid(tokens); if (!tv.ok) { return { ok: false, error: tv.errors.join("; ") }; } // (c) light "won't white-screen" check: actually compile + verify balanced braces. const compiled = compileTheme(tokens, { engine: env.engine || ENGINE }); if (compiled.warnings.some((w) => /unbalanced|empty \(invalid\)/.test(w))) { return { ok: false, error: "Theme tokens do not compile to valid CSS" }; } return { ok: true, draft: { name: (env.name || "Imported Theme").trim(), engine: env.engine || ENGINE, tokens, layoutTree: env.layoutTree ?? null, }, }; } module.exports = { ImportError, buildEnvelope, upcastEnvelope, FORMAT_UPCASTERS, parseEnvelope };