/** * Walkt einen vom CMS gelieferten Page-/Post-Content-Tree und sanitisiert * Felder, die später roh über `{@html}` gerendert werden — primär das * `html`-Feld von HtmlBlocks. * * Wird in den Server-Loads aufgerufen (`+layout.server.ts`, `+page.server.ts`), * **nachdem** `resolveContentImages` etc. den Tree fertig aufgelöst haben. * Die Daten landen anschließend als sanitiertes HTML in der Page-Data, sodass * Svelte-Komponenten beim Render (Server + Client) keine zusätzliche Filter- * Logik brauchen. * * Server-only (`sanitize-html` braucht Node-APIs). Komponenten dürfen das * Modul daher nicht importieren — der `.server.ts`-Suffix erzwingt das. */ import sanitizeHtml from 'sanitize-html'; const HTML_BLOCK_OPTIONS: sanitizeHtml.IOptions = { // Allowlist: bewusst restriktiv. Alles, was nicht in `allowedTags` steht, // wird entfernt. `allowedSchemes` blockiert javascript:, data: usw. allowedTags: [ 'p', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup', 'mark', 'a', 'blockquote', 'cite', 'q', 'ul', 'ol', 'li', 'pre', 'code', 'kbd', 'samp', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'div', 'span', 'section', 'article', 'aside', 'figure', 'figcaption', 'img', 'picture', 'source', 'iframe', ], allowedAttributes: { a: ['href', 'name', 'target', 'rel', 'title'], img: ['src', 'srcset', 'sizes', 'alt', 'width', 'height', 'loading', 'decoding'], source: ['src', 'srcset', 'sizes', 'type', 'media'], iframe: [ 'src', 'width', 'height', 'allow', 'allowfullscreen', 'referrerpolicy', 'loading', 'title', 'frameborder', ], '*': ['class', 'id', 'style', 'data-*', 'aria-*'], }, allowedSchemes: ['http', 'https', 'mailto', 'tel'], allowedSchemesByTag: { img: ['http', 'https', 'data'], }, allowedIframeHostnames: [ 'www.youtube.com', 'youtube.com', 'www.youtube-nocookie.com', 'player.vimeo.com', 'open.spotify.com', ], allowProtocolRelative: false, transformTags: { a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, rel: attribs.target === '_blank' ? 'noopener noreferrer' : (attribs.rel ?? ''), }, }), }, }; /** * Pro-Block-Hash-Cache: sanitize-html ist teuer (~ms pro KB), das CMS * liefert dasselbe Markup wieder und wieder. Key = raw input, Value = * sanitised output. Größe geclippt (LRU-ähnlich via Map-Insertion-Order). */ const SANITIZE_CACHE = new Map(); const SANITIZE_CACHE_MAX = 500; export function sanitizeUntrustedHtml(html: string): string { const cached = SANITIZE_CACHE.get(html); if (cached !== undefined) { // Move to end (most-recently-used). SANITIZE_CACHE.delete(html); SANITIZE_CACHE.set(html, cached); return cached; } const out = sanitizeHtml(html, HTML_BLOCK_OPTIONS); SANITIZE_CACHE.set(html, out); if (SANITIZE_CACHE.size > SANITIZE_CACHE_MAX) { // Evict oldest. const oldest = SANITIZE_CACHE.keys().next().value; if (oldest !== undefined) SANITIZE_CACHE.delete(oldest); } return out; } type Block = { _type?: string; html?: unknown } & Record; function isBlock(value: unknown): value is Block { return typeof value === 'object' && value !== null; } function visit(node: unknown): void { if (Array.isArray(node)) { for (const item of node) visit(item); return; } if (!isBlock(node)) return; if (node._type === 'html' && typeof node.html === 'string') { node.html = sanitizeUntrustedHtml(node.html); } for (const key of Object.keys(node)) { if (key === '_type' || key === 'html') continue; visit(node[key]); } } /** Mutiert den Tree in-place: alle HtmlBlocks bekommen sanitisiertes `html`. */ export function sanitizeBlocks(root: unknown): void { visit(root); }