Files
new-api/web/src/components/html-content.tsx
T
Calcium-Ion 31d70fca39 refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
2026-07-20 16:48:43 +08:00

208 lines
5.0 KiB
TypeScript

/*
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 <https://www.gnu.org/licenses/>.
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 = `
<style>
:host {
display: block;
width: 100%;
color: inherit;
font: inherit;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
img,
video,
iframe {
max-width: 100%;
}
iframe {
border: 0;
}
</style>
`
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<HTMLDivElement>(null)
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
const shadowRoot =
container.shadowRoot ?? container.attachShadow({ mode: 'open' })
const applicationStyleNodes = [
...document.head.querySelectorAll<HTMLLinkElement | HTMLStyleElement>(
'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 (
<div ref={containerRef} className={cn('block w-full', props.className)} />
)
}
export function HtmlContent(props: HtmlContentProps) {
const variant = props.variant ?? 'inline'
const html = useMemo(
() => sanitizeHtmlContent(props.content, variant),
[props.content, variant]
)
if (variant === 'isolated') {
return <IsolatedHtmlContent className={props.className} html={html} />
}
return (
<div
className={cn(
'prose prose-neutral dark:prose-invert max-w-none',
props.className
)}
// eslint-disable-next-line react/no-danger -- html is sanitized above
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}