@keyou007/agent-bot 0.1.17-alpha.2 → 0.1.18-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/dist/feishu/CardRenderer.js +6 -16
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/local-files/LocalFileViewerServer.js +519 -59
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +62 -9
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/startup/StartupNotifier.js +7 -4
- package/dist/startup/StartupNotifier.js.map +1 -1
- package/docs/technical-reference.md +2 -0
- package/docs/technical-reference.zh.md +2 -0
- package/npm-shrinkwrap.json +108 -2
- package/package.json +3 -1
- package/src/feishu/CardRenderer.ts +6 -22
- package/src/local-files/LocalFileViewerServer.ts +536 -61
- package/src/proxy/ProxySessionController.ts +68 -7
- package/src/startup/StartupNotifier.ts +9 -4
|
@@ -2,12 +2,180 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import hljs from "highlight.js/lib/common";
|
|
6
|
+
import dos from "highlight.js/lib/languages/dos";
|
|
7
|
+
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
|
8
|
+
import powershell from "highlight.js/lib/languages/powershell";
|
|
9
|
+
import MarkdownIt from "markdown-it";
|
|
5
10
|
import { selectPreferredNetworkAddress, } from "./NetworkAddressSelector.js";
|
|
6
11
|
const SECRET_FILE = "secret";
|
|
7
12
|
const PORT_FILE = "port";
|
|
13
|
+
const SHORT_TOKEN_BYTES = 12;
|
|
14
|
+
const SHORT_TOKEN_LENGTH = 16;
|
|
8
15
|
const MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024;
|
|
9
16
|
const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
|
|
17
|
+
const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
|
|
10
18
|
const DIRECTORY_PAGE_SIZE = 100;
|
|
19
|
+
const MARKDOWN_EXTENSIONS = new Set([".markdown", ".md", ".mdown", ".mkd", ".mkdn"]);
|
|
20
|
+
hljs.registerLanguage("dos", dos);
|
|
21
|
+
hljs.registerLanguage("dockerfile", dockerfile);
|
|
22
|
+
hljs.registerLanguage("powershell", powershell);
|
|
23
|
+
const MARKDOWN_RENDERER = new MarkdownIt({
|
|
24
|
+
breaks: false,
|
|
25
|
+
html: false,
|
|
26
|
+
linkify: true,
|
|
27
|
+
typographer: false,
|
|
28
|
+
highlight(value, language) {
|
|
29
|
+
const highlighted = language && hljs.getLanguage(language)
|
|
30
|
+
? hljs.highlight(value, { language, ignoreIllegals: true }).value
|
|
31
|
+
: escapeHtml(value);
|
|
32
|
+
const languageAttribute = language && hljs.getLanguage(language)
|
|
33
|
+
? ` class="language-${escapeAttribute(language)}"`
|
|
34
|
+
: "";
|
|
35
|
+
return `<pre class="markdown-code-block hljs"><code${languageAttribute}>${highlighted}</code></pre>`;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const HIGHLIGHT_LANGUAGE_BY_EXTENSION = {
|
|
39
|
+
".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
|
|
40
|
+
".cs": "csharp", ".css": "css", ".cxx": "cpp", ".diff": "diff", ".go": "go", ".h": "cpp",
|
|
41
|
+
".hpp": "cpp", ".htm": "xml", ".html": "xml", ".ini": "ini", ".ipynb": "json", ".java": "java",
|
|
42
|
+
".js": "javascript", ".json": "json", ".jsonl": "json", ".jsx": "javascript", ".kt": "kotlin",
|
|
43
|
+
".kts": "kotlin", ".less": "less", ".lua": "lua", ".markdown": "markdown", ".md": "markdown",
|
|
44
|
+
".mjs": "javascript", ".patch": "diff", ".php": "php", ".pl": "perl", ".ps1": "powershell",
|
|
45
|
+
".py": "python", ".r": "r", ".rb": "ruby", ".rs": "rust", ".scss": "scss", ".sh": "bash",
|
|
46
|
+
".sql": "sql", ".swift": "swift", ".toml": "ini", ".ts": "typescript", ".tsx": "typescript",
|
|
47
|
+
".vue": "xml", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".zsh": "bash",
|
|
48
|
+
};
|
|
49
|
+
const HIGHLIGHT_LANGUAGE_BY_FILENAME = {
|
|
50
|
+
".env": "ini",
|
|
51
|
+
".gitignore": "ini",
|
|
52
|
+
"dockerfile": "dockerfile",
|
|
53
|
+
"makefile": "makefile",
|
|
54
|
+
};
|
|
55
|
+
const VIEWER_CLIENT_SCRIPT = `(() => {
|
|
56
|
+
const eventsUrl = document.body.dataset.eventsUrl;
|
|
57
|
+
const content = document.getElementById("viewer-content");
|
|
58
|
+
const metadata = document.getElementById("viewer-metadata");
|
|
59
|
+
const header = document.getElementById("viewer-header");
|
|
60
|
+
const title = document.getElementById("viewer-title");
|
|
61
|
+
const viewSwitch = document.getElementById("viewer-view-switch");
|
|
62
|
+
const viewButtons = Array.from(document.querySelectorAll("[data-view-mode-button]"));
|
|
63
|
+
if (!content || !metadata || !header || !title) return;
|
|
64
|
+
const filePath = title.dataset.filePath || title.textContent || "";
|
|
65
|
+
|
|
66
|
+
const syncViewMode = () => {
|
|
67
|
+
const mode = document.body.dataset.viewMode === "code" ? "code" : "rendered";
|
|
68
|
+
for (const button of viewButtons) {
|
|
69
|
+
const active = button.dataset.viewModeButton === mode;
|
|
70
|
+
button.classList.toggle("is-active", active);
|
|
71
|
+
button.setAttribute("aria-selected", String(active));
|
|
72
|
+
}
|
|
73
|
+
for (const panel of content.querySelectorAll("[data-view-panel]")) {
|
|
74
|
+
panel.setAttribute("aria-hidden", String(panel.dataset.viewPanel !== mode));
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const setViewMode = (mode) => {
|
|
78
|
+
document.body.dataset.viewMode = mode === "code" ? "code" : "rendered";
|
|
79
|
+
syncViewMode();
|
|
80
|
+
if (mode === "code") requestAnimationFrame(positionHashTarget);
|
|
81
|
+
};
|
|
82
|
+
for (const button of viewButtons) {
|
|
83
|
+
button.addEventListener("click", () => setViewMode(button.dataset.viewModeButton));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
title.addEventListener("pointerdown", (event) => {
|
|
87
|
+
if (event.button !== 0) return;
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
const selection = window.getSelection();
|
|
90
|
+
if (!selection) return;
|
|
91
|
+
const range = document.createRange();
|
|
92
|
+
range.selectNodeContents(title);
|
|
93
|
+
selection.removeAllRanges();
|
|
94
|
+
selection.addRange(range);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const updateHeaderOffset = () => {
|
|
98
|
+
const offset = Math.ceil(header.getBoundingClientRect().height) + 8;
|
|
99
|
+
document.documentElement.style.setProperty("--viewer-header-offset", offset + "px");
|
|
100
|
+
};
|
|
101
|
+
const highlightHashTarget = () => {
|
|
102
|
+
document.querySelector(".line.is-target-line")?.classList.remove("is-target-line");
|
|
103
|
+
let id;
|
|
104
|
+
try {
|
|
105
|
+
id = decodeURIComponent(window.location.hash.slice(1));
|
|
106
|
+
} catch {
|
|
107
|
+
title.textContent = filePath;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const lineNumber = /^L(\\d+)$/.exec(id)?.[1];
|
|
111
|
+
title.textContent = lineNumber ? filePath + ":" + lineNumber : filePath;
|
|
112
|
+
if (!lineNumber) return null;
|
|
113
|
+
const target = document.getElementById(id);
|
|
114
|
+
target?.classList.add("is-target-line");
|
|
115
|
+
return target;
|
|
116
|
+
};
|
|
117
|
+
const positionHashTarget = () => {
|
|
118
|
+
highlightHashTarget()?.scrollIntoView({ block: "start" });
|
|
119
|
+
};
|
|
120
|
+
if (/^#L\\d+$/u.test(window.location.hash)) document.body.dataset.viewMode = "code";
|
|
121
|
+
syncViewMode();
|
|
122
|
+
updateHeaderOffset();
|
|
123
|
+
if (typeof ResizeObserver === "function") {
|
|
124
|
+
new ResizeObserver(updateHeaderOffset).observe(header);
|
|
125
|
+
}
|
|
126
|
+
window.addEventListener("hashchange", () => requestAnimationFrame(positionHashTarget));
|
|
127
|
+
requestAnimationFrame(positionHashTarget);
|
|
128
|
+
if (!eventsUrl || typeof EventSource !== "function") return;
|
|
129
|
+
|
|
130
|
+
const restoreScroll = (top, left, atBottom, codeScrollLeft) => {
|
|
131
|
+
requestAnimationFrame(() => {
|
|
132
|
+
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
133
|
+
window.scrollTo(left, atBottom ? maxTop : Math.min(top, maxTop));
|
|
134
|
+
const code = content.querySelector(".code");
|
|
135
|
+
if (code) code.scrollLeft = codeScrollLeft;
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
const replaceContent = (nextContent, nextMetadata) => {
|
|
139
|
+
const top = window.scrollY;
|
|
140
|
+
const left = window.scrollX;
|
|
141
|
+
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
142
|
+
const atBottom = maxTop - top <= 24;
|
|
143
|
+
const codeScrollLeft = content.querySelector(".code")?.scrollLeft ?? 0;
|
|
144
|
+
content.innerHTML = nextContent;
|
|
145
|
+
metadata.innerHTML = nextMetadata;
|
|
146
|
+
syncViewMode();
|
|
147
|
+
highlightHashTarget();
|
|
148
|
+
restoreScroll(top, left, atBottom, codeScrollLeft);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const source = new EventSource(eventsUrl);
|
|
152
|
+
source.addEventListener("update", (event) => {
|
|
153
|
+
try {
|
|
154
|
+
const update = JSON.parse(event.data);
|
|
155
|
+
if (typeof update.content === "string" && typeof update.metadata === "string") {
|
|
156
|
+
if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown";
|
|
157
|
+
replaceContent(update.content, update.metadata);
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// A later valid event will repair the view.
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
source.addEventListener("unavailable", (event) => {
|
|
164
|
+
let message = "文件暂时无法读取,服务会继续等待它恢复。";
|
|
165
|
+
try {
|
|
166
|
+
const update = JSON.parse(event.data);
|
|
167
|
+
if (typeof update.message === "string") message = update.message;
|
|
168
|
+
} catch {
|
|
169
|
+
// Keep the fallback message.
|
|
170
|
+
}
|
|
171
|
+
const notice = document.createElement("div");
|
|
172
|
+
notice.className = "notice";
|
|
173
|
+
notice.textContent = message;
|
|
174
|
+
replaceContent(notice.outerHTML, metadata.innerHTML);
|
|
175
|
+
});
|
|
176
|
+
window.addEventListener("pagehide", () => source.close(), { once: true });
|
|
177
|
+
})();
|
|
178
|
+
`;
|
|
11
179
|
export class LocalFileViewerServer {
|
|
12
180
|
options;
|
|
13
181
|
server;
|
|
@@ -105,8 +273,7 @@ export class LocalFileViewerServer {
|
|
|
105
273
|
catch {
|
|
106
274
|
return undefined;
|
|
107
275
|
}
|
|
108
|
-
const
|
|
109
|
-
const url = this.createSignedUrl("view", token);
|
|
276
|
+
const url = this.createPreviewUrl(absolutePath);
|
|
110
277
|
const line = parseLineReference(reference);
|
|
111
278
|
if (line !== undefined)
|
|
112
279
|
url.hash = `L${line}`;
|
|
@@ -120,14 +287,27 @@ export class LocalFileViewerServer {
|
|
|
120
287
|
}
|
|
121
288
|
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
122
289
|
const route = routeWithinBasePath(requestUrl.pathname, this.basePath);
|
|
123
|
-
|
|
124
|
-
|
|
290
|
+
if (!route.startsWith("/")) {
|
|
291
|
+
this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (route === "/assets/viewer.js") {
|
|
295
|
+
this.sendJavascript(response, request.method === "HEAD");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
|
|
299
|
+
const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
|
|
300
|
+
const previewPath = previewMatch?.[1]
|
|
301
|
+
? this.verifyPreviewToken(previewMatch[1], requestUrl.searchParams.get("path") ?? "")
|
|
302
|
+
: undefined;
|
|
303
|
+
const legacyPath = legacyMatch?.[2]
|
|
304
|
+
? this.verifyLegacyToken(legacyMatch[2], requestUrl.searchParams.get("sig") ?? "")
|
|
305
|
+
: undefined;
|
|
306
|
+
const filePath = previewPath ?? legacyPath;
|
|
307
|
+
if (!previewMatch && !legacyMatch) {
|
|
125
308
|
this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
|
|
126
309
|
return;
|
|
127
310
|
}
|
|
128
|
-
const token = match[2];
|
|
129
|
-
const signature = requestUrl.searchParams.get("sig") ?? "";
|
|
130
|
-
const filePath = this.verifyToken(token, signature);
|
|
131
311
|
if (!filePath) {
|
|
132
312
|
this.sendHtml(response, 403, errorPage("文件链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
|
|
133
313
|
return;
|
|
@@ -142,7 +322,16 @@ export class LocalFileViewerServer {
|
|
|
142
322
|
this.sendHtml(response, 404, errorPage("文件不存在", "文件可能已被移动、重命名或删除。"), request.method === "HEAD");
|
|
143
323
|
return;
|
|
144
324
|
}
|
|
145
|
-
if (
|
|
325
|
+
if (requestUrl.searchParams.get("events") === "1") {
|
|
326
|
+
if (!stat.isFile()) {
|
|
327
|
+
this.sendHtml(response, 400, errorPage("无法监听目录", "实时更新目前只适用于文件。"), request.method === "HEAD");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.serveFileEvents(request, response, filePath);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
|
|
334
|
+
if (raw) {
|
|
146
335
|
if (stat.isDirectory()) {
|
|
147
336
|
this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
|
|
148
337
|
return;
|
|
@@ -151,11 +340,11 @@ export class LocalFileViewerServer {
|
|
|
151
340
|
return;
|
|
152
341
|
}
|
|
153
342
|
const html = stat.isDirectory()
|
|
154
|
-
? this.renderDirectoryViewer(filePath, stat,
|
|
155
|
-
: this.renderViewer(filePath, stat
|
|
343
|
+
? this.renderDirectoryViewer(filePath, stat, parseDirectoryPage(requestUrl.searchParams.get("page")))
|
|
344
|
+
: this.renderViewer(filePath, stat);
|
|
156
345
|
this.sendHtml(response, 200, html, request.method === "HEAD");
|
|
157
346
|
}
|
|
158
|
-
|
|
347
|
+
verifyLegacyToken(token, signature) {
|
|
159
348
|
if (!this.secret || !/^[a-f0-9]{64}$/u.test(signature))
|
|
160
349
|
return undefined;
|
|
161
350
|
const expected = this.sign(token);
|
|
@@ -174,10 +363,21 @@ export class LocalFileViewerServer {
|
|
|
174
363
|
return undefined;
|
|
175
364
|
return normalizeAbsolutePath(decoded);
|
|
176
365
|
}
|
|
366
|
+
verifyPreviewToken(token, rawPath) {
|
|
367
|
+
const filePath = normalizeAbsolutePath(rawPath);
|
|
368
|
+
if (!filePath || !this.secret)
|
|
369
|
+
return undefined;
|
|
370
|
+
const expected = this.createShortToken(filePath);
|
|
371
|
+
const actualBuffer = Buffer.from(token, "utf8");
|
|
372
|
+
const expectedBuffer = Buffer.from(expected, "utf8");
|
|
373
|
+
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
|
|
374
|
+
? filePath
|
|
375
|
+
: undefined;
|
|
376
|
+
}
|
|
177
377
|
sign(token) {
|
|
178
378
|
return createHmac("sha256", this.secret).update(token).digest("hex");
|
|
179
379
|
}
|
|
180
|
-
|
|
380
|
+
createLegacySignedUrl(route, token) {
|
|
181
381
|
const url = new URL(this.address.publicBaseUrl);
|
|
182
382
|
url.pathname = `${this.basePath}/${route}/${token}`.replace(/\/{2,}/gu, "/");
|
|
183
383
|
url.search = "";
|
|
@@ -185,19 +385,63 @@ export class LocalFileViewerServer {
|
|
|
185
385
|
url.searchParams.set("sig", this.sign(token));
|
|
186
386
|
return url;
|
|
187
387
|
}
|
|
188
|
-
|
|
388
|
+
createPreviewUrl(filePath) {
|
|
389
|
+
const url = new URL(this.address.publicBaseUrl);
|
|
390
|
+
url.pathname = `${this.basePath}/preview/${this.createShortToken(filePath)}`
|
|
391
|
+
.replace(/\/{2,}/gu, "/");
|
|
392
|
+
url.search = `?path=${encodeReadableQueryPath(filePath)}`;
|
|
393
|
+
url.hash = "";
|
|
394
|
+
return url;
|
|
395
|
+
}
|
|
396
|
+
createShortToken(filePath) {
|
|
397
|
+
return createHmac("sha256", this.secret)
|
|
398
|
+
.update(filePath)
|
|
399
|
+
.digest()
|
|
400
|
+
.subarray(0, SHORT_TOKEN_BYTES)
|
|
401
|
+
.toString("base64url");
|
|
402
|
+
}
|
|
403
|
+
renderViewer(filePath, stat) {
|
|
404
|
+
const fileName = path.basename(filePath);
|
|
405
|
+
const rawUrl = new URL(this.createFileUrl(filePath));
|
|
406
|
+
rawUrl.hash = "";
|
|
407
|
+
appendUrlQueryParameter(rawUrl, "raw", "1");
|
|
408
|
+
const downloadUrl = new URL(rawUrl);
|
|
409
|
+
appendUrlQueryParameter(downloadUrl, "download", "1");
|
|
410
|
+
const eventsUrl = new URL(this.createFileUrl(filePath));
|
|
411
|
+
eventsUrl.hash = "";
|
|
412
|
+
appendUrlQueryParameter(eventsUrl, "events", "1");
|
|
413
|
+
const snapshot = this.renderFileSnapshot(filePath, stat);
|
|
414
|
+
return renderViewerPage({
|
|
415
|
+
title: fileName,
|
|
416
|
+
filePath,
|
|
417
|
+
metadata: snapshot.metadata,
|
|
418
|
+
actions: [
|
|
419
|
+
{ href: rawUrl.toString(), label: "打开原始文件" },
|
|
420
|
+
{ href: downloadUrl.toString(), label: "下载" },
|
|
421
|
+
],
|
|
422
|
+
content: snapshot.content,
|
|
423
|
+
viewMode: snapshot.viewMode,
|
|
424
|
+
liveUpdates: {
|
|
425
|
+
eventsUrl: eventsUrl.toString(),
|
|
426
|
+
scriptPath: `${this.basePath}/assets/viewer.js`.replace(/\/{2,}/gu, "/"),
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
renderFileSnapshot(filePath, stat) {
|
|
189
431
|
const fileName = path.basename(filePath);
|
|
190
432
|
const extension = path.extname(filePath).toLowerCase();
|
|
191
433
|
const classification = classifyFileContent(filePath, stat.size);
|
|
192
434
|
const contentType = binaryContentTypeFor(extension);
|
|
193
|
-
const rawUrl = this.
|
|
194
|
-
rawUrl.
|
|
195
|
-
|
|
196
|
-
downloadUrl.searchParams.set("download", "1");
|
|
435
|
+
const rawUrl = new URL(this.createFileUrl(filePath));
|
|
436
|
+
rawUrl.hash = "";
|
|
437
|
+
appendUrlQueryParameter(rawUrl, "raw", "1");
|
|
197
438
|
let content;
|
|
198
439
|
if (classification.kind === "text") {
|
|
199
440
|
const preview = readTextPreview(filePath, stat.size, classification.encoding);
|
|
200
|
-
|
|
441
|
+
const previewContent = isMarkdownFile(filePath)
|
|
442
|
+
? renderMarkdownDocument(preview.text, filePath)
|
|
443
|
+
: renderText(preview.text, filePath);
|
|
444
|
+
content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${previewContent}`;
|
|
201
445
|
}
|
|
202
446
|
else if (contentType.startsWith("image/")) {
|
|
203
447
|
content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
|
|
@@ -214,18 +458,83 @@ export class LocalFileViewerServer {
|
|
|
214
458
|
else {
|
|
215
459
|
content = '<div class="unsupported">该文件是二进制格式,无法直接预览。可以打开原始文件或下载到本地。</div>';
|
|
216
460
|
}
|
|
217
|
-
return
|
|
218
|
-
title: fileName,
|
|
219
|
-
filePath,
|
|
220
|
-
metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
|
|
221
|
-
actions: [
|
|
222
|
-
{ href: rawUrl.toString(), label: "打开原始文件" },
|
|
223
|
-
{ href: downloadUrl.toString(), label: "下载" },
|
|
224
|
-
],
|
|
461
|
+
return {
|
|
225
462
|
content,
|
|
226
|
-
|
|
463
|
+
metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
|
|
464
|
+
...(classification.kind === "text" && isMarkdownFile(filePath)
|
|
465
|
+
? { viewMode: "markdown" }
|
|
466
|
+
: {}),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
serveFileEvents(request, response, filePath) {
|
|
470
|
+
setSecurityHeaders(response);
|
|
471
|
+
response.statusCode = 200;
|
|
472
|
+
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
473
|
+
response.setHeader("Connection", "keep-alive");
|
|
474
|
+
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
475
|
+
response.setHeader("X-Accel-Buffering", "no");
|
|
476
|
+
if (request.method === "HEAD") {
|
|
477
|
+
response.end();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
response.flushHeaders();
|
|
481
|
+
response.write("retry: 2000\n\n");
|
|
482
|
+
let closed = false;
|
|
483
|
+
let pendingUpdate;
|
|
484
|
+
const sendSnapshot = () => {
|
|
485
|
+
if (closed || response.destroyed)
|
|
486
|
+
return;
|
|
487
|
+
try {
|
|
488
|
+
const stat = fs.statSync(filePath);
|
|
489
|
+
if (!stat.isFile())
|
|
490
|
+
throw new Error("Path is no longer a file");
|
|
491
|
+
const snapshot = this.renderFileSnapshot(filePath, stat);
|
|
492
|
+
writeServerSentEvent(response, "update", JSON.stringify({
|
|
493
|
+
content: snapshot.content,
|
|
494
|
+
metadata: renderMetadata(snapshot.metadata),
|
|
495
|
+
viewMode: snapshot.viewMode ?? null,
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
writeServerSentEvent(response, "unavailable", JSON.stringify({
|
|
500
|
+
message: "文件可能已被移动、重命名或删除。",
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
const scheduleSnapshot = () => {
|
|
505
|
+
if (pendingUpdate)
|
|
506
|
+
clearTimeout(pendingUpdate);
|
|
507
|
+
pendingUpdate = setTimeout(sendSnapshot, 100);
|
|
508
|
+
pendingUpdate.unref();
|
|
509
|
+
};
|
|
510
|
+
const watcher = (current, previous) => {
|
|
511
|
+
if (current.mtimeMs === previous.mtimeMs
|
|
512
|
+
&& current.ctimeMs === previous.ctimeMs
|
|
513
|
+
&& current.size === previous.size
|
|
514
|
+
&& current.nlink === previous.nlink)
|
|
515
|
+
return;
|
|
516
|
+
scheduleSnapshot();
|
|
517
|
+
};
|
|
518
|
+
fs.watchFile(filePath, { interval: 1_000, persistent: false }, watcher);
|
|
519
|
+
sendSnapshot();
|
|
520
|
+
const keepAlive = setInterval(() => {
|
|
521
|
+
if (!closed && !response.destroyed)
|
|
522
|
+
response.write(": keep-alive\n\n");
|
|
523
|
+
}, 15_000);
|
|
524
|
+
keepAlive.unref();
|
|
525
|
+
const cleanup = () => {
|
|
526
|
+
if (closed)
|
|
527
|
+
return;
|
|
528
|
+
closed = true;
|
|
529
|
+
if (pendingUpdate)
|
|
530
|
+
clearTimeout(pendingUpdate);
|
|
531
|
+
clearInterval(keepAlive);
|
|
532
|
+
fs.unwatchFile(filePath, watcher);
|
|
533
|
+
};
|
|
534
|
+
request.once("aborted", cleanup);
|
|
535
|
+
response.once("close", cleanup);
|
|
227
536
|
}
|
|
228
|
-
renderDirectoryViewer(directory, stat,
|
|
537
|
+
renderDirectoryViewer(directory, stat, requestedPage) {
|
|
229
538
|
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
|
230
539
|
.flatMap((entry) => {
|
|
231
540
|
const entryPath = path.join(directory, entry.name);
|
|
@@ -254,8 +563,8 @@ export class LocalFileViewerServer {
|
|
|
254
563
|
: '<div class="empty-directory">这个目录是空的。</div>';
|
|
255
564
|
const pagination = totalPages > 1
|
|
256
565
|
? renderDirectoryPagination(page, totalPages, (targetPage) => {
|
|
257
|
-
const url = this.
|
|
258
|
-
url
|
|
566
|
+
const url = new URL(this.createFileUrl(directory));
|
|
567
|
+
appendUrlQueryParameter(url, "page", String(targetPage));
|
|
259
568
|
return url.toString();
|
|
260
569
|
})
|
|
261
570
|
: "";
|
|
@@ -312,12 +621,33 @@ export class LocalFileViewerServer {
|
|
|
312
621
|
response.setHeader("Content-Length", body.length);
|
|
313
622
|
response.end(headOnly ? undefined : body);
|
|
314
623
|
}
|
|
624
|
+
sendJavascript(response, headOnly) {
|
|
625
|
+
const body = Buffer.from(VIEWER_CLIENT_SCRIPT, "utf8");
|
|
626
|
+
setSecurityHeaders(response);
|
|
627
|
+
response.statusCode = 200;
|
|
628
|
+
response.setHeader("Cache-Control", "no-store");
|
|
629
|
+
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
630
|
+
response.setHeader("Content-Length", body.length);
|
|
631
|
+
response.end(headOnly ? undefined : body);
|
|
632
|
+
}
|
|
315
633
|
}
|
|
316
634
|
function renderViewerPage(input) {
|
|
317
|
-
const metadata = input.metadata
|
|
635
|
+
const metadata = renderMetadata(input.metadata);
|
|
318
636
|
const actions = input.actions.length > 0
|
|
319
637
|
? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
|
|
320
638
|
: "";
|
|
639
|
+
const viewSwitch = input.viewMode === "markdown"
|
|
640
|
+
? '<div class="view-switch" id="viewer-view-switch" role="tablist" aria-label="预览模式"><button class="is-active" type="button" role="tab" aria-selected="true" data-view-mode-button="rendered">预览</button><button type="button" role="tab" aria-selected="false" data-view-mode-button="code">代码</button></div>'
|
|
641
|
+
: "";
|
|
642
|
+
const toolbar = viewSwitch || actions
|
|
643
|
+
? `<div class="toolbar">${viewSwitch}${actions}</div>`
|
|
644
|
+
: "";
|
|
645
|
+
const liveAttributes = input.liveUpdates
|
|
646
|
+
? ` data-events-url="${escapeAttribute(input.liveUpdates.eventsUrl)}"`
|
|
647
|
+
: "";
|
|
648
|
+
const liveScript = input.liveUpdates
|
|
649
|
+
? `<script src="${escapeAttribute(input.liveUpdates.scriptPath)}" defer></script>`
|
|
650
|
+
: "";
|
|
321
651
|
return `<!doctype html>
|
|
322
652
|
<html lang="zh-CN">
|
|
323
653
|
<head>
|
|
@@ -325,26 +655,63 @@ function renderViewerPage(input) {
|
|
|
325
655
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
326
656
|
<title>${escapeHtml(input.title)} · Agent Bot</title>
|
|
327
657
|
<style>
|
|
328
|
-
:root { color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; }
|
|
658
|
+
:root { --viewer-header-offset: 56px; --viewer-code-font: "Cascadia Mono", "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", "Sarasa Mono SC", "Noto Sans Mono CJK SC", "Microsoft YaHei Mono", NSimSun, monospace; color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; }
|
|
329
659
|
* { box-sizing: border-box; }
|
|
330
660
|
body { margin: 0; background: #f5f7fa; color: #1f2329; }
|
|
331
|
-
header { position: sticky; top: 0; z-index: 2; padding:
|
|
332
|
-
h1 { margin: 0;
|
|
333
|
-
.
|
|
334
|
-
.
|
|
335
|
-
.
|
|
336
|
-
.actions
|
|
337
|
-
|
|
338
|
-
.
|
|
339
|
-
.
|
|
340
|
-
.
|
|
341
|
-
.
|
|
661
|
+
header { position: sticky; top: 0; z-index: 2; padding: 7px 12px; background: rgba(255,255,255,.96); border-bottom: 1px solid #dfe3e8; }
|
|
662
|
+
h1 { margin: 0; cursor: text; font: 600 13px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
|
663
|
+
.meta { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; min-height: 26px; margin-top: 3px; color: #646a73; font-size: 11px; }
|
|
664
|
+
.metadata-values { display: contents; }
|
|
665
|
+
.toolbar { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
|
666
|
+
.actions { display: flex; gap: 8px; }
|
|
667
|
+
.actions a { color: #1456f0; text-decoration: none; padding: 3px 7px; border: 1px solid #c9d0db; border-radius: 4px; background: #fff; }
|
|
668
|
+
.view-switch { display: inline-flex; padding: 2px; border: 1px solid #c9d0db; border-radius: 5px; background: #f2f3f5; }
|
|
669
|
+
.view-switch[hidden] { display: none; }
|
|
670
|
+
.view-switch button { min-width: 46px; padding: 2px 8px; border: 0; border-radius: 3px; background: transparent; color: #646a73; cursor: pointer; font: inherit; }
|
|
671
|
+
.view-switch button.is-active { background: #fff; color: #1456f0; box-shadow: 0 1px 3px rgba(31,35,41,.16); }
|
|
672
|
+
main { padding: 0; }
|
|
673
|
+
.notice, .unsupported { margin: 8px; padding: 10px 12px; border: 1px solid #f3cf8f; background: #fff7e6; border-radius: 5px; }
|
|
674
|
+
.code { margin: 0; padding: 6px 0; overflow: auto; border: 0; border-radius: 0; background: #fff; color: #1f2329; font-size: 13px; line-height: 1.5; tab-size: 2; }
|
|
675
|
+
.code, .code code, .code .line, .code .line * { font-family: var(--viewer-code-font) !important; font-variant-ligatures: none; font-feature-settings: "liga" 0, "calt" 0; }
|
|
676
|
+
.line { display: block; min-height: 1.55em; padding-right: 16px; scroll-margin-top: var(--viewer-header-offset); }
|
|
677
|
+
.line:target, .line.is-target-line { background: #fff1b8; box-shadow: inset 4px 0 #e6a700; }
|
|
342
678
|
.line-number { display: inline-block; width: 4.5em; margin-right: 12px; padding-right: 12px; text-align: right; color: #8f959e; border-right: 1px solid #eceff3; text-decoration: none; user-select: none; }
|
|
679
|
+
.hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
|
|
680
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #a626a4; }
|
|
681
|
+
.hljs-string, .hljs-regexp, .hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #087f5b; }
|
|
682
|
+
.hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #986801; }
|
|
683
|
+
.hljs-title.function_, .hljs-title.class_ { color: #005cc5; }
|
|
684
|
+
.hljs-deletion { color: #b31d28; background: #ffeef0; }
|
|
685
|
+
.hljs-addition { background: #e6ffed; }
|
|
686
|
+
.hljs-emphasis { font-style: italic; }
|
|
687
|
+
.hljs-strong { font-weight: 700; }
|
|
688
|
+
body[data-view-mode="rendered"] [data-view-panel="code"], body[data-view-mode="code"] [data-view-panel="rendered"] { display: none; }
|
|
689
|
+
.markdown-body { max-width: 980px; margin: 0 auto; padding: 20px 28px 48px; background: #fff; font-size: 15px; line-height: 1.72; overflow-wrap: anywhere; }
|
|
690
|
+
.markdown-body > :first-child { margin-top: 0; }
|
|
691
|
+
.markdown-body > :last-child { margin-bottom: 0; }
|
|
692
|
+
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { margin: 1.45em 0 .6em; line-height: 1.28; }
|
|
693
|
+
.markdown-body h1 { padding-bottom: .35em; border-bottom: 1px solid #dfe3e8; font-size: 2em; }
|
|
694
|
+
.markdown-body h2 { padding-bottom: .3em; border-bottom: 1px solid #eceff3; font-size: 1.5em; }
|
|
695
|
+
.markdown-body h3 { font-size: 1.25em; }
|
|
696
|
+
.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body table, .markdown-body pre { margin: 0 0 1em; }
|
|
697
|
+
.markdown-body ul, .markdown-body ol { padding-left: 1.8em; }
|
|
698
|
+
.markdown-body li + li { margin-top: .28em; }
|
|
699
|
+
.markdown-body blockquote { margin-left: 0; padding: .15em 1em; border-left: 4px solid #c9d0db; color: #646a73; }
|
|
700
|
+
.markdown-body a { color: #1456f0; text-decoration: none; }
|
|
701
|
+
.markdown-body a:hover { text-decoration: underline; }
|
|
702
|
+
.markdown-body code { padding: .14em .35em; border-radius: 4px; background: #f2f3f5; font-family: var(--viewer-code-font); font-size: .9em; }
|
|
703
|
+
.markdown-body .markdown-code-block { margin: 0 0 1em; padding: 14px 16px; overflow: auto; border-radius: 6px; background: #f6f8fa; line-height: 1.55; }
|
|
704
|
+
.markdown-body .markdown-code-block code { padding: 0; background: transparent; font-size: 13px; }
|
|
705
|
+
.markdown-body table { display: block; max-width: 100%; overflow: auto; border-collapse: collapse; }
|
|
706
|
+
.markdown-body th, .markdown-body td { padding: 7px 12px; border: 1px solid #dfe3e8; text-align: left; }
|
|
707
|
+
.markdown-body th { background: #f5f7fa; }
|
|
708
|
+
.markdown-body img { max-width: 100%; height: auto; }
|
|
709
|
+
.markdown-body hr { height: 1px; margin: 1.5em 0; border: 0; background: #dfe3e8; }
|
|
343
710
|
.media { display: grid; place-items: center; min-height: 220px; }
|
|
344
|
-
.media img, .media video { max-width: 100%; max-height: calc(100vh -
|
|
711
|
+
.media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
|
|
345
712
|
.media audio { width: min(720px, 100%); }
|
|
346
|
-
.document { width: 100%; height: calc(100vh -
|
|
347
|
-
.directory-list { overflow: hidden; border:
|
|
713
|
+
.document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
|
|
714
|
+
.directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
|
|
348
715
|
.directory-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 18px; align-items: center; min-height: 44px; padding: 8px 14px; color: inherit; text-decoration: none; border-bottom: 1px solid #eceff3; }
|
|
349
716
|
.directory-entry:last-child { border-bottom: 0; }
|
|
350
717
|
.directory-entry:hover { background: #f0f4ff; }
|
|
@@ -360,28 +727,50 @@ function renderViewerPage(input) {
|
|
|
360
727
|
@media (prefers-color-scheme: dark) {
|
|
361
728
|
body { background: #17181a; color: #e5e6eb; }
|
|
362
729
|
header { background: rgba(32,33,36,.96); border-color: #3a3b3d; }
|
|
363
|
-
.
|
|
730
|
+
.meta, .pagination { color: #a6a9ad; }
|
|
364
731
|
.actions a, .pagination a { color: #8ab4ff; }
|
|
365
732
|
.actions a { border-color: #55585c; background: #292a2d; }
|
|
733
|
+
.view-switch { border-color: #55585c; background: #292a2d; }
|
|
734
|
+
.view-switch button { color: #a6a9ad; }
|
|
735
|
+
.view-switch button.is-active { background: #3a3b3d; color: #8ab4ff; box-shadow: none; }
|
|
366
736
|
.code, .directory-list { background: #202124; color: #e8eaed; border-color: #3a3b3d; }
|
|
737
|
+
.markdown-body { background: #202124; color: #e8eaed; }
|
|
738
|
+
.markdown-body h1, .markdown-body h2 { border-color: #3a3b3d; }
|
|
739
|
+
.markdown-body blockquote { color: #a6a9ad; border-color: #55585c; }
|
|
740
|
+
.markdown-body a { color: #8ab4ff; }
|
|
741
|
+
.markdown-body code { background: #303134; }
|
|
742
|
+
.markdown-body .markdown-code-block { background: #282a2d; }
|
|
743
|
+
.markdown-body th, .markdown-body td { border-color: #55585c; }
|
|
744
|
+
.markdown-body th { background: #292a2d; }
|
|
745
|
+
.markdown-body hr { background: #3a3b3d; }
|
|
367
746
|
.directory-entry { border-color: #3a3b3d; }
|
|
368
747
|
.directory-entry:hover { background: #292f3d; }
|
|
369
748
|
.line-number { color: #8b9098; border-color: #3a3b3d; }
|
|
370
|
-
.line:target { background: #594c20; }
|
|
749
|
+
.line:target, .line.is-target-line { background: #594c20; box-shadow: inset 4px 0 #d29922; }
|
|
750
|
+
.hljs-comment, .hljs-quote { color: #8b949e; }
|
|
751
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #ff7b72; }
|
|
752
|
+
.hljs-string, .hljs-regexp, .hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #a5d6ff; }
|
|
753
|
+
.hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #d2a8ff; }
|
|
754
|
+
.hljs-title.function_, .hljs-title.class_ { color: #d2a8ff; }
|
|
755
|
+
.hljs-deletion { color: #ffdcd7; background: #67060c; }
|
|
756
|
+
.hljs-addition { color: #aff5b4; background: #033a16; }
|
|
371
757
|
.notice, .unsupported { color: #f5d58a; background: #3b321c; border-color: #705b28; }
|
|
372
758
|
}
|
|
373
759
|
</style>
|
|
374
760
|
</head>
|
|
375
|
-
<body>
|
|
376
|
-
<header>
|
|
377
|
-
<h1>${escapeHtml(input.
|
|
378
|
-
<div class="
|
|
379
|
-
<div class="meta">${metadata}${actions}</div>
|
|
761
|
+
<body${input.viewMode === "markdown" ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
|
|
762
|
+
<header id="viewer-header">
|
|
763
|
+
<h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
|
|
764
|
+
<div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${toolbar}</div>
|
|
380
765
|
</header>
|
|
381
|
-
<main>${input.content}</main>
|
|
766
|
+
<main id="viewer-content">${input.content}</main>
|
|
767
|
+
${liveScript}
|
|
382
768
|
</body>
|
|
383
769
|
</html>`;
|
|
384
770
|
}
|
|
771
|
+
function renderMetadata(metadata) {
|
|
772
|
+
return metadata.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
|
|
773
|
+
}
|
|
385
774
|
function directoryViewerEntryKind(filePath, stat) {
|
|
386
775
|
if (stat.isDirectory())
|
|
387
776
|
return "directory";
|
|
@@ -460,6 +849,15 @@ function normalizeAbsolutePath(filePath) {
|
|
|
460
849
|
return undefined;
|
|
461
850
|
return path.resolve(normalized);
|
|
462
851
|
}
|
|
852
|
+
function encodeReadableQueryPath(filePath) {
|
|
853
|
+
return encodeURIComponent(filePath.replaceAll("\\", "/"))
|
|
854
|
+
.replaceAll("%3A", ":")
|
|
855
|
+
.replaceAll("%2F", "/");
|
|
856
|
+
}
|
|
857
|
+
function appendUrlQueryParameter(url, name, value) {
|
|
858
|
+
const parameter = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
859
|
+
url.search = url.search ? `${url.search}&${parameter}` : `?${parameter}`;
|
|
860
|
+
}
|
|
463
861
|
function normalizePublicBaseUrl(value) {
|
|
464
862
|
const url = new URL(value);
|
|
465
863
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -586,13 +984,67 @@ function hasTextLikeCharacters(value) {
|
|
|
586
984
|
}
|
|
587
985
|
return controls <= 2 || controls / Math.max(1, characters) <= 0.02;
|
|
588
986
|
}
|
|
589
|
-
function renderText(value) {
|
|
590
|
-
const
|
|
591
|
-
|
|
987
|
+
function renderText(value, filePath) {
|
|
988
|
+
const normalized = value.replace(/\r\n?/gu, "\n");
|
|
989
|
+
const language = syntaxLanguageFor(filePath);
|
|
990
|
+
const lines = language && Buffer.byteLength(normalized, "utf8") <= MAX_SYNTAX_HIGHLIGHT_BYTES
|
|
991
|
+
? highlightLines(normalized, language)
|
|
992
|
+
: normalized.split("\n").map(escapeHtml);
|
|
993
|
+
const languageClass = language ? ` class="language-${escapeAttribute(language)}"` : "";
|
|
994
|
+
return `<pre class="code hljs"><code${languageClass}>${lines.map((line, index) => {
|
|
592
995
|
const lineNumber = index + 1;
|
|
593
|
-
return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${
|
|
996
|
+
return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${line}</span>`;
|
|
594
997
|
}).join("")}</code></pre>`;
|
|
595
998
|
}
|
|
999
|
+
function renderMarkdownDocument(value, filePath) {
|
|
1000
|
+
return `<article class="markdown-body" data-view-panel="rendered">${MARKDOWN_RENDERER.render(value)}</article><section data-view-panel="code">${renderText(value, filePath)}</section>`;
|
|
1001
|
+
}
|
|
1002
|
+
function isMarkdownFile(filePath) {
|
|
1003
|
+
return MARKDOWN_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
1004
|
+
}
|
|
1005
|
+
function syntaxLanguageFor(filePath) {
|
|
1006
|
+
const fileName = path.basename(filePath).toLowerCase();
|
|
1007
|
+
return HIGHLIGHT_LANGUAGE_BY_FILENAME[fileName]
|
|
1008
|
+
?? HIGHLIGHT_LANGUAGE_BY_EXTENSION[path.extname(fileName)];
|
|
1009
|
+
}
|
|
1010
|
+
function highlightLines(value, language) {
|
|
1011
|
+
try {
|
|
1012
|
+
return splitHighlightedLines(hljs.highlight(value, { language, ignoreIllegals: true }).value);
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
return value.split("\n").map(escapeHtml);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
function splitHighlightedLines(value) {
|
|
1019
|
+
const lines = [];
|
|
1020
|
+
const openTags = [];
|
|
1021
|
+
const tokenPattern = /<span class="[^"]+">|<\/span>|\n/gu;
|
|
1022
|
+
let current = "";
|
|
1023
|
+
let offset = 0;
|
|
1024
|
+
for (const match of value.matchAll(tokenPattern)) {
|
|
1025
|
+
const token = match[0];
|
|
1026
|
+
const index = match.index ?? offset;
|
|
1027
|
+
current += value.slice(offset, index);
|
|
1028
|
+
offset = index + token.length;
|
|
1029
|
+
if (token === "\n") {
|
|
1030
|
+
current += "</span>".repeat(openTags.length);
|
|
1031
|
+
lines.push(current);
|
|
1032
|
+
current = openTags.join("");
|
|
1033
|
+
}
|
|
1034
|
+
else if (token === "</span>") {
|
|
1035
|
+
current += token;
|
|
1036
|
+
openTags.pop();
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
current += token;
|
|
1040
|
+
openTags.push(token);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
current += value.slice(offset);
|
|
1044
|
+
current += "</span>".repeat(openTags.length);
|
|
1045
|
+
lines.push(current);
|
|
1046
|
+
return lines;
|
|
1047
|
+
}
|
|
596
1048
|
function contentTypeFor(extension) {
|
|
597
1049
|
const known = {
|
|
598
1050
|
".aac": "audio/aac", ".avi": "video/x-msvideo", ".bmp": "image/bmp", ".css": "text/css; charset=utf-8",
|
|
@@ -663,8 +1115,16 @@ function formatFileSize(size) {
|
|
|
663
1115
|
return `${(size / 1024 / 1024).toFixed(1)} MiB`;
|
|
664
1116
|
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
|
665
1117
|
}
|
|
1118
|
+
function writeServerSentEvent(response, event, data) {
|
|
1119
|
+
if (response.destroyed || response.writableEnded)
|
|
1120
|
+
return;
|
|
1121
|
+
response.write(`event: ${event}\n`);
|
|
1122
|
+
for (const line of data.split(/\r?\n/u))
|
|
1123
|
+
response.write(`data: ${line}\n`);
|
|
1124
|
+
response.write("\n");
|
|
1125
|
+
}
|
|
666
1126
|
function setSecurityHeaders(response) {
|
|
667
|
-
response.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
|
|
1127
|
+
response.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'self'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
|
|
668
1128
|
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
669
1129
|
response.setHeader("Referrer-Policy", "no-referrer");
|
|
670
1130
|
response.setHeader("X-Content-Type-Options", "nosniff");
|