Commit fda81778 by olwater Committed by GitHub

fix(web): 修复自定义 HTML 样式被过滤及排版间距异常的问题 (#5795)

* fix(web): 修复自定义 HTML 样式被过滤及排版间距异常的问题

* fix(web): isolate custom HTML rendering

---------

Co-authored-by: CaIon <i@caion.me>
parent 5bf34683
......@@ -16,18 +16,157 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import DOMPurify from 'dompurify'
import { useMemo } from 'react'
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 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' })
shadowRoot.innerHTML = `${isolatedContentBaseStyles}${props.html}`
}, [props.html])
return (
<div
ref={containerRef}
className={cn('block w-full', props.className)}
/>
)
}
export function HtmlContent(props: HtmlContentProps) {
const html = useMemo(() => DOMPurify.sanitize(props.content), [props.content])
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
......
......@@ -16,7 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { HtmlContent } from '@/components/html-content'
import {
HtmlContent,
type HtmlContentVariant,
} from '@/components/html-content'
import { Markdown } from '@/components/ui/markdown'
type RichContentMode = 'markdown' | 'html'
......@@ -26,11 +29,18 @@ interface RichContentProps {
mode?: RichContentMode
breaks?: boolean
className?: string
htmlVariant?: HtmlContentVariant
}
export function RichContent(props: RichContentProps) {
if (props.mode === 'html') {
return <HtmlContent content={props.content} className={props.className} />
return (
<HtmlContent
content={props.content}
className={props.className}
variant={props.htmlVariant}
/>
)
}
return (
......
......@@ -122,6 +122,7 @@ export function About() {
const rawContent = data?.data?.trim() ?? ''
const hasContent = rawContent.length > 0
const isUrl = hasContent && isHttpUrl(rawContent)
const contentIsHtml = hasContent && isLikelyHtml(rawContent)
if (isLoading) {
return (
......@@ -157,11 +158,24 @@ export function About() {
)
}
if (contentIsHtml) {
return (
<PublicLayout showMainContainer={false}>
<RichContent
mode='html'
htmlVariant='isolated'
content={rawContent}
className='prose-neutral dark:prose-invert max-w-none'
/>
</PublicLayout>
)
}
return (
<PublicLayout>
<div className='mx-auto max-w-6xl px-4 py-8'>
<RichContent
mode={isLikelyHtml(rawContent) ? 'html' : 'markdown'}
mode='markdown'
content={rawContent}
className='prose-neutral dark:prose-invert max-w-none'
/>
......
......@@ -57,11 +57,26 @@ export function Home() {
)
}
const contentIsHtml = isLikelyHtml(content)
if (contentIsHtml) {
return (
<PublicLayout showMainContainer={false}>
<RichContent
mode='html'
htmlVariant='isolated'
content={content}
className='custom-home-content'
/>
</PublicLayout>
)
}
return (
<PublicLayout>
<div className='mx-auto max-w-6xl px-4 py-8'>
<RichContent
mode={isLikelyHtml(content) ? 'html' : 'markdown'}
mode='markdown'
content={content}
className='custom-home-content'
/>
......
......@@ -52,6 +52,7 @@ export function LegalDocument({
const rawContent = data?.data?.trim() ?? ''
const hasContent = rawContent.length > 0
const isUrl = hasContent && isHttpUrl(rawContent)
const contentIsHtml = hasContent && isLikelyHtml(rawContent)
const success = data?.success ?? false
if (isLoading) {
......@@ -122,18 +123,26 @@ export function LegalDocument({
}
return (
<PublicLayout>
<div className='mx-auto max-w-4xl space-y-6 py-12'>
<div className='space-y-2'>
<h1 className='text-3xl font-semibold tracking-tight'>{title}</h1>
</div>
<PublicLayout showMainContainer={!contentIsHtml}>
{contentIsHtml ? (
<RichContent
mode={isLikelyHtml(rawContent) ? 'html' : 'markdown'}
mode='html'
htmlVariant='isolated'
content={rawContent}
className='prose-neutral dark:prose-invert max-w-none'
/>
</div>
) : (
<div className='mx-auto max-w-4xl space-y-6 py-12'>
<div className='space-y-2'>
<h1 className='text-3xl font-semibold tracking-tight'>{title}</h1>
</div>
<RichContent
mode='markdown'
content={rawContent}
className='prose-neutral dark:prose-invert max-w-none'
/>
</div>
)}
</PublicLayout>
)
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment