diff --git a/web/components/safe-rich-html.tsx b/web/components/safe-rich-html.tsx new file mode 100644 index 0000000..569f10e --- /dev/null +++ b/web/components/safe-rich-html.tsx @@ -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) => `

${part.replaceAll("\n", "
")}

`) + .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 ( +
+ ) +}