@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,6 +2,11 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import http, { type IncomingMessage, type ServerResponse } 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 {
|
|
6
11
|
selectPreferredNetworkAddress,
|
|
7
12
|
type NetworkConnectionKind,
|
|
@@ -9,9 +14,174 @@ import {
|
|
|
9
14
|
|
|
10
15
|
const SECRET_FILE = "secret";
|
|
11
16
|
const PORT_FILE = "port";
|
|
17
|
+
const SHORT_TOKEN_BYTES = 12;
|
|
18
|
+
const SHORT_TOKEN_LENGTH = 16;
|
|
12
19
|
const MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024;
|
|
13
20
|
const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
|
|
21
|
+
const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
|
|
14
22
|
const DIRECTORY_PAGE_SIZE = 100;
|
|
23
|
+
const MARKDOWN_EXTENSIONS = new Set([".markdown", ".md", ".mdown", ".mkd", ".mkdn"]);
|
|
24
|
+
hljs.registerLanguage("dos", dos);
|
|
25
|
+
hljs.registerLanguage("dockerfile", dockerfile);
|
|
26
|
+
hljs.registerLanguage("powershell", powershell);
|
|
27
|
+
|
|
28
|
+
const MARKDOWN_RENDERER = new MarkdownIt({
|
|
29
|
+
breaks: false,
|
|
30
|
+
html: false,
|
|
31
|
+
linkify: true,
|
|
32
|
+
typographer: false,
|
|
33
|
+
highlight(value, language) {
|
|
34
|
+
const highlighted = language && hljs.getLanguage(language)
|
|
35
|
+
? hljs.highlight(value, { language, ignoreIllegals: true }).value
|
|
36
|
+
: escapeHtml(value);
|
|
37
|
+
const languageAttribute = language && hljs.getLanguage(language)
|
|
38
|
+
? ` class="language-${escapeAttribute(language)}"`
|
|
39
|
+
: "";
|
|
40
|
+
return `<pre class="markdown-code-block hljs"><code${languageAttribute}>${highlighted}</code></pre>`;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const HIGHLIGHT_LANGUAGE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
45
|
+
".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
|
|
46
|
+
".cs": "csharp", ".css": "css", ".cxx": "cpp", ".diff": "diff", ".go": "go", ".h": "cpp",
|
|
47
|
+
".hpp": "cpp", ".htm": "xml", ".html": "xml", ".ini": "ini", ".ipynb": "json", ".java": "java",
|
|
48
|
+
".js": "javascript", ".json": "json", ".jsonl": "json", ".jsx": "javascript", ".kt": "kotlin",
|
|
49
|
+
".kts": "kotlin", ".less": "less", ".lua": "lua", ".markdown": "markdown", ".md": "markdown",
|
|
50
|
+
".mjs": "javascript", ".patch": "diff", ".php": "php", ".pl": "perl", ".ps1": "powershell",
|
|
51
|
+
".py": "python", ".r": "r", ".rb": "ruby", ".rs": "rust", ".scss": "scss", ".sh": "bash",
|
|
52
|
+
".sql": "sql", ".swift": "swift", ".toml": "ini", ".ts": "typescript", ".tsx": "typescript",
|
|
53
|
+
".vue": "xml", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".zsh": "bash",
|
|
54
|
+
};
|
|
55
|
+
const HIGHLIGHT_LANGUAGE_BY_FILENAME: Readonly<Record<string, string>> = {
|
|
56
|
+
".env": "ini",
|
|
57
|
+
".gitignore": "ini",
|
|
58
|
+
"dockerfile": "dockerfile",
|
|
59
|
+
"makefile": "makefile",
|
|
60
|
+
};
|
|
61
|
+
const VIEWER_CLIENT_SCRIPT = `(() => {
|
|
62
|
+
const eventsUrl = document.body.dataset.eventsUrl;
|
|
63
|
+
const content = document.getElementById("viewer-content");
|
|
64
|
+
const metadata = document.getElementById("viewer-metadata");
|
|
65
|
+
const header = document.getElementById("viewer-header");
|
|
66
|
+
const title = document.getElementById("viewer-title");
|
|
67
|
+
const viewSwitch = document.getElementById("viewer-view-switch");
|
|
68
|
+
const viewButtons = Array.from(document.querySelectorAll("[data-view-mode-button]"));
|
|
69
|
+
if (!content || !metadata || !header || !title) return;
|
|
70
|
+
const filePath = title.dataset.filePath || title.textContent || "";
|
|
71
|
+
|
|
72
|
+
const syncViewMode = () => {
|
|
73
|
+
const mode = document.body.dataset.viewMode === "code" ? "code" : "rendered";
|
|
74
|
+
for (const button of viewButtons) {
|
|
75
|
+
const active = button.dataset.viewModeButton === mode;
|
|
76
|
+
button.classList.toggle("is-active", active);
|
|
77
|
+
button.setAttribute("aria-selected", String(active));
|
|
78
|
+
}
|
|
79
|
+
for (const panel of content.querySelectorAll("[data-view-panel]")) {
|
|
80
|
+
panel.setAttribute("aria-hidden", String(panel.dataset.viewPanel !== mode));
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const setViewMode = (mode) => {
|
|
84
|
+
document.body.dataset.viewMode = mode === "code" ? "code" : "rendered";
|
|
85
|
+
syncViewMode();
|
|
86
|
+
if (mode === "code") requestAnimationFrame(positionHashTarget);
|
|
87
|
+
};
|
|
88
|
+
for (const button of viewButtons) {
|
|
89
|
+
button.addEventListener("click", () => setViewMode(button.dataset.viewModeButton));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
title.addEventListener("pointerdown", (event) => {
|
|
93
|
+
if (event.button !== 0) return;
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
const selection = window.getSelection();
|
|
96
|
+
if (!selection) return;
|
|
97
|
+
const range = document.createRange();
|
|
98
|
+
range.selectNodeContents(title);
|
|
99
|
+
selection.removeAllRanges();
|
|
100
|
+
selection.addRange(range);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const updateHeaderOffset = () => {
|
|
104
|
+
const offset = Math.ceil(header.getBoundingClientRect().height) + 8;
|
|
105
|
+
document.documentElement.style.setProperty("--viewer-header-offset", offset + "px");
|
|
106
|
+
};
|
|
107
|
+
const highlightHashTarget = () => {
|
|
108
|
+
document.querySelector(".line.is-target-line")?.classList.remove("is-target-line");
|
|
109
|
+
let id;
|
|
110
|
+
try {
|
|
111
|
+
id = decodeURIComponent(window.location.hash.slice(1));
|
|
112
|
+
} catch {
|
|
113
|
+
title.textContent = filePath;
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const lineNumber = /^L(\\d+)$/.exec(id)?.[1];
|
|
117
|
+
title.textContent = lineNumber ? filePath + ":" + lineNumber : filePath;
|
|
118
|
+
if (!lineNumber) return null;
|
|
119
|
+
const target = document.getElementById(id);
|
|
120
|
+
target?.classList.add("is-target-line");
|
|
121
|
+
return target;
|
|
122
|
+
};
|
|
123
|
+
const positionHashTarget = () => {
|
|
124
|
+
highlightHashTarget()?.scrollIntoView({ block: "start" });
|
|
125
|
+
};
|
|
126
|
+
if (/^#L\\d+$/u.test(window.location.hash)) document.body.dataset.viewMode = "code";
|
|
127
|
+
syncViewMode();
|
|
128
|
+
updateHeaderOffset();
|
|
129
|
+
if (typeof ResizeObserver === "function") {
|
|
130
|
+
new ResizeObserver(updateHeaderOffset).observe(header);
|
|
131
|
+
}
|
|
132
|
+
window.addEventListener("hashchange", () => requestAnimationFrame(positionHashTarget));
|
|
133
|
+
requestAnimationFrame(positionHashTarget);
|
|
134
|
+
if (!eventsUrl || typeof EventSource !== "function") return;
|
|
135
|
+
|
|
136
|
+
const restoreScroll = (top, left, atBottom, codeScrollLeft) => {
|
|
137
|
+
requestAnimationFrame(() => {
|
|
138
|
+
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
139
|
+
window.scrollTo(left, atBottom ? maxTop : Math.min(top, maxTop));
|
|
140
|
+
const code = content.querySelector(".code");
|
|
141
|
+
if (code) code.scrollLeft = codeScrollLeft;
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
const replaceContent = (nextContent, nextMetadata) => {
|
|
145
|
+
const top = window.scrollY;
|
|
146
|
+
const left = window.scrollX;
|
|
147
|
+
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
148
|
+
const atBottom = maxTop - top <= 24;
|
|
149
|
+
const codeScrollLeft = content.querySelector(".code")?.scrollLeft ?? 0;
|
|
150
|
+
content.innerHTML = nextContent;
|
|
151
|
+
metadata.innerHTML = nextMetadata;
|
|
152
|
+
syncViewMode();
|
|
153
|
+
highlightHashTarget();
|
|
154
|
+
restoreScroll(top, left, atBottom, codeScrollLeft);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const source = new EventSource(eventsUrl);
|
|
158
|
+
source.addEventListener("update", (event) => {
|
|
159
|
+
try {
|
|
160
|
+
const update = JSON.parse(event.data);
|
|
161
|
+
if (typeof update.content === "string" && typeof update.metadata === "string") {
|
|
162
|
+
if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown";
|
|
163
|
+
replaceContent(update.content, update.metadata);
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// A later valid event will repair the view.
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
source.addEventListener("unavailable", (event) => {
|
|
170
|
+
let message = "文件暂时无法读取,服务会继续等待它恢复。";
|
|
171
|
+
try {
|
|
172
|
+
const update = JSON.parse(event.data);
|
|
173
|
+
if (typeof update.message === "string") message = update.message;
|
|
174
|
+
} catch {
|
|
175
|
+
// Keep the fallback message.
|
|
176
|
+
}
|
|
177
|
+
const notice = document.createElement("div");
|
|
178
|
+
notice.className = "notice";
|
|
179
|
+
notice.textContent = message;
|
|
180
|
+
replaceContent(notice.outerHTML, metadata.innerHTML);
|
|
181
|
+
});
|
|
182
|
+
window.addEventListener("pagehide", () => source.close(), { once: true });
|
|
183
|
+
})();
|
|
184
|
+
`;
|
|
15
185
|
|
|
16
186
|
type TextEncoding = "utf-8" | "utf-16le" | "utf-16be" | "gb18030";
|
|
17
187
|
|
|
@@ -28,6 +198,12 @@ interface DirectoryViewerEntry {
|
|
|
28
198
|
kind: DirectoryViewerEntryKind;
|
|
29
199
|
}
|
|
30
200
|
|
|
201
|
+
interface RenderedFileSnapshot {
|
|
202
|
+
content: string;
|
|
203
|
+
metadata: string[];
|
|
204
|
+
viewMode?: "markdown";
|
|
205
|
+
}
|
|
206
|
+
|
|
31
207
|
export interface LocalFileViewerServerOptions {
|
|
32
208
|
host: string;
|
|
33
209
|
port: number;
|
|
@@ -138,8 +314,7 @@ export class LocalFileViewerServer {
|
|
|
138
314
|
return undefined;
|
|
139
315
|
}
|
|
140
316
|
|
|
141
|
-
const
|
|
142
|
-
const url = this.createSignedUrl("view", token);
|
|
317
|
+
const url = this.createPreviewUrl(absolutePath);
|
|
143
318
|
const line = parseLineReference(reference);
|
|
144
319
|
if (line !== undefined) url.hash = `L${line}`;
|
|
145
320
|
return url.toString();
|
|
@@ -154,15 +329,27 @@ export class LocalFileViewerServer {
|
|
|
154
329
|
|
|
155
330
|
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
156
331
|
const route = routeWithinBasePath(requestUrl.pathname, this.basePath);
|
|
157
|
-
|
|
158
|
-
|
|
332
|
+
if (!route.startsWith("/")) {
|
|
333
|
+
this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (route === "/assets/viewer.js") {
|
|
337
|
+
this.sendJavascript(response, request.method === "HEAD");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
|
|
341
|
+
const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
|
|
342
|
+
const previewPath = previewMatch?.[1]
|
|
343
|
+
? this.verifyPreviewToken(previewMatch[1], requestUrl.searchParams.get("path") ?? "")
|
|
344
|
+
: undefined;
|
|
345
|
+
const legacyPath = legacyMatch?.[2]
|
|
346
|
+
? this.verifyLegacyToken(legacyMatch[2], requestUrl.searchParams.get("sig") ?? "")
|
|
347
|
+
: undefined;
|
|
348
|
+
const filePath = previewPath ?? legacyPath;
|
|
349
|
+
if (!previewMatch && !legacyMatch) {
|
|
159
350
|
this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
|
|
160
351
|
return;
|
|
161
352
|
}
|
|
162
|
-
|
|
163
|
-
const token = match[2];
|
|
164
|
-
const signature = requestUrl.searchParams.get("sig") ?? "";
|
|
165
|
-
const filePath = this.verifyToken(token, signature);
|
|
166
353
|
if (!filePath) {
|
|
167
354
|
this.sendHtml(response, 403, errorPage("文件链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
|
|
168
355
|
return;
|
|
@@ -177,7 +364,17 @@ export class LocalFileViewerServer {
|
|
|
177
364
|
return;
|
|
178
365
|
}
|
|
179
366
|
|
|
180
|
-
if (
|
|
367
|
+
if (requestUrl.searchParams.get("events") === "1") {
|
|
368
|
+
if (!stat.isFile()) {
|
|
369
|
+
this.sendHtml(response, 400, errorPage("无法监听目录", "实时更新目前只适用于文件。"), request.method === "HEAD");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
this.serveFileEvents(request, response, filePath);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
|
|
377
|
+
if (raw) {
|
|
181
378
|
if (stat.isDirectory()) {
|
|
182
379
|
this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
|
|
183
380
|
return;
|
|
@@ -187,12 +384,12 @@ export class LocalFileViewerServer {
|
|
|
187
384
|
}
|
|
188
385
|
|
|
189
386
|
const html = stat.isDirectory()
|
|
190
|
-
? this.renderDirectoryViewer(filePath, stat,
|
|
191
|
-
: this.renderViewer(filePath, stat
|
|
387
|
+
? this.renderDirectoryViewer(filePath, stat, parseDirectoryPage(requestUrl.searchParams.get("page")))
|
|
388
|
+
: this.renderViewer(filePath, stat);
|
|
192
389
|
this.sendHtml(response, 200, html, request.method === "HEAD");
|
|
193
390
|
}
|
|
194
391
|
|
|
195
|
-
private
|
|
392
|
+
private verifyLegacyToken(token: string, signature: string): string | undefined {
|
|
196
393
|
if (!this.secret || !/^[a-f0-9]{64}$/u.test(signature)) return undefined;
|
|
197
394
|
const expected = this.sign(token);
|
|
198
395
|
const actualBuffer = Buffer.from(signature, "hex");
|
|
@@ -209,11 +406,22 @@ export class LocalFileViewerServer {
|
|
|
209
406
|
return normalizeAbsolutePath(decoded);
|
|
210
407
|
}
|
|
211
408
|
|
|
409
|
+
private verifyPreviewToken(token: string, rawPath: string): string | undefined {
|
|
410
|
+
const filePath = normalizeAbsolutePath(rawPath);
|
|
411
|
+
if (!filePath || !this.secret) return undefined;
|
|
412
|
+
const expected = this.createShortToken(filePath);
|
|
413
|
+
const actualBuffer = Buffer.from(token, "utf8");
|
|
414
|
+
const expectedBuffer = Buffer.from(expected, "utf8");
|
|
415
|
+
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
|
|
416
|
+
? filePath
|
|
417
|
+
: undefined;
|
|
418
|
+
}
|
|
419
|
+
|
|
212
420
|
private sign(token: string): string {
|
|
213
421
|
return createHmac("sha256", this.secret!).update(token).digest("hex");
|
|
214
422
|
}
|
|
215
423
|
|
|
216
|
-
private
|
|
424
|
+
private createLegacySignedUrl(route: "view" | "raw", token: string): URL {
|
|
217
425
|
const url = new URL(this.address!.publicBaseUrl);
|
|
218
426
|
url.pathname = `${this.basePath}/${route}/${token}`.replace(/\/{2,}/gu, "/");
|
|
219
427
|
url.search = "";
|
|
@@ -222,20 +430,68 @@ export class LocalFileViewerServer {
|
|
|
222
430
|
return url;
|
|
223
431
|
}
|
|
224
432
|
|
|
225
|
-
private
|
|
433
|
+
private createPreviewUrl(filePath: string): URL {
|
|
434
|
+
const url = new URL(this.address!.publicBaseUrl);
|
|
435
|
+
url.pathname = `${this.basePath}/preview/${this.createShortToken(filePath)}`
|
|
436
|
+
.replace(/\/{2,}/gu, "/");
|
|
437
|
+
url.search = `?path=${encodeReadableQueryPath(filePath)}`;
|
|
438
|
+
url.hash = "";
|
|
439
|
+
return url;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private createShortToken(filePath: string): string {
|
|
443
|
+
return createHmac("sha256", this.secret!)
|
|
444
|
+
.update(filePath)
|
|
445
|
+
.digest()
|
|
446
|
+
.subarray(0, SHORT_TOKEN_BYTES)
|
|
447
|
+
.toString("base64url");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private renderViewer(filePath: string, stat: fs.Stats): string {
|
|
451
|
+
const fileName = path.basename(filePath);
|
|
452
|
+
const rawUrl = new URL(this.createFileUrl(filePath)!);
|
|
453
|
+
rawUrl.hash = "";
|
|
454
|
+
appendUrlQueryParameter(rawUrl, "raw", "1");
|
|
455
|
+
const downloadUrl = new URL(rawUrl);
|
|
456
|
+
appendUrlQueryParameter(downloadUrl, "download", "1");
|
|
457
|
+
const eventsUrl = new URL(this.createFileUrl(filePath)!);
|
|
458
|
+
eventsUrl.hash = "";
|
|
459
|
+
appendUrlQueryParameter(eventsUrl, "events", "1");
|
|
460
|
+
const snapshot = this.renderFileSnapshot(filePath, stat);
|
|
461
|
+
|
|
462
|
+
return renderViewerPage({
|
|
463
|
+
title: fileName,
|
|
464
|
+
filePath,
|
|
465
|
+
metadata: snapshot.metadata,
|
|
466
|
+
actions: [
|
|
467
|
+
{ href: rawUrl.toString(), label: "打开原始文件" },
|
|
468
|
+
{ href: downloadUrl.toString(), label: "下载" },
|
|
469
|
+
],
|
|
470
|
+
content: snapshot.content,
|
|
471
|
+
viewMode: snapshot.viewMode,
|
|
472
|
+
liveUpdates: {
|
|
473
|
+
eventsUrl: eventsUrl.toString(),
|
|
474
|
+
scriptPath: `${this.basePath}/assets/viewer.js`.replace(/\/{2,}/gu, "/"),
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private renderFileSnapshot(filePath: string, stat: fs.Stats): RenderedFileSnapshot {
|
|
226
480
|
const fileName = path.basename(filePath);
|
|
227
481
|
const extension = path.extname(filePath).toLowerCase();
|
|
228
482
|
const classification = classifyFileContent(filePath, stat.size);
|
|
229
483
|
const contentType = binaryContentTypeFor(extension);
|
|
230
|
-
const rawUrl = this.
|
|
231
|
-
rawUrl.
|
|
232
|
-
|
|
233
|
-
downloadUrl.searchParams.set("download", "1");
|
|
484
|
+
const rawUrl = new URL(this.createFileUrl(filePath)!);
|
|
485
|
+
rawUrl.hash = "";
|
|
486
|
+
appendUrlQueryParameter(rawUrl, "raw", "1");
|
|
234
487
|
|
|
235
488
|
let content: string;
|
|
236
489
|
if (classification.kind === "text") {
|
|
237
490
|
const preview = readTextPreview(filePath, stat.size, classification.encoding);
|
|
238
|
-
|
|
491
|
+
const previewContent = isMarkdownFile(filePath)
|
|
492
|
+
? renderMarkdownDocument(preview.text, filePath)
|
|
493
|
+
: renderText(preview.text, filePath);
|
|
494
|
+
content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${previewContent}`;
|
|
239
495
|
} else if (contentType.startsWith("image/")) {
|
|
240
496
|
content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
|
|
241
497
|
} else if (contentType === "application/pdf") {
|
|
@@ -247,20 +503,81 @@ export class LocalFileViewerServer {
|
|
|
247
503
|
} else {
|
|
248
504
|
content = '<div class="unsupported">该文件是二进制格式,无法直接预览。可以打开原始文件或下载到本地。</div>';
|
|
249
505
|
}
|
|
250
|
-
|
|
251
|
-
return renderViewerPage({
|
|
252
|
-
title: fileName,
|
|
253
|
-
filePath,
|
|
254
|
-
metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
|
|
255
|
-
actions: [
|
|
256
|
-
{ href: rawUrl.toString(), label: "打开原始文件" },
|
|
257
|
-
{ href: downloadUrl.toString(), label: "下载" },
|
|
258
|
-
],
|
|
506
|
+
return {
|
|
259
507
|
content,
|
|
260
|
-
|
|
508
|
+
metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
|
|
509
|
+
...(classification.kind === "text" && isMarkdownFile(filePath)
|
|
510
|
+
? { viewMode: "markdown" as const }
|
|
511
|
+
: {}),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private serveFileEvents(request: IncomingMessage, response: ServerResponse, filePath: string): void {
|
|
516
|
+
setSecurityHeaders(response);
|
|
517
|
+
response.statusCode = 200;
|
|
518
|
+
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
519
|
+
response.setHeader("Connection", "keep-alive");
|
|
520
|
+
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
521
|
+
response.setHeader("X-Accel-Buffering", "no");
|
|
522
|
+
if (request.method === "HEAD") {
|
|
523
|
+
response.end();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
response.flushHeaders();
|
|
527
|
+
response.write("retry: 2000\n\n");
|
|
528
|
+
|
|
529
|
+
let closed = false;
|
|
530
|
+
let pendingUpdate: NodeJS.Timeout | undefined;
|
|
531
|
+
const sendSnapshot = () => {
|
|
532
|
+
if (closed || response.destroyed) return;
|
|
533
|
+
try {
|
|
534
|
+
const stat = fs.statSync(filePath);
|
|
535
|
+
if (!stat.isFile()) throw new Error("Path is no longer a file");
|
|
536
|
+
const snapshot = this.renderFileSnapshot(filePath, stat);
|
|
537
|
+
writeServerSentEvent(response, "update", JSON.stringify({
|
|
538
|
+
content: snapshot.content,
|
|
539
|
+
metadata: renderMetadata(snapshot.metadata),
|
|
540
|
+
viewMode: snapshot.viewMode ?? null,
|
|
541
|
+
}));
|
|
542
|
+
} catch {
|
|
543
|
+
writeServerSentEvent(response, "unavailable", JSON.stringify({
|
|
544
|
+
message: "文件可能已被移动、重命名或删除。",
|
|
545
|
+
}));
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
const scheduleSnapshot = () => {
|
|
549
|
+
if (pendingUpdate) clearTimeout(pendingUpdate);
|
|
550
|
+
pendingUpdate = setTimeout(sendSnapshot, 100);
|
|
551
|
+
pendingUpdate.unref();
|
|
552
|
+
};
|
|
553
|
+
const watcher = (current: fs.Stats, previous: fs.Stats) => {
|
|
554
|
+
if (
|
|
555
|
+
current.mtimeMs === previous.mtimeMs
|
|
556
|
+
&& current.ctimeMs === previous.ctimeMs
|
|
557
|
+
&& current.size === previous.size
|
|
558
|
+
&& current.nlink === previous.nlink
|
|
559
|
+
) return;
|
|
560
|
+
scheduleSnapshot();
|
|
561
|
+
};
|
|
562
|
+
fs.watchFile(filePath, { interval: 1_000, persistent: false }, watcher);
|
|
563
|
+
sendSnapshot();
|
|
564
|
+
const keepAlive = setInterval(() => {
|
|
565
|
+
if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
|
|
566
|
+
}, 15_000);
|
|
567
|
+
keepAlive.unref();
|
|
568
|
+
|
|
569
|
+
const cleanup = () => {
|
|
570
|
+
if (closed) return;
|
|
571
|
+
closed = true;
|
|
572
|
+
if (pendingUpdate) clearTimeout(pendingUpdate);
|
|
573
|
+
clearInterval(keepAlive);
|
|
574
|
+
fs.unwatchFile(filePath, watcher);
|
|
575
|
+
};
|
|
576
|
+
request.once("aborted", cleanup);
|
|
577
|
+
response.once("close", cleanup);
|
|
261
578
|
}
|
|
262
579
|
|
|
263
|
-
private renderDirectoryViewer(directory: string, stat: fs.Stats,
|
|
580
|
+
private renderDirectoryViewer(directory: string, stat: fs.Stats, requestedPage: number): string {
|
|
264
581
|
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
|
265
582
|
.flatMap((entry): DirectoryViewerEntry[] => {
|
|
266
583
|
const entryPath = path.join(directory, entry.name);
|
|
@@ -287,8 +604,8 @@ export class LocalFileViewerServer {
|
|
|
287
604
|
: '<div class="empty-directory">这个目录是空的。</div>';
|
|
288
605
|
const pagination = totalPages > 1
|
|
289
606
|
? renderDirectoryPagination(page, totalPages, (targetPage) => {
|
|
290
|
-
const url = this.
|
|
291
|
-
url
|
|
607
|
+
const url = new URL(this.createFileUrl(directory)!);
|
|
608
|
+
appendUrlQueryParameter(url, "page", String(targetPage));
|
|
292
609
|
return url.toString();
|
|
293
610
|
})
|
|
294
611
|
: "";
|
|
@@ -355,6 +672,16 @@ export class LocalFileViewerServer {
|
|
|
355
672
|
response.setHeader("Content-Length", body.length);
|
|
356
673
|
response.end(headOnly ? undefined : body);
|
|
357
674
|
}
|
|
675
|
+
|
|
676
|
+
private sendJavascript(response: ServerResponse, headOnly: boolean): void {
|
|
677
|
+
const body = Buffer.from(VIEWER_CLIENT_SCRIPT, "utf8");
|
|
678
|
+
setSecurityHeaders(response);
|
|
679
|
+
response.statusCode = 200;
|
|
680
|
+
response.setHeader("Cache-Control", "no-store");
|
|
681
|
+
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
682
|
+
response.setHeader("Content-Length", body.length);
|
|
683
|
+
response.end(headOnly ? undefined : body);
|
|
684
|
+
}
|
|
358
685
|
}
|
|
359
686
|
|
|
360
687
|
function renderViewerPage(input: {
|
|
@@ -363,11 +690,25 @@ function renderViewerPage(input: {
|
|
|
363
690
|
metadata: string[];
|
|
364
691
|
actions: Array<{ href: string; label: string }>;
|
|
365
692
|
content: string;
|
|
693
|
+
viewMode?: "markdown";
|
|
694
|
+
liveUpdates?: { eventsUrl: string; scriptPath: string };
|
|
366
695
|
}): string {
|
|
367
|
-
const metadata = input.metadata
|
|
696
|
+
const metadata = renderMetadata(input.metadata);
|
|
368
697
|
const actions = input.actions.length > 0
|
|
369
698
|
? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
|
|
370
699
|
: "";
|
|
700
|
+
const viewSwitch = input.viewMode === "markdown"
|
|
701
|
+
? '<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>'
|
|
702
|
+
: "";
|
|
703
|
+
const toolbar = viewSwitch || actions
|
|
704
|
+
? `<div class="toolbar">${viewSwitch}${actions}</div>`
|
|
705
|
+
: "";
|
|
706
|
+
const liveAttributes = input.liveUpdates
|
|
707
|
+
? ` data-events-url="${escapeAttribute(input.liveUpdates.eventsUrl)}"`
|
|
708
|
+
: "";
|
|
709
|
+
const liveScript = input.liveUpdates
|
|
710
|
+
? `<script src="${escapeAttribute(input.liveUpdates.scriptPath)}" defer></script>`
|
|
711
|
+
: "";
|
|
371
712
|
return `<!doctype html>
|
|
372
713
|
<html lang="zh-CN">
|
|
373
714
|
<head>
|
|
@@ -375,26 +716,63 @@ function renderViewerPage(input: {
|
|
|
375
716
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
376
717
|
<title>${escapeHtml(input.title)} · Agent Bot</title>
|
|
377
718
|
<style>
|
|
378
|
-
:root { color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; }
|
|
719
|
+
: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; }
|
|
379
720
|
* { box-sizing: border-box; }
|
|
380
721
|
body { margin: 0; background: #f5f7fa; color: #1f2329; }
|
|
381
|
-
header { position: sticky; top: 0; z-index: 2; padding:
|
|
382
|
-
h1 { margin: 0;
|
|
383
|
-
.
|
|
384
|
-
.
|
|
385
|
-
.
|
|
386
|
-
.actions
|
|
387
|
-
|
|
388
|
-
.
|
|
389
|
-
.
|
|
390
|
-
.
|
|
391
|
-
.
|
|
722
|
+
header { position: sticky; top: 0; z-index: 2; padding: 7px 12px; background: rgba(255,255,255,.96); border-bottom: 1px solid #dfe3e8; }
|
|
723
|
+
h1 { margin: 0; cursor: text; font: 600 13px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
|
724
|
+
.meta { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; min-height: 26px; margin-top: 3px; color: #646a73; font-size: 11px; }
|
|
725
|
+
.metadata-values { display: contents; }
|
|
726
|
+
.toolbar { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
|
727
|
+
.actions { display: flex; gap: 8px; }
|
|
728
|
+
.actions a { color: #1456f0; text-decoration: none; padding: 3px 7px; border: 1px solid #c9d0db; border-radius: 4px; background: #fff; }
|
|
729
|
+
.view-switch { display: inline-flex; padding: 2px; border: 1px solid #c9d0db; border-radius: 5px; background: #f2f3f5; }
|
|
730
|
+
.view-switch[hidden] { display: none; }
|
|
731
|
+
.view-switch button { min-width: 46px; padding: 2px 8px; border: 0; border-radius: 3px; background: transparent; color: #646a73; cursor: pointer; font: inherit; }
|
|
732
|
+
.view-switch button.is-active { background: #fff; color: #1456f0; box-shadow: 0 1px 3px rgba(31,35,41,.16); }
|
|
733
|
+
main { padding: 0; }
|
|
734
|
+
.notice, .unsupported { margin: 8px; padding: 10px 12px; border: 1px solid #f3cf8f; background: #fff7e6; border-radius: 5px; }
|
|
735
|
+
.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; }
|
|
736
|
+
.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; }
|
|
737
|
+
.line { display: block; min-height: 1.55em; padding-right: 16px; scroll-margin-top: var(--viewer-header-offset); }
|
|
738
|
+
.line:target, .line.is-target-line { background: #fff1b8; box-shadow: inset 4px 0 #e6a700; }
|
|
392
739
|
.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; }
|
|
740
|
+
.hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
|
|
741
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #a626a4; }
|
|
742
|
+
.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; }
|
|
743
|
+
.hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #986801; }
|
|
744
|
+
.hljs-title.function_, .hljs-title.class_ { color: #005cc5; }
|
|
745
|
+
.hljs-deletion { color: #b31d28; background: #ffeef0; }
|
|
746
|
+
.hljs-addition { background: #e6ffed; }
|
|
747
|
+
.hljs-emphasis { font-style: italic; }
|
|
748
|
+
.hljs-strong { font-weight: 700; }
|
|
749
|
+
body[data-view-mode="rendered"] [data-view-panel="code"], body[data-view-mode="code"] [data-view-panel="rendered"] { display: none; }
|
|
750
|
+
.markdown-body { max-width: 980px; margin: 0 auto; padding: 20px 28px 48px; background: #fff; font-size: 15px; line-height: 1.72; overflow-wrap: anywhere; }
|
|
751
|
+
.markdown-body > :first-child { margin-top: 0; }
|
|
752
|
+
.markdown-body > :last-child { margin-bottom: 0; }
|
|
753
|
+
.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; }
|
|
754
|
+
.markdown-body h1 { padding-bottom: .35em; border-bottom: 1px solid #dfe3e8; font-size: 2em; }
|
|
755
|
+
.markdown-body h2 { padding-bottom: .3em; border-bottom: 1px solid #eceff3; font-size: 1.5em; }
|
|
756
|
+
.markdown-body h3 { font-size: 1.25em; }
|
|
757
|
+
.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body table, .markdown-body pre { margin: 0 0 1em; }
|
|
758
|
+
.markdown-body ul, .markdown-body ol { padding-left: 1.8em; }
|
|
759
|
+
.markdown-body li + li { margin-top: .28em; }
|
|
760
|
+
.markdown-body blockquote { margin-left: 0; padding: .15em 1em; border-left: 4px solid #c9d0db; color: #646a73; }
|
|
761
|
+
.markdown-body a { color: #1456f0; text-decoration: none; }
|
|
762
|
+
.markdown-body a:hover { text-decoration: underline; }
|
|
763
|
+
.markdown-body code { padding: .14em .35em; border-radius: 4px; background: #f2f3f5; font-family: var(--viewer-code-font); font-size: .9em; }
|
|
764
|
+
.markdown-body .markdown-code-block { margin: 0 0 1em; padding: 14px 16px; overflow: auto; border-radius: 6px; background: #f6f8fa; line-height: 1.55; }
|
|
765
|
+
.markdown-body .markdown-code-block code { padding: 0; background: transparent; font-size: 13px; }
|
|
766
|
+
.markdown-body table { display: block; max-width: 100%; overflow: auto; border-collapse: collapse; }
|
|
767
|
+
.markdown-body th, .markdown-body td { padding: 7px 12px; border: 1px solid #dfe3e8; text-align: left; }
|
|
768
|
+
.markdown-body th { background: #f5f7fa; }
|
|
769
|
+
.markdown-body img { max-width: 100%; height: auto; }
|
|
770
|
+
.markdown-body hr { height: 1px; margin: 1.5em 0; border: 0; background: #dfe3e8; }
|
|
393
771
|
.media { display: grid; place-items: center; min-height: 220px; }
|
|
394
|
-
.media img, .media video { max-width: 100%; max-height: calc(100vh -
|
|
772
|
+
.media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
|
|
395
773
|
.media audio { width: min(720px, 100%); }
|
|
396
|
-
.document { width: 100%; height: calc(100vh -
|
|
397
|
-
.directory-list { overflow: hidden; border:
|
|
774
|
+
.document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
|
|
775
|
+
.directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
|
|
398
776
|
.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; }
|
|
399
777
|
.directory-entry:last-child { border-bottom: 0; }
|
|
400
778
|
.directory-entry:hover { background: #f0f4ff; }
|
|
@@ -410,29 +788,52 @@ function renderViewerPage(input: {
|
|
|
410
788
|
@media (prefers-color-scheme: dark) {
|
|
411
789
|
body { background: #17181a; color: #e5e6eb; }
|
|
412
790
|
header { background: rgba(32,33,36,.96); border-color: #3a3b3d; }
|
|
413
|
-
.
|
|
791
|
+
.meta, .pagination { color: #a6a9ad; }
|
|
414
792
|
.actions a, .pagination a { color: #8ab4ff; }
|
|
415
793
|
.actions a { border-color: #55585c; background: #292a2d; }
|
|
794
|
+
.view-switch { border-color: #55585c; background: #292a2d; }
|
|
795
|
+
.view-switch button { color: #a6a9ad; }
|
|
796
|
+
.view-switch button.is-active { background: #3a3b3d; color: #8ab4ff; box-shadow: none; }
|
|
416
797
|
.code, .directory-list { background: #202124; color: #e8eaed; border-color: #3a3b3d; }
|
|
798
|
+
.markdown-body { background: #202124; color: #e8eaed; }
|
|
799
|
+
.markdown-body h1, .markdown-body h2 { border-color: #3a3b3d; }
|
|
800
|
+
.markdown-body blockquote { color: #a6a9ad; border-color: #55585c; }
|
|
801
|
+
.markdown-body a { color: #8ab4ff; }
|
|
802
|
+
.markdown-body code { background: #303134; }
|
|
803
|
+
.markdown-body .markdown-code-block { background: #282a2d; }
|
|
804
|
+
.markdown-body th, .markdown-body td { border-color: #55585c; }
|
|
805
|
+
.markdown-body th { background: #292a2d; }
|
|
806
|
+
.markdown-body hr { background: #3a3b3d; }
|
|
417
807
|
.directory-entry { border-color: #3a3b3d; }
|
|
418
808
|
.directory-entry:hover { background: #292f3d; }
|
|
419
809
|
.line-number { color: #8b9098; border-color: #3a3b3d; }
|
|
420
|
-
.line:target { background: #594c20; }
|
|
810
|
+
.line:target, .line.is-target-line { background: #594c20; box-shadow: inset 4px 0 #d29922; }
|
|
811
|
+
.hljs-comment, .hljs-quote { color: #8b949e; }
|
|
812
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #ff7b72; }
|
|
813
|
+
.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; }
|
|
814
|
+
.hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #d2a8ff; }
|
|
815
|
+
.hljs-title.function_, .hljs-title.class_ { color: #d2a8ff; }
|
|
816
|
+
.hljs-deletion { color: #ffdcd7; background: #67060c; }
|
|
817
|
+
.hljs-addition { color: #aff5b4; background: #033a16; }
|
|
421
818
|
.notice, .unsupported { color: #f5d58a; background: #3b321c; border-color: #705b28; }
|
|
422
819
|
}
|
|
423
820
|
</style>
|
|
424
821
|
</head>
|
|
425
|
-
<body>
|
|
426
|
-
<header>
|
|
427
|
-
<h1>${escapeHtml(input.
|
|
428
|
-
<div class="
|
|
429
|
-
<div class="meta">${metadata}${actions}</div>
|
|
822
|
+
<body${input.viewMode === "markdown" ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
|
|
823
|
+
<header id="viewer-header">
|
|
824
|
+
<h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
|
|
825
|
+
<div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${toolbar}</div>
|
|
430
826
|
</header>
|
|
431
|
-
<main>${input.content}</main>
|
|
827
|
+
<main id="viewer-content">${input.content}</main>
|
|
828
|
+
${liveScript}
|
|
432
829
|
</body>
|
|
433
830
|
</html>`;
|
|
434
831
|
}
|
|
435
832
|
|
|
833
|
+
function renderMetadata(metadata: string[]): string {
|
|
834
|
+
return metadata.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
|
|
835
|
+
}
|
|
836
|
+
|
|
436
837
|
function directoryViewerEntryKind(filePath: string, stat: fs.Stats): DirectoryViewerEntryKind {
|
|
437
838
|
if (stat.isDirectory()) return "directory";
|
|
438
839
|
if (classifyFileContent(filePath, stat.size).kind === "text") return "text";
|
|
@@ -509,6 +910,17 @@ function normalizeAbsolutePath(filePath: string): string | undefined {
|
|
|
509
910
|
return path.resolve(normalized);
|
|
510
911
|
}
|
|
511
912
|
|
|
913
|
+
function encodeReadableQueryPath(filePath: string): string {
|
|
914
|
+
return encodeURIComponent(filePath.replaceAll("\\", "/"))
|
|
915
|
+
.replaceAll("%3A", ":")
|
|
916
|
+
.replaceAll("%2F", "/");
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function appendUrlQueryParameter(url: URL, name: string, value: string): void {
|
|
920
|
+
const parameter = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
921
|
+
url.search = url.search ? `${url.search}&${parameter}` : `?${parameter}`;
|
|
922
|
+
}
|
|
923
|
+
|
|
512
924
|
function normalizePublicBaseUrl(value: string): string {
|
|
513
925
|
const url = new URL(value);
|
|
514
926
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -650,14 +1062,70 @@ function hasTextLikeCharacters(value: string): boolean {
|
|
|
650
1062
|
return controls <= 2 || controls / Math.max(1, characters) <= 0.02;
|
|
651
1063
|
}
|
|
652
1064
|
|
|
653
|
-
function renderText(value: string): string {
|
|
654
|
-
const
|
|
655
|
-
|
|
1065
|
+
function renderText(value: string, filePath: string): string {
|
|
1066
|
+
const normalized = value.replace(/\r\n?/gu, "\n");
|
|
1067
|
+
const language = syntaxLanguageFor(filePath);
|
|
1068
|
+
const lines = language && Buffer.byteLength(normalized, "utf8") <= MAX_SYNTAX_HIGHLIGHT_BYTES
|
|
1069
|
+
? highlightLines(normalized, language)
|
|
1070
|
+
: normalized.split("\n").map(escapeHtml);
|
|
1071
|
+
const languageClass = language ? ` class="language-${escapeAttribute(language)}"` : "";
|
|
1072
|
+
return `<pre class="code hljs"><code${languageClass}>${lines.map((line, index) => {
|
|
656
1073
|
const lineNumber = index + 1;
|
|
657
|
-
return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${
|
|
1074
|
+
return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${line}</span>`;
|
|
658
1075
|
}).join("")}</code></pre>`;
|
|
659
1076
|
}
|
|
660
1077
|
|
|
1078
|
+
function renderMarkdownDocument(value: string, filePath: string): string {
|
|
1079
|
+
return `<article class="markdown-body" data-view-panel="rendered">${MARKDOWN_RENDERER.render(value)}</article><section data-view-panel="code">${renderText(value, filePath)}</section>`;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function isMarkdownFile(filePath: string): boolean {
|
|
1083
|
+
return MARKDOWN_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function syntaxLanguageFor(filePath: string): string | undefined {
|
|
1087
|
+
const fileName = path.basename(filePath).toLowerCase();
|
|
1088
|
+
return HIGHLIGHT_LANGUAGE_BY_FILENAME[fileName]
|
|
1089
|
+
?? HIGHLIGHT_LANGUAGE_BY_EXTENSION[path.extname(fileName)];
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function highlightLines(value: string, language: string): string[] {
|
|
1093
|
+
try {
|
|
1094
|
+
return splitHighlightedLines(hljs.highlight(value, { language, ignoreIllegals: true }).value);
|
|
1095
|
+
} catch {
|
|
1096
|
+
return value.split("\n").map(escapeHtml);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function splitHighlightedLines(value: string): string[] {
|
|
1101
|
+
const lines: string[] = [];
|
|
1102
|
+
const openTags: string[] = [];
|
|
1103
|
+
const tokenPattern = /<span class="[^"]+">|<\/span>|\n/gu;
|
|
1104
|
+
let current = "";
|
|
1105
|
+
let offset = 0;
|
|
1106
|
+
for (const match of value.matchAll(tokenPattern)) {
|
|
1107
|
+
const token = match[0];
|
|
1108
|
+
const index = match.index ?? offset;
|
|
1109
|
+
current += value.slice(offset, index);
|
|
1110
|
+
offset = index + token.length;
|
|
1111
|
+
if (token === "\n") {
|
|
1112
|
+
current += "</span>".repeat(openTags.length);
|
|
1113
|
+
lines.push(current);
|
|
1114
|
+
current = openTags.join("");
|
|
1115
|
+
} else if (token === "</span>") {
|
|
1116
|
+
current += token;
|
|
1117
|
+
openTags.pop();
|
|
1118
|
+
} else {
|
|
1119
|
+
current += token;
|
|
1120
|
+
openTags.push(token);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
current += value.slice(offset);
|
|
1124
|
+
current += "</span>".repeat(openTags.length);
|
|
1125
|
+
lines.push(current);
|
|
1126
|
+
return lines;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
661
1129
|
function contentTypeFor(extension: string): string {
|
|
662
1130
|
const known: Record<string, string> = {
|
|
663
1131
|
".aac": "audio/aac", ".avi": "video/x-msvideo", ".bmp": "image/bmp", ".css": "text/css; charset=utf-8",
|
|
@@ -726,8 +1194,15 @@ function formatFileSize(size: number): string {
|
|
|
726
1194
|
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
|
727
1195
|
}
|
|
728
1196
|
|
|
1197
|
+
function writeServerSentEvent(response: ServerResponse, event: string, data: string): void {
|
|
1198
|
+
if (response.destroyed || response.writableEnded) return;
|
|
1199
|
+
response.write(`event: ${event}\n`);
|
|
1200
|
+
for (const line of data.split(/\r?\n/u)) response.write(`data: ${line}\n`);
|
|
1201
|
+
response.write("\n");
|
|
1202
|
+
}
|
|
1203
|
+
|
|
729
1204
|
function setSecurityHeaders(response: ServerResponse): void {
|
|
730
|
-
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'");
|
|
1205
|
+
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'");
|
|
731
1206
|
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
732
1207
|
response.setHeader("Referrer-Policy", "no-referrer");
|
|
733
1208
|
response.setHeader("X-Content-Type-Options", "nosniff");
|