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

119 lines
3.9 KiB
JavaScript

// 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 };