From fda8177864d7ce0f3ebf9c615e1700eb234b5b45 Mon Sep 17 00:00:00 2001 From: olwater <52482488+olwater@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:34:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E4=BF=AE=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20HTML=20=E6=A0=B7=E5=BC=8F=E8=A2=AB?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E5=8F=8A=E6=8E=92=E7=89=88=E9=97=B4=E8=B7=9D?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E7=9A=84=E9=97=AE=E9=A2=98=20(#5795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): 修复自定义 HTML 样式被过滤及排版间距异常的问题 * fix(web): isolate custom HTML rendering --------- Co-authored-by: CaIon --- web/default/src/components/html-content.tsx | 145 +++++++++++++++++- web/default/src/components/rich-content.tsx | 14 +- web/default/src/features/about/index.tsx | 16 +- web/default/src/features/home/index.tsx | 17 +- .../src/features/legal/legal-document.tsx | 27 ++-- 5 files changed, 203 insertions(+), 16 deletions(-) diff --git a/web/default/src/components/html-content.tsx b/web/default/src/components/html-content.tsx index c4e9d9cd..1332d72f 100644 --- a/web/default/src/components/html-content.tsx +++ b/web/default/src/components/html-content.tsx @@ -16,18 +16,157 @@ along with this program. If not, see . 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 = ` + +` + +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(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 ( +
+ ) } 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 + } return (
. 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 + return ( + + ) } return ( diff --git a/web/default/src/features/about/index.tsx b/web/default/src/features/about/index.tsx index ac19df2a..22834305 100644 --- a/web/default/src/features/about/index.tsx +++ b/web/default/src/features/about/index.tsx @@ -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 ( + + + + ) + } + return (
diff --git a/web/default/src/features/home/index.tsx b/web/default/src/features/home/index.tsx index fefdd67f..0494191e 100644 --- a/web/default/src/features/home/index.tsx +++ b/web/default/src/features/home/index.tsx @@ -57,11 +57,26 @@ export function Home() { ) } + const contentIsHtml = isLikelyHtml(content) + + if (contentIsHtml) { + return ( + + + + ) + } + return (
diff --git a/web/default/src/features/legal/legal-document.tsx b/web/default/src/features/legal/legal-document.tsx index d60d8612..50d813e4 100644 --- a/web/default/src/features/legal/legal-document.tsx +++ b/web/default/src/features/legal/legal-document.tsx @@ -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 ( - -
-
-

{title}

-
- + + {contentIsHtml ? ( -
+ ) : ( +
+
+

{title}

+
+ + +
+ )}
) }