/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import DOMPurify, { type Config } from 'dompurify' import { useEffect, useMemo, useRef } from 'react' import { cn } from '@/lib/utils' export type HtmlContentVariant = 'inline' | 'isolated' interface HtmlContentProps { content: string className?: string variant?: HtmlContentVariant } const isolatedContentSandbox = 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation' const isolatedContentBaseStyles = ` ` const isolatedSanitizeOptions = { ADD_ATTR: [ 'allowfullscreen', 'autoplay', 'class', 'controls', 'default', 'id', 'kind', 'label', 'loading', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'referrerpolicy', 'rel', 'srclang', 'style', 'target', ], ADD_TAGS: ['audio', 'iframe', 'picture', 'source', 'style', 'track', 'video'], FORBID_ATTR: ['srcdoc'], FORBID_TAGS: ['base', 'embed', 'link', 'meta', 'object', 'script'], FORCE_BODY: true, } satisfies Config function hardenIsolatedHtml(html: string): string { if (typeof document === 'undefined') { return html } const template = document.createElement('template') template.innerHTML = html template.content.querySelectorAll('a[target="_blank"]').forEach((link) => { const rel = new Set( link.getAttribute('rel')?.split(/\s+/).filter(Boolean) ?? [] ) rel.add('noopener') rel.add('noreferrer') link.setAttribute('rel', [...rel].join(' ')) }) template.content.querySelectorAll('iframe').forEach((frame) => { frame.removeAttribute('srcdoc') frame.setAttribute('sandbox', isolatedContentSandbox) frame.setAttribute('referrerpolicy', 'no-referrer') if (!frame.hasAttribute('loading')) { frame.setAttribute('loading', 'lazy') } }) return template.innerHTML } function sanitizeHtmlContent( content: string, variant: HtmlContentVariant ): string { if (variant === 'isolated') { const html = DOMPurify.sanitize(content, isolatedSanitizeOptions) return hardenIsolatedHtml(html) } return DOMPurify.sanitize(content) } function syncDarkClass(wrapper: HTMLElement): void { const isDark = document.documentElement.classList.contains('dark') wrapper.classList.toggle('dark', isDark) } function IsolatedHtmlContent(props: { className?: string html: string }): React.ReactElement { const containerRef = useRef(null) useEffect(() => { const container = containerRef.current if (!container) { return } const shadowRoot = container.shadowRoot ?? container.attachShadow({ mode: 'open' }) const applicationStyleNodes = [ ...document.head.querySelectorAll( 'style, link[rel="stylesheet"]' ), ].map((node) => node.cloneNode(true)) const wrapper = document.createElement('div') syncDarkClass(wrapper) wrapper.innerHTML = props.html const contentTemplate = document.createElement('template') contentTemplate.innerHTML = isolatedContentBaseStyles shadowRoot.replaceChildren( ...applicationStyleNodes, contentTemplate.content, wrapper ) const observer = new MutationObserver(() => syncDarkClass(wrapper)) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }) return () => observer.disconnect() }, [props.html]) return (
) } export function HtmlContent(props: HtmlContentProps) { const variant = props.variant ?? 'inline' const html = useMemo( () => sanitizeHtmlContent(props.content, variant), [props.content, variant] ) if (variant === 'isolated') { return } return (
) }