/** * HtmlSanitizerService — clean user-authored HTML from the WYSIWYG editor * before storing it in the DB. * * Pipeline order on the render side (posts.js): * 1. content already sanitized HTML (this service ran on save) * 2. autoembed → adds iframes for Spotify/YouTube/etc (server-controlled, safe) * 3. shortcode replacement → adds custom embed HTML (server-controlled, safe) * * Shortcodes like [[track:UUID]] / [[album:Name]] / [[playlist:slug]] live in * text nodes — sanitize-html preserves text, so they pass through untouched. */ import sanitizeHtml from 'sanitize-html'; const ALLOWED_TAGS = [ // Block 'p', 'div', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'ul', 'ol', 'li', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'td', 'th', // Inline 'strong', 'em', 'b', 'i', 'u', 's', 'mark', 'small', 'sub', 'sup', 'code', 'a', 'span', 'img', // Native media (bare .webm/.mp4/.mp3 embeds + federated-in players) 'video', 'audio', 'source', ]; // Per-tag attribute allowlist. '*' applies to every tag. const ALLOWED_ATTRS = { '*': ['class', 'id', 'dir', 'lang', 'data-sc'], a: ['href', 'title', 'target', 'rel'], img: ['src', 'alt', 'title', 'width', 'height', 'loading'], video: ['src', 'controls', 'preload', 'poster', 'width', 'height', 'loop', 'muted', 'autoplay', 'playsinline'], audio: ['src', 'controls', 'preload', 'loop', 'muted', 'autoplay'], source: ['src', 'type'], }; const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel']; const ALLOWED_SCHEMES_BY_TAG = { img: ['http', 'https', 'data'], a: ['http', 'https', 'mailto', 'tel'], video: ['http', 'https'], audio: ['http', 'https'], source: ['http', 'https'], }; class HtmlSanitizerService { /** * Sanitize user HTML. Returns a clean string ready for DB storage. * Empty input → empty string. Anything that would have rendered as a *