Rich-text fields (hero lead, paragraphs, FAQ-style copy, datenschutz sections, etc.) now use a proper toolbar editor - bold/italic/underline buttons plus a per-selection font picker drawn from the same curated font list as the Design tab - instead of typed **markdown** syntax. Content is stored as a small allowlisted HTML subset (strong/em/u/span with a validated data-font/br) and sanitized server-side with sanitize-html on every save (admin/lib/rich-sanitize.js), independent of what the client sends - verified this rejects script tags, event handlers, and made-up font names while preserving legitimate markup, and that re-saving already-sanitized content is a no-op. The Astro site mirrors the same allowlist at render time for defense-in-depth (src/lib/rich-sanitize.ts) and only requests Google Fonts for fonts actually referenced somewhere in content, in addition to the two Design-tab fonts - verified a font used via a data-font span gets its own Google Fonts request and [data-font] CSS rule in the built page, and that unused fonts don't get requested. Migrated the three existing fields that used the old markdown-lite syntax (**bold**/*italic*) to real HTML so they keep rendering correctly under the new system. Also replaces the single long scrolling admin page with a tabbed sidebar (grouped "Inhalte"/"Design") to cut down on clutter, per feedback that the all-in-one-page layout was hard to navigate.
58 lines
1.9 KiB
JavaScript
58 lines
1.9 KiB
JavaScript
const schema = require('./schema');
|
|
const { sanitizeRichText } = require('./rich-sanitize');
|
|
|
|
function isPlainObject(v) {
|
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
}
|
|
|
|
function sanitizeItemFields(item, itemFields) {
|
|
if (!isPlainObject(item)) return item;
|
|
for (const field of itemFields) {
|
|
sanitizeField(item, field);
|
|
}
|
|
return item;
|
|
}
|
|
|
|
function sanitizeField(obj, field) {
|
|
const value = obj[field.key];
|
|
switch (field.type) {
|
|
case 'richtext':
|
|
if (typeof value === 'string') obj[field.key] = sanitizeRichText(value);
|
|
break;
|
|
case 'list-richtext':
|
|
if (Array.isArray(value)) obj[field.key] = value.map((v) => (typeof v === 'string' ? sanitizeRichText(v) : v));
|
|
break;
|
|
case 'list-object':
|
|
if (Array.isArray(value)) value.forEach((item) => sanitizeItemFields(item, field.itemFields));
|
|
break;
|
|
case 'group':
|
|
if (isPlainObject(value)) sanitizeItemFields(value, field.itemFields);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** Returns a deep copy of a content.json payload with every richtext /
|
|
* list-richtext field run through the HTML allowlist sanitizer. Content
|
|
* fields never trust client-submitted HTML directly - this is the
|
|
* authoritative sanitization pass before anything is written to disk. */
|
|
function sanitizeContentPayload(payload) {
|
|
const copy = JSON.parse(JSON.stringify(payload));
|
|
const contentSections = schema.filter((s) => (s.file || 'content') === 'content');
|
|
|
|
for (const section of contentSections) {
|
|
if (section.type === 'array-root') {
|
|
const arr = copy[section.key];
|
|
if (Array.isArray(arr)) arr.forEach((item) => sanitizeItemFields(item, section.itemFields));
|
|
} else if (isPlainObject(copy[section.key])) {
|
|
for (const field of section.fields) {
|
|
sanitizeField(copy[section.key], field);
|
|
}
|
|
}
|
|
}
|
|
|
|
return copy;
|
|
}
|
|
|
|
module.exports = { sanitizeContentPayload };
|