// Whitelist-based HTML sanitizer for rendering third-party post
// descriptions (e.g. Patreon) via v-html. Strips script/style/iframe
// + event handlers + dangerous hrefs. Tag whitelist covers what
// Patreon, SubscribeStar, and similar platforms ship in normal posts.
//
// Not a substitute for server-side sanitization in higher-stakes
// contexts. This is for FC's single-operator homelab posture where
// the content source is the operator's own subscriptions.
const ALLOWED_TAGS = new Set([
'a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li',
'ol', 'p', 'pre', 's', 'span', 'strong', 'sub', 'sup', 'u', 'ul',
])
const ALLOWED_ATTRS = {
a: new Set(['href', 'title', 'rel', 'target']),
img: new Set(['src', 'alt', 'title', 'width', 'height']),
// any tag → these always allowed
'*': new Set(['class']),
}
const SAFE_URL_RE = /^(https?:|mailto:|#|\/)/i
export function sanitizeHtml (html) {
if (typeof html !== 'string' || !html) return ''
const doc = new DOMParser().parseFromString(html, 'text/html')
_scrubNode(doc.body)
return doc.body.innerHTML
}
function _scrubNode (node) {
const children = Array.from(node.children)
for (const child of children) {
const tag = child.tagName.toLowerCase()
if (!ALLOWED_TAGS.has(tag)) {
// Strip the tag but keep its text content as a fallback.
const text = document.createTextNode(child.textContent || '')
child.replaceWith(text)
continue
}
const allowed = new Set([
...(ALLOWED_ATTRS[tag] || []),
...(ALLOWED_ATTRS['*'] || []),
])
for (const attr of Array.from(child.attributes)) {
const name = attr.name.toLowerCase()
if (name.startsWith('on') || !allowed.has(name)) {
child.removeAttribute(attr.name)
continue
}
if ((name === 'href' || name === 'src') && !SAFE_URL_RE.test(attr.value)) {
child.removeAttribute(attr.name)
}
}
if (tag === 'a' && child.getAttribute('target') === '_blank') {
child.setAttribute('rel', 'noopener noreferrer')
}
_scrubNode(child)
}
}