65 lines
1.6 KiB
TypeScript
65 lines
1.6 KiB
TypeScript
import DOMPurify from 'dompurify'
|
|
import type { Config, UponSanitizeElementHook } from 'dompurify'
|
|
|
|
type SafeHtmlProps = {
|
|
html: string | null | undefined
|
|
className?: string
|
|
allowEmbeds?: boolean
|
|
}
|
|
|
|
const ALLOWED_EMBED_HOSTS = new Set(['videos.cdn.spotlightr.com'])
|
|
|
|
function isAllowedEmbedSrc(src: string | null) {
|
|
if (!src) return false
|
|
|
|
try {
|
|
const url = new URL(src, 'https://invalid.local')
|
|
return url.protocol === 'https:' && ALLOWED_EMBED_HOSTS.has(url.hostname)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function sanitizeHtml(html: string | null | undefined, allowEmbeds: boolean) {
|
|
const config: Config = {
|
|
USE_PROFILES: { html: true },
|
|
ADD_ATTR: allowEmbeds
|
|
? ['style', 'allow', 'allowfullscreen', 'allowtransparency', 'frameborder', 'scrolling', 'name', 'src']
|
|
: ['style'],
|
|
}
|
|
|
|
if (!allowEmbeds) {
|
|
return DOMPurify.sanitize(html ?? '', config)
|
|
}
|
|
|
|
const removeUntrustedIframe: UponSanitizeElementHook = (currentNode) => {
|
|
if (
|
|
currentNode instanceof HTMLIFrameElement &&
|
|
!isAllowedEmbedSrc(currentNode.getAttribute('src'))
|
|
) {
|
|
currentNode.remove()
|
|
}
|
|
}
|
|
|
|
DOMPurify.addHook('uponSanitizeElement', removeUntrustedIframe)
|
|
try {
|
|
return DOMPurify.sanitize(html ?? '', {
|
|
...config,
|
|
ADD_TAGS: ['iframe'],
|
|
})
|
|
} finally {
|
|
DOMPurify.removeHook('uponSanitizeElement', removeUntrustedIframe)
|
|
}
|
|
}
|
|
|
|
export function SafeHtml({ html, className, allowEmbeds = false }: SafeHtmlProps) {
|
|
return (
|
|
<div
|
|
className={className}
|
|
dangerouslySetInnerHTML={{
|
|
__html: sanitizeHtml(html, allowEmbeds),
|
|
}}
|
|
/>
|
|
)
|
|
}
|