larpaso-website/admin/lib/sanitize-content.js
Pascal Scholz 7c67ae1ff5 Add WYSIWYG rich-text editor and reorganize admin UI into tabs
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.
2026-09-21 23:11:19 +02:00

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