feat: add safe rich html viewer
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SafeRichHTMLProps = {
|
||||
html?: string | null
|
||||
fallback?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const allowedTags = new Set([
|
||||
"a",
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"div",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"i",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"span",
|
||||
"strong",
|
||||
"u",
|
||||
"ul",
|
||||
])
|
||||
|
||||
const allowedAttrs = new Set(["class", "href", "rel", "target", "title"])
|
||||
|
||||
function escapeHTML(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
function looksLikeHTML(value: string) {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(value)
|
||||
}
|
||||
|
||||
function plainTextToHTML(value: string) {
|
||||
return escapeHTML(value)
|
||||
.split(/\n{2,}/)
|
||||
.map((part) => `<p>${part.replaceAll("\n", "<br>")}</p>`)
|
||||
.join("")
|
||||
}
|
||||
|
||||
function isSafeURL(value: string) {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
if (value.startsWith("/")) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const url = new URL(value, window.location.origin)
|
||||
return ["http:", "https:"].includes(url.protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRichHTML(value: string) {
|
||||
const source = looksLikeHTML(value) ? value : plainTextToHTML(value)
|
||||
if (typeof window === "undefined") {
|
||||
return source
|
||||
}
|
||||
|
||||
const doc = new DOMParser().parseFromString(source, "text/html")
|
||||
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT)
|
||||
const elements: Element[] = []
|
||||
|
||||
while (walker.nextNode()) {
|
||||
elements.push(walker.currentNode as Element)
|
||||
}
|
||||
|
||||
for (const element of elements) {
|
||||
const tag = element.tagName.toLowerCase()
|
||||
if (!allowedTags.has(tag)) {
|
||||
element.replaceWith(...Array.from(element.childNodes))
|
||||
continue
|
||||
}
|
||||
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
const name = attr.name.toLowerCase()
|
||||
const attrValue = attr.value.trim()
|
||||
if (name.startsWith("on") || !allowedAttrs.has(name)) {
|
||||
element.removeAttribute(attr.name)
|
||||
continue
|
||||
}
|
||||
if (name === "href" && !isSafeURL(attrValue)) {
|
||||
element.removeAttribute(attr.name)
|
||||
}
|
||||
}
|
||||
|
||||
if (tag === "a" && element.getAttribute("href")) {
|
||||
element.setAttribute("target", "_blank")
|
||||
element.setAttribute("rel", "noreferrer noopener")
|
||||
}
|
||||
}
|
||||
|
||||
return doc.body.innerHTML
|
||||
}
|
||||
|
||||
export function isRichTextEmpty(value?: string | null) {
|
||||
const normalized = String(value ?? "").trim()
|
||||
if (!normalized) {
|
||||
return true
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return normalized.replace(/<[^>]*>/g, "").trim() === ""
|
||||
}
|
||||
const doc = new DOMParser().parseFromString(normalized, "text/html")
|
||||
return (doc.body.textContent ?? "").trim() === "" && doc.body.querySelector("img") === null
|
||||
}
|
||||
|
||||
export function SafeRichHTML({ html, fallback = "-", className }: SafeRichHTMLProps) {
|
||||
const normalized = String(html ?? "").trim()
|
||||
const safeHTML = useMemo(() => {
|
||||
if (!normalized) {
|
||||
return plainTextToHTML(fallback)
|
||||
}
|
||||
return sanitizeRichHTML(normalized)
|
||||
}, [fallback, normalized])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"break-words text-sm leading-6 [&_a]:text-primary [&_a]:underline [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_h1]:mb-2 [&_h1]:text-lg [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:font-semibold [&_li]:my-1 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:m-0 [&_p+*]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_strong]:font-semibold [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
|
||||
className,
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: safeHTML }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user