mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
fix(web): 修复自定义 HTML 样式被过滤及排版间距异常的问题 (#5795)
* fix(web): 修复自定义 HTML 样式被过滤及排版间距异常的问题 * fix(web): isolate custom HTML rendering --------- Co-authored-by: CaIon <i@caion.me>
This commit is contained in:
+142
-3
@@ -16,18 +16,157 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify, { type Config } from 'dompurify'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type HtmlContentVariant = 'inline' | 'isolated'
|
||||||
|
|
||||||
interface HtmlContentProps {
|
interface HtmlContentProps {
|
||||||
content: string
|
content: string
|
||||||
className?: 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) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
+12
-2
@@ -16,7 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
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'
|
import { Markdown } from '@/components/ui/markdown'
|
||||||
|
|
||||||
type RichContentMode = 'markdown' | 'html'
|
type RichContentMode = 'markdown' | 'html'
|
||||||
@@ -26,11 +29,18 @@ interface RichContentProps {
|
|||||||
mode?: RichContentMode
|
mode?: RichContentMode
|
||||||
breaks?: boolean
|
breaks?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
htmlVariant?: HtmlContentVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RichContent(props: RichContentProps) {
|
export function RichContent(props: RichContentProps) {
|
||||||
if (props.mode === 'html') {
|
if (props.mode === 'html') {
|
||||||
return <HtmlContent content={props.content} className={props.className} />
|
return (
|
||||||
|
<HtmlContent
|
||||||
|
content={props.content}
|
||||||
|
className={props.className}
|
||||||
|
variant={props.htmlVariant}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+15
-1
@@ -122,6 +122,7 @@ export function About() {
|
|||||||
const rawContent = data?.data?.trim() ?? ''
|
const rawContent = data?.data?.trim() ?? ''
|
||||||
const hasContent = rawContent.length > 0
|
const hasContent = rawContent.length > 0
|
||||||
const isUrl = hasContent && isHttpUrl(rawContent)
|
const isUrl = hasContent && isHttpUrl(rawContent)
|
||||||
|
const contentIsHtml = hasContent && isLikelyHtml(rawContent)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<PublicLayout>
|
<PublicLayout>
|
||||||
<div className='mx-auto max-w-6xl px-4 py-8'>
|
<div className='mx-auto max-w-6xl px-4 py-8'>
|
||||||
<RichContent
|
<RichContent
|
||||||
mode={isLikelyHtml(rawContent) ? 'html' : 'markdown'}
|
mode='markdown'
|
||||||
content={rawContent}
|
content={rawContent}
|
||||||
className='prose-neutral dark:prose-invert max-w-none'
|
className='prose-neutral dark:prose-invert max-w-none'
|
||||||
/>
|
/>
|
||||||
|
|||||||
+16
-1
@@ -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 (
|
return (
|
||||||
<PublicLayout>
|
<PublicLayout>
|
||||||
<div className='mx-auto max-w-6xl px-4 py-8'>
|
<div className='mx-auto max-w-6xl px-4 py-8'>
|
||||||
<RichContent
|
<RichContent
|
||||||
mode={isLikelyHtml(content) ? 'html' : 'markdown'}
|
mode='markdown'
|
||||||
content={content}
|
content={content}
|
||||||
className='custom-home-content'
|
className='custom-home-content'
|
||||||
/>
|
/>
|
||||||
|
|||||||
+18
-9
@@ -52,6 +52,7 @@ export function LegalDocument({
|
|||||||
const rawContent = data?.data?.trim() ?? ''
|
const rawContent = data?.data?.trim() ?? ''
|
||||||
const hasContent = rawContent.length > 0
|
const hasContent = rawContent.length > 0
|
||||||
const isUrl = hasContent && isHttpUrl(rawContent)
|
const isUrl = hasContent && isHttpUrl(rawContent)
|
||||||
|
const contentIsHtml = hasContent && isLikelyHtml(rawContent)
|
||||||
const success = data?.success ?? false
|
const success = data?.success ?? false
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -122,18 +123,26 @@ export function LegalDocument({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicLayout>
|
<PublicLayout showMainContainer={!contentIsHtml}>
|
||||||
<div className='mx-auto max-w-4xl space-y-6 py-12'>
|
{contentIsHtml ? (
|
||||||
<div className='space-y-2'>
|
|
||||||
<h1 className='text-3xl font-semibold tracking-tight'>{title}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RichContent
|
<RichContent
|
||||||
mode={isLikelyHtml(rawContent) ? 'html' : 'markdown'}
|
mode='html'
|
||||||
|
htmlVariant='isolated'
|
||||||
content={rawContent}
|
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>
|
</PublicLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user