main
js 200 lines 5.37 KB
Raw
1 import DOMPurify from "/vendor/dompurify/purify.es.mjs";
2 import { marked } from "/vendor/marked/marked.esm.js";
3 import { addBlankTargetsToLinks } from "/js/html-links.js";
4
5 const GITHUB_REPO_ROUTE_PREFIXES = new Set([
6 "actions",
7 "blob",
8 "branches",
9 "commit",
10 "commits",
11 "compare",
12 "discussions",
13 "issues",
14 "labels",
15 "milestones",
16 "packages",
17 "projects",
18 "pulls",
19 "raw",
20 "releases",
21 "security",
22 "tags",
23 "tree",
24 "wiki",
25 ]);
26
27 const DOMPURIFY_CONFIG = Object.freeze({
28 USE_PROFILES: { html: true },
29 FORBID_TAGS: ["script", "iframe", "object", "embed", "svg", "math"],
30 });
31
32 const DATA_IMAGE_URL_PATTERN =
33 /^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,[a-z0-9+/=\s]+$/i;
34
35 function getDompurifyConfig(options = {}) {
36 const config = { ...DOMPURIFY_CONFIG };
37 if (options.allowLatex) {
38 config.ADD_TAGS = ["latex"];
39 }
40 return config;
41 }
42
43 function parseGithubRepoContext(githubUrl) {
44 if (!githubUrl || typeof githubUrl !== "string") return null;
45
46 let repoUrl;
47 try {
48 repoUrl = new URL(githubUrl.trim().replace(/\.git$/i, ""));
49 } catch {
50 return null;
51 }
52
53 if (repoUrl.hostname !== "github.com") return null;
54
55 const [owner, repo] = repoUrl.pathname
56 .replace(/^\/+|\/+$/g, "")
57 .split("/");
58 if (!owner || !repo) return null;
59
60 return { owner, repo };
61 }
62
63 function shouldSkipRebase(value) {
64 return (
65 !value ||
66 value.startsWith("#") ||
67 value.startsWith("//") ||
68 /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)
69 );
70 }
71
72 function resolveRepoPath(value) {
73 if (shouldSkipRebase(value)) return null;
74 try {
75 const resolved = new URL(value, "https://repo-root.invalid/");
76 return `${resolved.pathname.replace(/^\/+/, "")}${resolved.search}${resolved.hash}`;
77 } catch {
78 return null;
79 }
80 }
81
82 function isGithubRepoRoutePath(repoPath) {
83 const pathOnly = repoPath
84 .split(/[?#]/, 1)[0]
85 .replace(/^\/+|\/+$/g, "");
86 if (!pathOnly) return false;
87 const firstSegment = pathOnly.split("/")[0].toLowerCase();
88 return GITHUB_REPO_ROUTE_PREFIXES.has(firstSegment);
89 }
90
91 function isSafeUrlValue(value, attributeName, options = {}) {
92 const normalized = String(value || "").trim();
93 if (!normalized) return true;
94 if (
95 options.allowDataImages &&
96 attributeName === "src" &&
97 DATA_IMAGE_URL_PATTERN.test(normalized)
98 ) {
99 return true;
100 }
101 if (
102 normalized.startsWith("#") ||
103 normalized.startsWith("/") ||
104 normalized.startsWith("./") ||
105 normalized.startsWith("../") ||
106 normalized.startsWith("?")
107 ) {
108 return true;
109 }
110
111 try {
112 const url = new URL(normalized, "https://sanitizer.invalid/");
113 if (url.origin === "https://sanitizer.invalid") {
114 return true;
115 }
116
117 const protocol = url.protocol.toLowerCase();
118 if (protocol === "http:" || protocol === "https:") return true;
119 if (attributeName === "href" && (protocol === "mailto:" || protocol === "tel:")) {
120 return true;
121 }
122 } catch {
123 return false;
124 }
125
126 return false;
127 }
128
129 function stripUnsafeUrlAttributes(html, options = {}) {
130 const doc = new DOMParser().parseFromString(html, "text/html");
131
132 doc.querySelectorAll("[href], [src]").forEach((element) => {
133 for (const attributeName of ["href", "src"]) {
134 if (!element.hasAttribute(attributeName)) continue;
135 const value = element.getAttribute(attributeName) || "";
136 if (!isSafeUrlValue(value, attributeName, options)) {
137 element.removeAttribute(attributeName);
138 }
139 }
140 });
141
142 return doc.body.innerHTML;
143 }
144
145 export function sanitizeHtml(html, options = {}) {
146 if (!html || typeof html !== "string") return "";
147 const sanitized = DOMPurify.sanitize(html, getDompurifyConfig(options));
148 return stripUnsafeUrlAttributes(sanitized, options);
149 }
150
151 export function rebaseGithubReadmeHtml(html, githubUrl, branch) {
152 if (!html || typeof html !== "string" || !branch) return html;
153
154 const repoContext = parseGithubRepoContext(githubUrl);
155 if (!repoContext) return html;
156
157 const { owner, repo } = repoContext;
158 const repoWebBase = `https://github.com/${owner}/${repo}`;
159 const repoBlobBase = `${repoWebBase}/blob/${branch}`;
160 const repoRawBase = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
161 const doc = new DOMParser().parseFromString(html, "text/html");
162
163 // Single-segment links like "releases" are ambiguous, so README rebasing
164 // needs an explicit GitHub repo-route allowlist instead of a single base URL.
165 doc.querySelectorAll("a[href]").forEach((anchor) => {
166 const href = (anchor.getAttribute("href") || "").trim();
167 const repoPath = resolveRepoPath(href);
168 if (!repoPath) return;
169 const base = isGithubRepoRoutePath(repoPath) ? repoWebBase : repoBlobBase;
170 anchor.setAttribute("href", `${base}/${repoPath}`);
171 });
172
173 doc.querySelectorAll("img[src]").forEach((image) => {
174 const src = (image.getAttribute("src") || "").trim();
175 const repoPath = resolveRepoPath(src);
176 if (!repoPath) return;
177 image.setAttribute("src", `${repoRawBase}/${repoPath}`);
178 });
179
180 return doc.body.innerHTML;
181 }
182
183 export function renderSafeMarkdown(markdown, options = {}) {
184 if (!markdown) return "";
185
186 const { githubUrl = "", branch = "", openExternalLinksInNewTab = true } = options;
187
188 let html = marked.parse(markdown, { breaks: true });
189 if (githubUrl && branch) {
190 html = rebaseGithubReadmeHtml(html, githubUrl, branch);
191 }
192
193 html = sanitizeHtml(html, options);
194
195 if (openExternalLinksInNewTab) {
196 html = addBlankTargetsToLinks(html);
197 }
198
199 return html;
200 }