81c82c33e6
- blog-utils.resolvePostOverviewBlocks: parallel getPostBySlug via Promise.all + .catch(null) instead of sequential await loop. - blog-utils.resolveCalendarBlocks: same fix for calendar items. - [...slug] page load: SearchableText/Calendar/DeadlineBanner now run parallel after tagsMap (was strict waterfall). - sanitize-blocks: LRU cache keyed on raw html input — sanitize-html is ~ms/KB, the same content repeats across pages and cache hits. - cms.getNormalizedTranslations: WeakMap-memoised the per-request Object.entries normalize loop on top of the existing 5-min bundle TTL. - cms.cached: opt-in dev cache via DEV_CACHE_CMS=1 (default still off). - rusty-image.ensureTransformedImage: was async-wrapping a sync URL builder; made sync, removed awaits at call sites — saves microtasks. - BlockRenderer: lazy-load StrommixBlock (~220 KB) via dynamic import in the template — pages without the live-strommix widget no longer ship its bundle. Also: enabled Caddy zstd+gzip on windwiderstand.de vhost (server-side edit; verified `content-encoding: zstd` on response). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
118 lines
3.8 KiB
TypeScript
118 lines
3.8 KiB
TypeScript
/**
|
|
* 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<string, string>();
|
|
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<string, unknown>;
|
|
|
|
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);
|
|
}
|