82 lines
1.7 KiB
TypeScript
82 lines
1.7 KiB
TypeScript
import { config } from "@/lib/config";
|
|
|
|
const ALLOWED_TAGS = new Set([
|
|
"a",
|
|
"b",
|
|
"blockquote",
|
|
"br",
|
|
"code",
|
|
"em",
|
|
"h1",
|
|
"h2",
|
|
"h3",
|
|
"h4",
|
|
"h5",
|
|
"h6",
|
|
"i",
|
|
"li",
|
|
"ol",
|
|
"p",
|
|
"pre",
|
|
"s",
|
|
"strong",
|
|
"strike",
|
|
"u",
|
|
"ul",
|
|
]);
|
|
|
|
const BLOCKED_TAGS = /<(script|style|iframe|object|embed|svg|math|template)[\s\S]*?<\/\1>/gi;
|
|
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g;
|
|
const ATTR_RE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/g;
|
|
|
|
export function sanitizeUrl(url: string) {
|
|
const trimmed = url.trim();
|
|
if (!trimmed) return "";
|
|
|
|
try {
|
|
const parsed = new URL(trimmed, config.site.url);
|
|
if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "mailto:") {
|
|
return trimmed;
|
|
}
|
|
} catch {
|
|
return "";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function escapeAttribute(value: string) {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """)
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
export function sanitizeHtml(html: string) {
|
|
return html.replace(BLOCKED_TAGS, "").replace(TAG_RE, (tag, rawName: string, rawAttrs: string) => {
|
|
const name = rawName.toLowerCase();
|
|
if (!ALLOWED_TAGS.has(name)) return "";
|
|
|
|
if (tag.startsWith("</")) {
|
|
return `</${name}>`;
|
|
}
|
|
|
|
if (name !== "a") {
|
|
return `<${name}>`;
|
|
}
|
|
|
|
let href = "";
|
|
for (const match of rawAttrs.matchAll(ATTR_RE)) {
|
|
if (match[1].toLowerCase() === "href") {
|
|
href = sanitizeUrl(match[2].replace(/^["']|["']$/g, ""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return href
|
|
? `<a href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer">`
|
|
: "<a>";
|
|
});
|
|
}
|