Files
FabledGrimoire/frontend/src/utils/markdown.ts
T
bvandeusenandClaude Sonnet 4.6 00643c778e security: fix 10 vulnerabilities from security audit
- SSRF: block private/internal URLs in image cache fetch
- SSRF: block private/internal URLs in RSS feed fetch (scheme guard)
- SSRF: block private/internal URLs in CalDAV URL setting
- Auth: require login for GET /api/images/<id> (was unauthenticated)
- Auth: restrict Ollama model pull/delete to admin users only
- Info disclosure: remove email from /api/users/search response
- OAuth: skip email-based account linking when email_verified is false
- Config: raise hard error on default SECRET_KEY when SECURE_COOKIES=true
- Rate limit: document proxy header requirement; add startup warning
- XSS: remove src/alt from global DOMPurify ADD_ATTR allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:37:13 -04:00

70 lines
2.2 KiB
TypeScript

import { marked, type RendererThis, type Tokens } from "marked";
import DOMPurify from "dompurify";
import { linkifyTags, linkifyWikilinks } from "@/utils/tags";
function decodeEntities(text: string): string {
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
return textarea.value;
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/<[^>]+>/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-");
}
export function stripFirstLineTags(text: string): string {
const match = text.match(/^[ \t]*((?:(?<!\w)#[\w]+(?:\/[\w]+)*[ \t]*)+)\n?/);
if (!match) return text;
// Verify every #-token is a tag, not a heading (headings have "# " with space)
const tokens = match[1].trim().split(/\s+/);
const allTags = tokens.every((t) => /^#[\w]+(?:\/[\w]+)*$/.test(t));
if (!allTags) return text;
return text.slice(match[0].length);
}
const headingRenderer = {
heading(this: RendererThis, { tokens, depth }: Tokens.Heading): string {
const text = this.parser.parseInline(tokens);
const id = slugify(text);
return `<h${depth} id="${id}">${text}</h${depth}>`;
},
};
marked.use({ renderer: headingRenderer });
const PURIFY_OPTS_FULL = {
ADD_ATTR: ["data-tag", "data-title"],
FORCE_BODY: true,
};
const PURIFY_OPTS_PREVIEW = {
FORBID_TAGS: ["a", "img"],
FORCE_BODY: true,
};
export function renderMarkdown(text: string): string {
const stripped = stripFirstLineTags(text);
const decoded = decodeEntities(stripped);
const html = marked(decoded) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_FULL);
// marked escapes ' to &#39; — replace after sanitization to ensure clean rendering
return sanitized.replace(/&#39;/g, "'");
}
export function renderPreview(text: string): string {
const stripped = stripFirstLineTags(text);
const decoded = decodeEntities(stripped);
const html = marked(decoded) as string;
const withTags = linkifyTags(html);
const withLinks = linkifyWikilinks(withTags);
const sanitized = DOMPurify.sanitize(withLinks, PURIFY_OPTS_PREVIEW);
return sanitized.replace(/&#39;/g, "'");
}