larpaso-website/admin/lib/rich-sanitize.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

36 lines
1.2 KiB
JavaScript

const sanitizeHtml = require('sanitize-html');
const { FONT_NAMES } = require('./fonts');
const OPTIONS = {
// b/i are included because execCommand('bold'/'italic') emits those in
// some browsers; transformTags normalizes them to the semantic tags.
allowedTags: ['strong', 'em', 'u', 'span', 'br', 'b', 'i'],
transformTags: {
b: 'strong',
i: 'em',
},
allowedAttributes: {
span: ['data-font'],
},
// Reject any span whose data-font isn't one of our curated fonts, instead
// of silently passing through an attacker-controlled string.
exclusiveFilter: (frame) => {
if (frame.tag === 'span') {
const font = frame.attribs && frame.attribs['data-font'];
return !font || !FONT_NAMES.includes(font);
}
return false;
},
disallowedTagsMode: 'discard',
};
/** Sanitizes rich-text HTML from the admin editor down to a small allowlist
* (bold/italic/underline + font-picker spans) before it's stored or rendered
* on the public site. Anything else - scripts, styles, links, attributes -
* is stripped, not escaped, so plain text content is preserved. */
function sanitizeRichText(html) {
if (typeof html !== 'string') return '';
return sanitizeHtml(html, OPTIONS).trim();
}
module.exports = { sanitizeRichText };