ework-web 0.1.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/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/ework-web.js +2 -0
- package/package.json +61 -0
- package/src/attachments.ts +54 -0
- package/src/auth.ts +218 -0
- package/src/build.ts +17 -0
- package/src/config.ts +178 -0
- package/src/db.ts +106 -0
- package/src/fileview.ts +617 -0
- package/src/giteaApi.ts +333 -0
- package/src/index.ts +1310 -0
- package/src/logger.ts +44 -0
- package/src/opencode.ts +340 -0
- package/src/ratelimit.ts +35 -0
- package/src/reactions.ts +31 -0
- package/src/render/components.ts +66 -0
- package/src/render/layout.ts +240 -0
- package/src/render/markdown.ts +106 -0
- package/src/schema.sql +178 -0
- package/src/static/app.js +571 -0
- package/src/static/favicon.svg +6 -0
- package/src/static/file.js +103 -0
- package/src/static/session.js +380 -0
- package/src/static/tts.js +101 -0
- package/src/store.ts +1020 -0
- package/src/translate.ts +166 -0
- package/src/views/adminTokens.ts +122 -0
- package/src/views/home.ts +109 -0
- package/src/views/issueList.ts +89 -0
- package/src/views/issueNew.ts +35 -0
- package/src/views/issueThread.ts +179 -0
- package/src/views/issues.ts +66 -0
- package/src/views/projectMembers.ts +146 -0
- package/src/views/projectUpstreams.ts +145 -0
- package/src/views/sessionLog.ts +709 -0
- package/src/views/settings.ts +67 -0
- package/src/views/tokens.ts +146 -0
- package/src/views/ttsBackends.ts +98 -0
- package/src/views/users.ts +163 -0
- package/src/views/webhooks.ts +166 -0
- package/src/webhooks.ts +634 -0
package/src/fileview.ts
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { realpathSync, statSync, readFileSync, openSync, readSync, closeSync, readdirSync } from "fs";
|
|
2
|
+
import { isAbsolute, relative, join, dirname } from "path";
|
|
3
|
+
import hljs from "highlight.js";
|
|
4
|
+
import { THEME_CSS, escapeHtml, escapeAttr } from "./render/layout";
|
|
5
|
+
import { renderMarkdown } from "./render/markdown";
|
|
6
|
+
import { BUILD_ID } from "./build";
|
|
7
|
+
import type { Config } from "./config";
|
|
8
|
+
|
|
9
|
+
export class FileViewError extends Error {
|
|
10
|
+
status: number;
|
|
11
|
+
constructor(message: string, status: number) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "FileViewError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Defense-in-depth denylist — always rejected even inside an allowlisted root.
|
|
19
|
+
// Tested on both the raw input and the realpath, so symlinks can't bypass.
|
|
20
|
+
const DENY = [
|
|
21
|
+
/(^|\/)\.env\b/i,
|
|
22
|
+
/(^|\/)\.(ssh|gnupg|aws|config\/gitea)\b/i,
|
|
23
|
+
/(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b/i,
|
|
24
|
+
/^\/(?:etc|proc|sys|dev|boot|root)\b/i,
|
|
25
|
+
/\/\.git\/(?:config|hooks|HEAD)\b/i,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export interface LineRow {
|
|
29
|
+
n: number;
|
|
30
|
+
t: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FileChunk {
|
|
34
|
+
realPath: string;
|
|
35
|
+
rows: LineRow[];
|
|
36
|
+
mode: "tail" | "head";
|
|
37
|
+
order: "asc" | "desc";
|
|
38
|
+
byteCapped: boolean;
|
|
39
|
+
note: string;
|
|
40
|
+
size: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FileDelta {
|
|
44
|
+
rows: LineRow[];
|
|
45
|
+
size: number;
|
|
46
|
+
rotated: boolean;
|
|
47
|
+
capped: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Shared security gate for /file and /api/file/since: absolute path → denylist
|
|
51
|
+
// (raw) → realpath → denylist (realpath) → allowlist containment → regular file.
|
|
52
|
+
// Returns the realpath; caller does the read.
|
|
53
|
+
function validateFilePath(cfg: Config, rawPath: string): string {
|
|
54
|
+
if (!rawPath || !isAbsolute(rawPath)) {
|
|
55
|
+
throw new FileViewError("path must be absolute", 400);
|
|
56
|
+
}
|
|
57
|
+
for (const re of DENY) if (re.test(rawPath)) {
|
|
58
|
+
throw new FileViewError("denied: sensitive path", 403);
|
|
59
|
+
}
|
|
60
|
+
let rp: string;
|
|
61
|
+
try {
|
|
62
|
+
rp = realpathSync(rawPath);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new FileViewError("file not found", 404);
|
|
65
|
+
}
|
|
66
|
+
for (const re of DENY) if (re.test(rp)) {
|
|
67
|
+
throw new FileViewError("denied: sensitive path", 403);
|
|
68
|
+
}
|
|
69
|
+
// Allowlist: realpath must be strictly within a root. Both root and target are
|
|
70
|
+
// realpath-resolved so a root symlink is followed consistently; relative() with
|
|
71
|
+
// no leading ".." and not absolute => target is contained under the root.
|
|
72
|
+
const inRoot = cfg.fileRoots.some((r) => {
|
|
73
|
+
if (!isAbsolute(r)) return false;
|
|
74
|
+
let rr: string;
|
|
75
|
+
try { rr = realpathSync(r); } catch { return false; }
|
|
76
|
+
const rel = relative(rr, rp);
|
|
77
|
+
return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
|
|
78
|
+
});
|
|
79
|
+
if (!inRoot) throw new FileViewError("denied: outside allowlisted roots", 403);
|
|
80
|
+
return rp;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateReadableFile(cfg: Config, rawPath: string): { rp: string; size: number } {
|
|
84
|
+
const rp = validateFilePath(cfg, rawPath);
|
|
85
|
+
let st: ReturnType<typeof statSync>;
|
|
86
|
+
try { st = statSync(rp); } catch { throw new FileViewError("stat failed", 404); }
|
|
87
|
+
if (!st.isFile()) throw new FileViewError("not a regular file", 400);
|
|
88
|
+
return { rp, size: st.size };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Like validateReadableFile but accepts directories too — caller branches on
|
|
92
|
+
// isDirectory(). Same allowlist/denylist/realpath gate applies to dirs.
|
|
93
|
+
function validatePath(cfg: Config, rawPath: string): { rp: string; isDir: boolean } {
|
|
94
|
+
const rp = validateFilePath(cfg, rawPath);
|
|
95
|
+
let st: ReturnType<typeof statSync>;
|
|
96
|
+
try { st = statSync(rp); } catch { throw new FileViewError("stat failed", 404); }
|
|
97
|
+
if (st.isDirectory()) return { rp, isDir: true };
|
|
98
|
+
if (!st.isFile()) throw new FileViewError("not a regular file or directory", 400);
|
|
99
|
+
return { rp, isDir: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readAllowedFile(
|
|
103
|
+
cfg: Config,
|
|
104
|
+
rawPath: string,
|
|
105
|
+
mode: "tail" | "head",
|
|
106
|
+
order: "asc" | "desc"
|
|
107
|
+
): FileChunk {
|
|
108
|
+
const { rp, size } = validateReadableFile(cfg, rawPath);
|
|
109
|
+
|
|
110
|
+
const cap = cfg.fileMaxBytes;
|
|
111
|
+
let buf: Buffer;
|
|
112
|
+
let byteCapped = false;
|
|
113
|
+
if (size > cap) {
|
|
114
|
+
// Slice only `cap` bytes from the chosen end — avoids loading multi-MB/GB
|
|
115
|
+
// logs into memory. Tail reads the last cap; head reads the first cap.
|
|
116
|
+
const start = mode === "tail" ? size - cap : 0;
|
|
117
|
+
buf = readSlice(rp, start, cap);
|
|
118
|
+
byteCapped = true;
|
|
119
|
+
} else {
|
|
120
|
+
try { buf = readFileSync(rp); } catch (e) {
|
|
121
|
+
throw new FileViewError(`read failed: ${(e as Error).message}`, 500);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Binary detection: a NUL byte in the first 8KB => not a text log.
|
|
126
|
+
const scanLen = Math.min(buf.length, 8192);
|
|
127
|
+
for (let i = 0; i < scanLen; i++) if (buf[i] === 0) {
|
|
128
|
+
throw new FileViewError("binary file (not viewable as text)", 415);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const text = buf.toString("utf-8");
|
|
132
|
+
let lines = text.split("\n");
|
|
133
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
134
|
+
|
|
135
|
+
const totalLines = lines.length;
|
|
136
|
+
const n = cfg.fileMaxLines;
|
|
137
|
+
let shown: string[];
|
|
138
|
+
let note: string;
|
|
139
|
+
if (byteCapped) {
|
|
140
|
+
if (mode === "tail") {
|
|
141
|
+
shown = lines.slice(-n);
|
|
142
|
+
// First line of a tail slice is likely truncated mid-line — clobber it.
|
|
143
|
+
if (shown.length > 1) shown[0] = "(…前文已截断)";
|
|
144
|
+
note = `文件 ${formatBytes(size)},仅读取末尾 ${formatBytes(cap)} 的最后 ${shown.length} 行`;
|
|
145
|
+
} else {
|
|
146
|
+
shown = lines.slice(0, n);
|
|
147
|
+
note = `文件 ${formatBytes(size)},仅读取开头 ${formatBytes(cap)} 的前 ${shown.length} 行`;
|
|
148
|
+
}
|
|
149
|
+
} else if (totalLines <= n) {
|
|
150
|
+
shown = lines;
|
|
151
|
+
note = `共 ${totalLines} 行 · ${formatBytes(size)}`;
|
|
152
|
+
} else if (mode === "tail") {
|
|
153
|
+
shown = lines.slice(-n);
|
|
154
|
+
note = `共 ${totalLines} 行,显示末尾 ${n} 行`;
|
|
155
|
+
} else {
|
|
156
|
+
shown = lines.slice(0, n);
|
|
157
|
+
note = `共 ${totalLines} 行,显示前 ${n} 行`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Original line numbers (unknown when byte-capped → positional 1..N).
|
|
161
|
+
let firstNum: number;
|
|
162
|
+
if (byteCapped) {
|
|
163
|
+
firstNum = 1;
|
|
164
|
+
} else if (mode === "tail") {
|
|
165
|
+
firstNum = totalLines - shown.length + 1;
|
|
166
|
+
} else {
|
|
167
|
+
firstNum = 1;
|
|
168
|
+
}
|
|
169
|
+
let rows = shown.map((t, i) => ({ n: firstNum + i, t }));
|
|
170
|
+
if (order === "desc") rows = rows.reverse();
|
|
171
|
+
|
|
172
|
+
return { realPath: rp, rows, mode, order, byteCapped, note, size };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Live tail -f: returns only bytes appended after `afterOffset`. `rotated`
|
|
176
|
+
// (size shrank) tells the client to reload; client seeds afterOffset from page size.
|
|
177
|
+
export function readFileSince(cfg: Config, rawPath: string, afterOffset: number): FileDelta {
|
|
178
|
+
if (!Number.isFinite(afterOffset) || afterOffset < 0) {
|
|
179
|
+
throw new FileViewError("bad after offset", 400);
|
|
180
|
+
}
|
|
181
|
+
const { rp, size } = validateReadableFile(cfg, rawPath);
|
|
182
|
+
if (size < afterOffset) return { rows: [], size, rotated: true, capped: false };
|
|
183
|
+
if (size === afterOffset) return { rows: [], size, rotated: false, capped: false };
|
|
184
|
+
const cap = cfg.fileMaxBytes;
|
|
185
|
+
const want = Math.min(size - afterOffset, cap);
|
|
186
|
+
const buf = readSlice(rp, afterOffset, want);
|
|
187
|
+
const scanLen = Math.min(buf.length, 8192);
|
|
188
|
+
for (let i = 0; i < scanLen; i++) if (buf[i] === 0) {
|
|
189
|
+
throw new FileViewError("binary file (not viewable as text)", 415);
|
|
190
|
+
}
|
|
191
|
+
const text = buf.toString("utf-8");
|
|
192
|
+
let lines = text.split("\n");
|
|
193
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
194
|
+
const capped = size - afterOffset >= cap;
|
|
195
|
+
if (capped && lines.length > 1) lines[0] = "(…前文已截断)";
|
|
196
|
+
const n = cfg.fileMaxLines;
|
|
197
|
+
if (lines.length > n) lines = lines.slice(-n);
|
|
198
|
+
const rows = lines.map((t) => ({ n: 0, t }));
|
|
199
|
+
return { rows, size, rotated: false, capped };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readSlice(path: string, start: number, len: number): Buffer {
|
|
203
|
+
const fd = openSync(path, "r");
|
|
204
|
+
try {
|
|
205
|
+
const buf = Buffer.alloc(len);
|
|
206
|
+
const got = readSync(fd, buf, 0, len, start);
|
|
207
|
+
return buf.subarray(0, got);
|
|
208
|
+
} finally {
|
|
209
|
+
closeSync(fd);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function formatBytes(n: number): string {
|
|
214
|
+
if (n < 1024) return `${n}B`;
|
|
215
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
216
|
+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
217
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)}GB`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Log files default newest-first (tail + desc); documents head + asc.
|
|
221
|
+
function isLogPath(p: string): boolean {
|
|
222
|
+
return /\.log(\.\d+)?$/i.test(p) || /\.out$/i.test(p);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const EXT_LANG: Record<string, string> = {
|
|
226
|
+
py: "python", ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", mjs: "javascript",
|
|
227
|
+
java: "java", kt: "kotlin", go: "go", rs: "rust", rb: "ruby", php: "php",
|
|
228
|
+
c: "c", h: "c", cpp: "cpp", cc: "cpp", hpp: "cpp", cxx: "cpp", cs: "csharp",
|
|
229
|
+
sh: "bash", bash: "bash", zsh: "bash", sql: "sql", json: "json", yaml: "yaml", yml: "yaml",
|
|
230
|
+
toml: "toml", xml: "xml", html: "xml", htm: "xml", css: "css", scss: "scss", less: "less",
|
|
231
|
+
md: "markdown", markdown: "markdown", scala: "scala", swift: "swift", lua: "lua", pl: "perl",
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
function extToLang(p: string): string {
|
|
235
|
+
const base = (p.split("/").pop() || "").toLowerCase();
|
|
236
|
+
if (base === "dockerfile" || base.endsWith(".dockerfile")) return "dockerfile";
|
|
237
|
+
const m = base.match(/\.([a-z0-9]+)$/);
|
|
238
|
+
const ext = m && m[1];
|
|
239
|
+
return ext ? (EXT_LANG[ext] || "") : "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function hljsHighlight(text: string, lang: string): string {
|
|
243
|
+
try {
|
|
244
|
+
return hljs.highlight(text, { language: lang }).value;
|
|
245
|
+
} catch {
|
|
246
|
+
return escapeHtml(text);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function fmtSize(n: number): string {
|
|
251
|
+
if (n < 1024) return `${n}B`;
|
|
252
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
253
|
+
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Compact relative/short mtime for directory rows — kept short on purpose so two
|
|
257
|
+
// fixed columns (time + size) don't starve the name on narrow phone screens.
|
|
258
|
+
function fmtMtime(ms: number): string {
|
|
259
|
+
if (!ms) return "";
|
|
260
|
+
const diff = Date.now() - ms;
|
|
261
|
+
if (diff < 0) return "刚刚";
|
|
262
|
+
if (diff < 60000) return "刚刚";
|
|
263
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}分前`;
|
|
264
|
+
if (diff < 86400000) return `${Math.floor(diff / 3600000)}时前`;
|
|
265
|
+
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`;
|
|
266
|
+
const d = new Date(ms);
|
|
267
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
268
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
269
|
+
if (d.getFullYear() === new Date().getFullYear()) return `${mm}-${dd}`;
|
|
270
|
+
return `${String(d.getFullYear()).slice(-2)}-${mm}-${dd}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Directory listing (FTP-style): parent link + entries (dirs first), each
|
|
274
|
+
// clickable to /file?path=<child> which re-validates + branches dir/file.
|
|
275
|
+
function breadcrumbHTML(rp: string, cfg: Config): string {
|
|
276
|
+
const segs = rp.split("/").filter(Boolean);
|
|
277
|
+
let roots: string[] = [];
|
|
278
|
+
try { roots = cfg.fileRoots.map((r) => realpathSync(r)); } catch { /* ignore */ }
|
|
279
|
+
const within = (p: string) => roots.some((root) => p === root || p.startsWith(root + "/"));
|
|
280
|
+
const parts: string[] = [];
|
|
281
|
+
let acc = "";
|
|
282
|
+
segs.forEach((seg, i) => {
|
|
283
|
+
acc += "/" + seg;
|
|
284
|
+
if (i === segs.length - 1) parts.push(`<span class="fpc-last">${escapeHtml(seg)}</span>`);
|
|
285
|
+
else if (within(acc)) parts.push(`<a class="fpc" href="/file?path=${encodeURIComponent(acc)}">${escapeHtml(seg)}</a>`);
|
|
286
|
+
else parts.push(`<span class="fpc-muted">${escapeHtml(seg)}</span>`);
|
|
287
|
+
});
|
|
288
|
+
return `<span class="fpc-root">/</span>` + parts.join(`<span class="fpc-sep">/</span>`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function buildDirView(cfg: Config, rp: string, sortReq: string | undefined, tdirReq: string | undefined): { html: string } {
|
|
292
|
+
type Ent = { name: string; isDir: boolean; size: number; mtime: number };
|
|
293
|
+
const ents: Ent[] = [];
|
|
294
|
+
try {
|
|
295
|
+
for (const d of readdirSync(rp, { withFileTypes: true })) {
|
|
296
|
+
let size = 0; let mtime = 0;
|
|
297
|
+
try { const st = statSync(join(rp, d.name)); size = st.size; mtime = st.mtimeMs; } catch { /* unreadable entry */ }
|
|
298
|
+
ents.push({ name: d.name, isDir: d.isDirectory(), size, mtime });
|
|
299
|
+
}
|
|
300
|
+
} catch (e) {
|
|
301
|
+
throw new FileViewError(`readdir failed: ${(e as Error).message}`, 500);
|
|
302
|
+
}
|
|
303
|
+
const sortByTime = sortReq !== "name";
|
|
304
|
+
const tdir: "asc" | "desc" = tdirReq === "asc" ? "asc" : "desc";
|
|
305
|
+
ents.sort((a, b) => {
|
|
306
|
+
if (sortByTime) {
|
|
307
|
+
const c = a.mtime - b.mtime;
|
|
308
|
+
return tdir === "desc" ? b.mtime - a.mtime : c;
|
|
309
|
+
}
|
|
310
|
+
return a.isDir === b.isDir
|
|
311
|
+
? a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
312
|
+
: a.isDir ? -1 : 1;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const parent = dirname(rp);
|
|
316
|
+
const parentInRoot = cfg.fileRoots.some((r) => {
|
|
317
|
+
if (!isAbsolute(r)) return false;
|
|
318
|
+
let rr: string;
|
|
319
|
+
try { rr = realpathSync(r); } catch { return false; }
|
|
320
|
+
const rel = relative(rr, parent);
|
|
321
|
+
return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const enc = encodeURIComponent(rp);
|
|
325
|
+
const rows = ents.map((e) => {
|
|
326
|
+
const href = `/file?path=${encodeURIComponent(join(rp, e.name))}`;
|
|
327
|
+
const icon = e.isDir ? "📁" : "📄";
|
|
328
|
+
const nm = e.isDir ? `${e.name}/` : e.name;
|
|
329
|
+
return `<a class="drow" href="${escapeAttr(href)}"><span class="dicon">${icon}</span><span class="dname">${escapeHtml(nm)}</span><span class="dtime">${fmtMtime(e.mtime)}</span><span class="dsz">${e.isDir ? "" : fmtSize(e.size)}</span></a>`;
|
|
330
|
+
}).join("");
|
|
331
|
+
const parentRow = parentInRoot
|
|
332
|
+
? `<a class="drow dup" href="/file?path=${encodeURIComponent(parent)}"><span class="dicon">📁</span><span class="dname">..</span><span class="dtime"></span><span class="dsz"></span></a>`
|
|
333
|
+
: "";
|
|
334
|
+
const nameQ = `?path=${enc}&sort=name`;
|
|
335
|
+
const timeDescQ = `?path=${enc}&sort=time&tdir=desc`;
|
|
336
|
+
const timeAscQ = `?path=${enc}&sort=time&tdir=asc`;
|
|
337
|
+
const sortBar = `<div class="dsort"><span class="fv-seg"><a class="${!sortByTime ? "active" : ""}" href="${nameQ}">名称</a><a class="${sortByTime ? "active" : ""}" href="${timeDescQ}">时间</a></span>${sortByTime ? `<span class="fv-seg"><a class="${tdir === "desc" ? "active" : ""}" href="${timeDescQ}">新→旧</a><a class="${tdir === "asc" ? "active" : ""}" href="${timeAscQ}">旧→新</a></span>` : ""}</div>`;
|
|
338
|
+
|
|
339
|
+
const html = `<!doctype html>
|
|
340
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
341
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
342
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
343
|
+
<title>${escapeHtml(basename(rp) || rp)} · 目录</title>
|
|
344
|
+
<style>${THEME_CSS}
|
|
345
|
+
.dlist{max-width:900px;margin:0 auto;padding:.4rem 1rem 3rem}
|
|
346
|
+
.drow{display:flex;align-items:center;gap:.6rem;padding:.45rem .6rem;border-bottom:1px solid var(--border);color:var(--text);text-decoration:none}
|
|
347
|
+
.drow:hover{background:var(--bg-muted);text-decoration:none}
|
|
348
|
+
.drow.dup{color:var(--text-muted)}
|
|
349
|
+
.dicon{width:1.4rem;flex-shrink:0;font-size:15px}
|
|
350
|
+
.dname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;word-break:break-all}
|
|
351
|
+
.dtime{color:var(--text-muted);font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;flex-shrink:0;text-align:right;min-width:3.4rem}
|
|
352
|
+
.dsz{color:var(--text-muted);font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;flex-shrink:0;text-align:right;min-width:3rem}
|
|
353
|
+
.dhdr{max-width:900px;margin:0 auto;padding:.7rem 1rem .3rem;color:var(--text-muted);font-size:13px;word-break:break-all}
|
|
354
|
+
.dhdr .fpc{color:var(--accent)}.dhdr .fpc:hover{text-decoration:underline}.dhdr .fpc-sep{color:var(--text-muted);margin:0 .1rem}.dhdr .fpc-last{color:var(--text);font-weight:600}.dhdr .fpc-muted{color:var(--text-muted)}.dhdr .fpc-root{color:var(--text-muted);margin-right:.1rem}
|
|
355
|
+
.dsort{max-width:900px;margin:0 auto;padding:.2rem 1rem .4rem;display:flex;gap:.4rem;flex-wrap:wrap}
|
|
356
|
+
.fv-seg{display:inline-flex;border:1px solid var(--border);border-radius:6px;overflow:hidden}
|
|
357
|
+
.fv-seg a{font-size:12px;padding:.22rem .55rem;background:var(--bg-elev);color:var(--text);border:none}
|
|
358
|
+
.fv-seg a:hover{background:var(--bg-muted);text-decoration:none}
|
|
359
|
+
.fv-seg a.active{background:var(--accent);color:#fff;font-weight:600}
|
|
360
|
+
@media(max-width:480px){.drow{gap:.4rem}.dtime{min-width:2.8rem;font-size:10px}.dsz{min-width:2.4rem;font-size:11px}}
|
|
361
|
+
</style></head><body>
|
|
362
|
+
<header class="nav" style="display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px;flex-wrap:wrap"><a href="/" style="color:var(--header-text)">🏠</a><span style="opacity:.5">/</span><a href="/sessions" style="color:var(--header-text)">会话</a><span style="opacity:.5">/</span><span style="opacity:.85">目录</span></header>
|
|
363
|
+
<div class="dhdr">${breadcrumbHTML(rp, cfg)}</div>
|
|
364
|
+
${sortBar}
|
|
365
|
+
<main class="dlist">${parentRow}${rows || `<div style="padding:2rem;color:var(--text-muted)">空目录</div>`}</main>
|
|
366
|
+
</body></html>`;
|
|
367
|
+
return { html };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const MEDIA: Record<string, { kind: "audio" | "video" | "image" | "pdf"; mime: string }> = {
|
|
371
|
+
mp3: { kind: "audio", mime: "audio/mpeg" }, wav: { kind: "audio", mime: "audio/wav" },
|
|
372
|
+
ogg: { kind: "audio", mime: "audio/ogg" }, m4a: { kind: "audio", mime: "audio/mp4" },
|
|
373
|
+
flac: { kind: "audio", mime: "audio/flac" }, aac: { kind: "audio", mime: "audio/aac" },
|
|
374
|
+
mp4: { kind: "video", mime: "video/mp4" }, webm: { kind: "video", mime: "video/webm" },
|
|
375
|
+
mov: { kind: "video", mime: "video/quicktime" }, mkv: { kind: "video", mime: "video/x-matroska" },
|
|
376
|
+
avi: { kind: "video", mime: "video/x-msvideo" },
|
|
377
|
+
png: { kind: "image", mime: "image/png" }, jpg: { kind: "image", mime: "image/jpeg" },
|
|
378
|
+
jpeg: { kind: "image", mime: "image/jpeg" }, gif: { kind: "image", mime: "image/gif" },
|
|
379
|
+
webp: { kind: "image", mime: "image/webp" }, bmp: { kind: "image", mime: "image/bmp" },
|
|
380
|
+
svg: { kind: "image", mime: "image/svg+xml" },
|
|
381
|
+
pdf: { kind: "pdf", mime: "application/pdf" },
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
function mediaKind(p: string): "" | "audio" | "video" | "image" | "pdf" {
|
|
385
|
+
const b = basename(p);
|
|
386
|
+
const i = b.lastIndexOf(".");
|
|
387
|
+
if (i < 0) return "";
|
|
388
|
+
return MEDIA[b.slice(i + 1).toLowerCase()]?.kind ?? "";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function mimeOf(p: string): string {
|
|
392
|
+
const b = basename(p);
|
|
393
|
+
const i = b.lastIndexOf(".");
|
|
394
|
+
if (i < 0) return "application/octet-stream";
|
|
395
|
+
return MEDIA[b.slice(i + 1).toLowerCase()]?.mime ?? "application/octet-stream";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Stream a file's raw bytes for /file/raw (inline: media player src) or /file/dl
|
|
399
|
+
// (attachment: forced download). Same gate as /file; large files stream via
|
|
400
|
+
// Bun.file without buffering into memory.
|
|
401
|
+
export function serveRawFile(cfg: Config, rawPath: string, disposition: "inline" | "attachment"): Response {
|
|
402
|
+
const { rp, size } = validateReadableFile(cfg, rawPath);
|
|
403
|
+
const name = basename(rp);
|
|
404
|
+
const headers: Record<string, string> = {
|
|
405
|
+
"content-type": mimeOf(rp),
|
|
406
|
+
"content-length": String(size),
|
|
407
|
+
"cache-control": "private, max-age=0",
|
|
408
|
+
};
|
|
409
|
+
if (disposition === "attachment") {
|
|
410
|
+
// RFC 5987 filename* for non-ASCII; ASCII fallback for older clients.
|
|
411
|
+
const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "");
|
|
412
|
+
headers["content-disposition"] = `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
413
|
+
} else {
|
|
414
|
+
headers["content-disposition"] = "inline";
|
|
415
|
+
}
|
|
416
|
+
return new Response(Bun.file(rp), { headers });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function buildMediaView(cfg: Config, rp: string): { html: string } {
|
|
420
|
+
const kind = mediaKind(rp);
|
|
421
|
+
const enc = encodeURIComponent(rp);
|
|
422
|
+
const rawUrl = `/file/raw?path=${enc}`;
|
|
423
|
+
const dlUrl = `/file/dl?path=${enc}`;
|
|
424
|
+
let player = "";
|
|
425
|
+
if (kind === "audio") player = `<audio controls preload="metadata" src="${rawUrl}" style="width:100%;max-width:640px"></audio>`;
|
|
426
|
+
else if (kind === "video") player = `<video controls preload="metadata" src="${rawUrl}" style="max-width:100%;max-height:70vh"></video>`;
|
|
427
|
+
else if (kind === "image") player = `<img src="${rawUrl}" alt="${escapeAttr(basename(rp))}" style="max-width:100%;max-height:70vh;border:1px solid var(--border);border-radius:8px">`;
|
|
428
|
+
else if (kind === "pdf") player = `<iframe src="${rawUrl}" style="width:100%;height:72vh;border:1px solid var(--border);border-radius:8px"></iframe>`;
|
|
429
|
+
const html = `<!doctype html>
|
|
430
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
431
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
432
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
433
|
+
<title>${escapeHtml(basename(rp))} · 媒体</title>
|
|
434
|
+
<style>${THEME_CSS}
|
|
435
|
+
.mv-head{max-width:900px;margin:0 auto;padding:.6rem 1rem;border-bottom:1px solid var(--border)}
|
|
436
|
+
.mv-head .fn{font-weight:600;font-size:15px;word-break:break-all;margin-bottom:.3rem}
|
|
437
|
+
.mv-head .fp{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted);word-break:break-all}
|
|
438
|
+
.mv-head .fp .fpc{color:var(--accent)}.mv-head .fp .fpc:hover{text-decoration:underline}.mv-head .fp .fpc-sep{color:var(--text-muted);margin:0 .1rem}.mv-head .fp .fpc-last{color:var(--text);font-weight:600}.mv-head .fp .fpc-muted{color:var(--text-muted)}.mv-head .fp .fpc-root{color:var(--text-muted);margin-right:.1rem}
|
|
439
|
+
.mv-body{max-width:900px;margin:1.5rem auto;padding:0 1rem 4rem;text-align:center}
|
|
440
|
+
.mv-body audio,.mv-body video,.mv-body img,.mv-body iframe{margin:0 auto}
|
|
441
|
+
.dlbtn{display:inline-block;margin-top:1.2rem;background:var(--green);color:#fff;padding:.55rem 1.2rem;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
|
442
|
+
.dlbtn:hover{opacity:.9;text-decoration:none}
|
|
443
|
+
</style></head><body>
|
|
444
|
+
<header class="nav" style="display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px;flex-wrap:wrap"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.5">/</span><span style="opacity:.85">媒体</span></header>
|
|
445
|
+
<div class="mv-head"><div class="fn">${escapeHtml(basename(rp))}</div><div class="fp">${breadcrumbHTML(rp, cfg)}</div></div>
|
|
446
|
+
<div class="mv-body">${player}<br><a class="dlbtn" href="${dlUrl}">⬇ 下载</a></div>
|
|
447
|
+
</body></html>`;
|
|
448
|
+
return { html };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function buildDownloadView(cfg: Config, rp: string): { html: string } {
|
|
452
|
+
const dlUrl = `/file/dl?path=${encodeURIComponent(rp)}`;
|
|
453
|
+
const html = `<!doctype html>
|
|
454
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
455
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
456
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
457
|
+
<title>${escapeHtml(basename(rp))} · 下载</title>
|
|
458
|
+
<style>${THEME_CSS}
|
|
459
|
+
.dv-head{max-width:900px;margin:0 auto;padding:.6rem 1rem;border-bottom:1px solid var(--border)}
|
|
460
|
+
.dv-head .fp{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted);word-break:break-all}
|
|
461
|
+
.dv-head .fp .fpc{color:var(--accent)}.dv-head .fp .fpc:hover{text-decoration:underline}.dv-head .fp .fpc-sep{color:var(--text-muted);margin:0 .1rem}.dv-head .fp .fpc-last{color:var(--text);font-weight:600}.dv-head .fp .fpc-muted{color:var(--text-muted)}.dv-head .fp .fpc-root{color:var(--text-muted);margin-right:.1rem}
|
|
462
|
+
.dv-body{max-width:900px;margin:2rem auto;padding:0 1rem 4rem;text-align:center}
|
|
463
|
+
.dv-body .big{font-size:40px;margin-bottom:.5rem}
|
|
464
|
+
.dv-body .msg{color:var(--text-muted);margin-bottom:1.2rem}
|
|
465
|
+
.dlbtn{display:inline-block;background:var(--green);color:#fff;padding:.6rem 1.4rem;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
|
466
|
+
.dlbtn:hover{opacity:.9;text-decoration:none}
|
|
467
|
+
</style></head><body>
|
|
468
|
+
<header class="nav" style="display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px;flex-wrap:wrap"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.5">/</span><span style="opacity:.85">文件</span></header>
|
|
469
|
+
<div class="dv-head"><div class="fp">${breadcrumbHTML(rp, cfg)}</div></div>
|
|
470
|
+
<div class="dv-body"><div class="big">📄</div><div class="msg">该文件无法在线预览(二进制)。</div><a class="dlbtn" href="${dlUrl}">⬇ 下载 ${escapeHtml(basename(rp))}</a></div>
|
|
471
|
+
</body></html>`;
|
|
472
|
+
return { html };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function buildFileView(
|
|
476
|
+
cfg: Config,
|
|
477
|
+
rawPath: string,
|
|
478
|
+
modeReq: string | undefined,
|
|
479
|
+
orderReq: string | undefined,
|
|
480
|
+
viewReq: string | undefined,
|
|
481
|
+
sortReq: string | undefined,
|
|
482
|
+
tdirReq: string | undefined
|
|
483
|
+
): { html: string } {
|
|
484
|
+
const isLog = isLogPath(rawPath);
|
|
485
|
+
const mode: "tail" | "head" = modeReq === "tail" || modeReq === "head"
|
|
486
|
+
? modeReq
|
|
487
|
+
: isLog ? "tail" : "head";
|
|
488
|
+
const order: "asc" | "desc" = orderReq === "asc" || orderReq === "desc"
|
|
489
|
+
? orderReq
|
|
490
|
+
: isLog ? "desc" : "asc";
|
|
491
|
+
|
|
492
|
+
const resolved = validatePath(cfg, rawPath);
|
|
493
|
+
if (resolved.isDir) return buildDirView(cfg, resolved.rp, sortReq, tdirReq);
|
|
494
|
+
if (mediaKind(rawPath)) return buildMediaView(cfg, resolved.rp);
|
|
495
|
+
let chunk: FileChunk;
|
|
496
|
+
try {
|
|
497
|
+
chunk = readAllowedFile(cfg, rawPath, mode, order);
|
|
498
|
+
} catch (e) {
|
|
499
|
+
if (e instanceof FileViewError && e.status === 415) return buildDownloadView(cfg, resolved.rp);
|
|
500
|
+
throw e;
|
|
501
|
+
}
|
|
502
|
+
const ext = (rawPath.split(".").pop() || "").toLowerCase();
|
|
503
|
+
const isMd = ext === "md" || ext === "markdown";
|
|
504
|
+
const mdRender = isMd && viewReq !== "raw";
|
|
505
|
+
const lang = extToLang(rawPath);
|
|
506
|
+
const shownBytes = chunk.rows.reduce((s, r) => s + r.t.length + 1, 0);
|
|
507
|
+
const fullText = chunk.rows.map((r) => r.t).join("\n");
|
|
508
|
+
const body = mdRender
|
|
509
|
+
? `<div class="md-render">${renderMarkdown(fullText, "")}</div>`
|
|
510
|
+
: lang && shownBytes <= 100000
|
|
511
|
+
? renderHighlighted(chunk, lang)
|
|
512
|
+
: `<pre><code>${chunk.rows.map((r) => lineRow(r.t, r.n)).join("")}</code></pre>`;
|
|
513
|
+
const enc = encodeURIComponent(rawPath);
|
|
514
|
+
const ordQ = `&order=${order}`;
|
|
515
|
+
const modeQ = `&mode=${mode}`;
|
|
516
|
+
const html = `<!doctype html>
|
|
517
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
518
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
519
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
520
|
+
<title>${escapeHtml(basename(rawPath))} · 文件</title>
|
|
521
|
+
<link rel="stylesheet" href="/static/highlight.css">
|
|
522
|
+
<style>${THEME_CSS}
|
|
523
|
+
.fv-head{max-width:1100px;margin:0 auto;padding:.6rem 1rem;border-bottom:1px solid var(--border)}
|
|
524
|
+
.fv-head .fp{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-muted);word-break:break-all}
|
|
525
|
+
.fv-head .fp .fpc{color:var(--accent)}.fv-head .fp .fpc:hover{text-decoration:underline}.fv-head .fp .fpc-sep{color:var(--text-muted);margin:0 .1rem}.fv-head .fp .fpc-last{color:var(--text);font-weight:600}.fv-head .fp .fpc-muted{color:var(--text-muted)}.fv-head .fp .fpc-root{color:var(--text-muted);margin-right:.1rem}
|
|
526
|
+
.fv-head .fn{font-weight:600;font-size:15px;word-break:break-all}
|
|
527
|
+
.fv-bar{display:flex;gap:.4rem;align-items:center;margin-top:.4rem;flex-wrap:wrap}
|
|
528
|
+
.fv-bar .note{font-size:12px;color:var(--text-muted);margin-right:.3rem}
|
|
529
|
+
.fv-seg{display:inline-flex;border:1px solid var(--border);border-radius:6px;overflow:hidden}
|
|
530
|
+
.fv-seg a{font-size:12px;padding:.25rem .6rem;background:var(--bg-elev);color:var(--text);border:none}
|
|
531
|
+
.fv-seg a:hover{background:var(--bg-muted);text-decoration:none}
|
|
532
|
+
.fv-seg a.active{background:var(--accent);color:#fff;font-weight:600}
|
|
533
|
+
.fv-pre{max-width:1100px;margin:0 auto;padding:.4rem .2rem 5rem}
|
|
534
|
+
.fv-pre pre{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:.6rem 0;overflow:auto;font-size:12.5px;line-height:1.5}
|
|
535
|
+
.fv-pre .ln{display:flex}
|
|
536
|
+
.fv-pre .lnn{user-select:none;text-align:right;color:var(--text-muted);padding:0 .7rem 0 .8rem;min-width:3.5em;flex-shrink:0;border-right:1px solid var(--border);background:var(--bg-muted)}
|
|
537
|
+
.fv-pre .lnc{padding:0 .8rem;white-space:pre;overflow-wrap:anywhere}
|
|
538
|
+
.fv-pre .fv-code{display:flex;border:1px solid var(--border);border-radius:8px;overflow:auto;margin:0}
|
|
539
|
+
.fv-pre .fv-gutter{background:var(--bg-muted);color:var(--text-muted);text-align:right;padding:.6rem .7rem;font-size:12.5px;line-height:1.5;user-select:none;white-space:pre;flex-shrink:0;border:none;border-radius:0;margin:0}
|
|
540
|
+
.fv-pre .fv-src{margin:0;flex:1;overflow:auto;background:var(--bg-elev);padding:.6rem .8rem;font-size:12.5px;line-height:1.5;border:none;border-radius:0}
|
|
541
|
+
.fv-pre .fv-src code{background:none;padding:0;white-space:pre;font-size:12.5px}
|
|
542
|
+
.fab{position:fixed;right:1.2rem;width:2.4rem;height:2.4rem;border-radius:50%;background:var(--bg-elev);border:1px solid var(--border);color:var(--text);display:flex;align-items:center;justify-content:center;font-size:18px;text-decoration:none;box-shadow:0 2px 8px rgba(0,0,0,.18);z-index:50}
|
|
543
|
+
.fab:hover{border-color:var(--accent);color:var(--accent);text-decoration:none}
|
|
544
|
+
.fab-top{bottom:3.6rem}
|
|
545
|
+
.fab-bot{bottom:.8rem}
|
|
546
|
+
.fbtn{font-size:12px;padding:.25rem .7rem;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;font:inherit;font-weight:600}
|
|
547
|
+
.fbtn:hover{border-color:var(--accent)}
|
|
548
|
+
.fbtn.on{background:var(--green);color:#fff;border-color:var(--green)}
|
|
549
|
+
.fbtn.flash{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
550
|
+
.md-render{max-width:820px;margin:0 auto;padding:1rem 1.5rem 5rem;font-size:14.5px;line-height:1.7;color:var(--text)}
|
|
551
|
+
.md-render h1,.md-render h2,.md-render h3,.md-render h4{line-height:1.3;margin:1.4em 0 .6em;font-weight:600}
|
|
552
|
+
.md-render h1{font-size:1.7em;border-bottom:1px solid var(--border);padding-bottom:.3em}
|
|
553
|
+
.md-render h2{font-size:1.4em;border-bottom:1px solid var(--border);padding-bottom:.3em}
|
|
554
|
+
.md-render h3{font-size:1.2em}
|
|
555
|
+
.md-render h4{font-size:1.05em}
|
|
556
|
+
.md-render p{margin:.6em 0}
|
|
557
|
+
.md-render ul,.md-render ol{margin:.6em 0;padding-left:1.6em}
|
|
558
|
+
.md-render li{margin:.25em 0}
|
|
559
|
+
.md-render a{color:var(--accent);text-decoration:none}
|
|
560
|
+
.md-render a:hover{text-decoration:underline}
|
|
561
|
+
.md-render code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em;background:var(--bg-muted);padding:.15em .35em;border-radius:4px}
|
|
562
|
+
.md-render pre{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:.7rem 1rem;overflow:auto;margin:.8em 0}
|
|
563
|
+
.md-render pre code{background:none;padding:0;font-size:13px}
|
|
564
|
+
.md-render blockquote{border-left:3px solid var(--border);margin:.8em 0;padding:.2em 0 .2em 1em;color:var(--text-muted)}
|
|
565
|
+
.md-render table{border-collapse:collapse;margin:.8em 0;width:100%}
|
|
566
|
+
.md-render th,.md-render td{border:1px solid var(--border);padding:.4em .7em;text-align:left}
|
|
567
|
+
.md-render th{background:var(--bg-muted);font-weight:600}
|
|
568
|
+
.md-render img{max-width:100%}
|
|
569
|
+
.md-render hr{border:none;border-top:1px solid var(--border);margin:1.4em 0}
|
|
570
|
+
</style></head><body>
|
|
571
|
+
<header class="nav" style="display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px;flex-wrap:wrap">
|
|
572
|
+
<a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.5">/</span>
|
|
573
|
+
<span style="opacity:.85">文件查看</span>
|
|
574
|
+
</header>
|
|
575
|
+
<div class="fv-head">
|
|
576
|
+
<div class="fn">${escapeHtml(basename(rawPath))} ${isLog ? '<span style="font-size:11px;color:var(--text-muted);font-weight:400">(日志,默认最新在顶)</span>' : ""}</div>
|
|
577
|
+
<div class="fp">${breadcrumbHTML(chunk.realPath, cfg)}</div>
|
|
578
|
+
<div class="fv-bar">
|
|
579
|
+
<span class="note">${escapeHtml(chunk.note)}</span>
|
|
580
|
+
<span class="fv-seg">
|
|
581
|
+
<a class="${mode === "tail" ? "active" : ""}" href="/file?path=${enc}&mode=tail${ordQ}">末尾 ${cfg.fileMaxLines}</a>
|
|
582
|
+
<a class="${mode === "head" ? "active" : ""}" href="/file?path=${enc}&mode=head${ordQ}">开头 ${cfg.fileMaxLines}</a>
|
|
583
|
+
</span>
|
|
584
|
+
<span class="fv-seg">
|
|
585
|
+
<a class="${order === "desc" ? "active" : ""}" href="/file?path=${enc}${modeQ}&order=desc">倒排</a>
|
|
586
|
+
<a class="${order === "asc" ? "active" : ""}" href="/file?path=${enc}${modeQ}&order=asc">正排</a>
|
|
587
|
+
</span>
|
|
588
|
+
${isMd ? `<span class="fv-seg">
|
|
589
|
+
<a class="${mdRender ? "active" : ""}" href="/file?path=${enc}&view=rendered${modeQ}${ordQ}">渲染</a>
|
|
590
|
+
<a class="${!mdRender ? "active" : ""}" href="/file?path=${enc}&view=raw${modeQ}${ordQ}">原文</a>
|
|
591
|
+
</span>` : ""}
|
|
592
|
+
<button type="button" class="fbtn" id="followBtn" title="实时刷新(tail -f)">🔄 Follow</button>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
<div class="fv-pre"><div id="top"></div>${body}<div id="bottom"></div></div>
|
|
596
|
+
<a class="fab fab-top" href="#top" title="跳到最上">↑</a>
|
|
597
|
+
<a class="fab fab-bot" href="#bottom" title="跳到最下">↓</a>
|
|
598
|
+
<script type="application/json" id="file-data">${JSON.stringify({ path: rawPath, size: chunk.size, order, isLog, maxN: chunk.rows.reduce((m, r) => Math.max(m, r.n), 0) })}</script>
|
|
599
|
+
<script src="/static/file.js?v=${BUILD_ID}" defer></script>
|
|
600
|
+
</body></html>`;
|
|
601
|
+
return { html };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function lineRow(line: string, n: number): string {
|
|
605
|
+
return `<span class="ln"><span class="lnn">${n}</span><span class="lnc">${escapeHtml(line)}</span></span>`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function renderHighlighted(chunk: FileChunk, lang: string): string {
|
|
609
|
+
const shown = chunk.rows.map((r) => r.t).join("\n");
|
|
610
|
+
const nums = chunk.rows.map((r) => r.n).join("\n");
|
|
611
|
+
return `<div class="fv-code"><pre class="fv-gutter">${escapeHtml(nums)}</pre><pre class="fv-src"><code class="hljs language-${escapeHtml(lang)}">${hljsHighlight(shown, lang)}</code></pre></div>`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function basename(p: string): string {
|
|
615
|
+
const i = p.lastIndexOf("/");
|
|
616
|
+
return i >= 0 ? p.slice(i + 1) : p;
|
|
617
|
+
}
|