119 lines
3.9 KiB
JavaScript
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 };
|