const sanitizeHtml = require('sanitize-html') // Allowlist for wiki/post body HTML. Anything not listed is stripped. This runs // on every save so the stored value is already safe; the client re-sanitizes on // render as defense in depth. Tuned for rich-text content from the admin editor. const OPTIONS = { allowedTags: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'hr', 'blockquote', 'pre', 'code', 'ul', 'ol', 'li', 'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span', 'a', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'th', 'td', ], allowedAttributes: { a: ['href', 'name', 'target', 'rel', 'title'], img: ['src', 'alt', 'title', 'width', 'height'], span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3) th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'], // Block alignment from the rich-text editor. `style` is only honored for the // properties/values whitelisted in allowedStyles below — everything else in // the style attribute is stripped. p: ['style'], h1: ['style'], h2: ['style'], h3: ['style'], h4: ['style'], h5: ['style'], h6: ['style'], }, // Restrict inline styles to text-align (left/right/center/justify) only. Any // other CSS property, or an unlisted value, is discarded. allowedStyles: { '*': { 'text-align': [/^(left|right|center|justify)$/], }, }, // http/https for links and images, mailto for links, plus relative URLs so // uploaded images (/uploads/...) and internal links (/wiki/...) pass through. allowedSchemes: ['http', 'https', 'mailto'], allowedSchemesByTag: { img: ['http', 'https'] }, allowProtocolRelative: false, // Force safe rel on links that open a new tab; drop empty/odd attributes. transformTags: { a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true), }, disallowedTagsMode: 'discard', } /** * Sanitize a block of body HTML against the allowlist above. * Null/empty input is returned unchanged. * @param {string|null|undefined} html * @returns {string|null|undefined} */ function cleanBody(html) { if (html == null || html === '') return html return sanitizeHtml(String(html), OPTIONS) } /** * Derive a plain-text excerpt from body HTML. Strips tags, collapses * whitespace, and truncates to `max` chars (with an ellipsis). Used as the * excerpt fallback when an author leaves the excerpt field blank. * @param {string|null|undefined} html * @param {number} [max=280] * @returns {string|null} */ function deriveExcerpt(html, max = 280) { if (html == null) return null const text = String(html) .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() if (!text) return null return text.length > max ? `${text.slice(0, max - 3)}...` : text } module.exports = { cleanBody, deriveExcerpt, OPTIONS }