@dsh-xhl/dsh-file-explorer 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/lib/index.js ADDED
@@ -0,0 +1,639 @@
1
+ import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { spawn } from "node:child_process";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ //#region src/trust-fence.ts
5
+ function header(headers, name) {
6
+ const value = headers[name];
7
+ return typeof value === "string" ? value : void 0;
8
+ }
9
+ function parseAuthority(authority) {
10
+ try {
11
+ return new URL(`http://${authority}`);
12
+ } catch {
13
+ return;
14
+ }
15
+ }
16
+ /** Whether a normalized URL hostname names the local loopback authority. */
17
+ function isLoopbackHostname(hostname) {
18
+ if (hostname === "localhost" || hostname === "[::1]") return true;
19
+ const parts = hostname.split(".");
20
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
21
+ }
22
+ /**
23
+ * Decide whether one request may reach the plugin routes. Only loopback
24
+ * Host authorities pass (the DSH web GUI is served from 127.0.0.1/localhost);
25
+ * cross-site browser requests are refused.
26
+ */
27
+ function isTrustedApiRequest(request) {
28
+ const host = header(request.headers, "host");
29
+ if (host === void 0) return false;
30
+ const hostUrl = parseAuthority(host);
31
+ if (hostUrl === void 0) return false;
32
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
33
+ if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
34
+ const origin = header(request.headers, "origin");
35
+ if (origin === void 0) return true;
36
+ try {
37
+ return new URL(origin).host === hostUrl.host;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+ //#endregion
43
+ //#region src/index.ts
44
+ /**
45
+ * dsh-file-explorer host half: the /filex JSON API (session cwd, file list,
46
+ * read / write / content search) and the /filex/file media route (images /
47
+ * PDFs). Every route passes the same loopback browser-trust fence.
48
+ *
49
+ * All operations are conversation-scoped: requests carry a sessionId and the
50
+ * session's authoritative cwd comes from the session store.
51
+ */
52
+ /** Plugin identity for cordis.yml rows. */
53
+ const name = "dsh-file-explorer";
54
+ /** Services required before mounting: the webserver routes and the session store. */
55
+ const inject = ["webServer", "sessions"];
56
+ /** Media content types by extension. */
57
+ const MEDIA_TYPES = {
58
+ ".png": "image/png",
59
+ ".jpg": "image/jpeg",
60
+ ".jpeg": "image/jpeg",
61
+ ".gif": "image/gif",
62
+ ".webp": "image/webp",
63
+ ".svg": "image/svg+xml",
64
+ ".bmp": "image/bmp",
65
+ ".ico": "image/x-icon",
66
+ ".avif": "image/avif",
67
+ ".pdf": "application/pdf"
68
+ };
69
+ /** Directories never listed by the explorer / search. */
70
+ const IGNORED_DIRS = /* @__PURE__ */ new Set([
71
+ "node_modules",
72
+ ".git",
73
+ ".hg",
74
+ ".svn",
75
+ ".dsh",
76
+ ".next",
77
+ ".nuxt",
78
+ ".output",
79
+ "dist",
80
+ "build",
81
+ "out",
82
+ "coverage",
83
+ ".turbo",
84
+ ".cache",
85
+ ".yarn",
86
+ ".pnpm-store",
87
+ "target",
88
+ ".venv",
89
+ "venv",
90
+ "__pycache__",
91
+ ".pytest_cache",
92
+ ".mypy_cache",
93
+ ".idea",
94
+ ".vscode",
95
+ "release",
96
+ "vendor",
97
+ "bin",
98
+ "obj",
99
+ ".expo",
100
+ "Pods",
101
+ "DerivedData"
102
+ ]);
103
+ const MAX_FILES = 3e4;
104
+ const MAX_PREVIEW = 1048576;
105
+ const MAX_SEARCH_FILE = 1048576;
106
+ const MAX_SEARCH_MATCHES = 1e3;
107
+ const MAX_MEDIA = 31457280;
108
+ /** One wire failure carrying a stable code. */
109
+ var FilexError = class extends Error {
110
+ code;
111
+ status;
112
+ constructor(code, message, status = 400) {
113
+ super(message);
114
+ this.code = code;
115
+ this.status = status;
116
+ }
117
+ };
118
+ function requireString(payload, key) {
119
+ const value = payload[key];
120
+ if (typeof value !== "string" || value === "") throw new FilexError("bad-request", `missing or invalid "${key}"`);
121
+ return value;
122
+ }
123
+ /** Resolve a request path (absolute or cwd-relative) and fence it inside the session cwd. */
124
+ function resolvePathWithin(cwd, raw) {
125
+ const abs = isAbsolute(raw) ? resolve(raw) : resolve(join(cwd, raw));
126
+ if (!isWithin(cwd, abs)) throw new FilexError("forbidden", "path outside the session working directory", 403);
127
+ return abs;
128
+ }
129
+ /** Canonical containment check (case-aware on Windows, separator-normalized). */
130
+ function isWithin(parent, child) {
131
+ const p = resolve(parent);
132
+ const c = resolve(child);
133
+ if (c === p) return true;
134
+ return c.startsWith(p + "\\") || c.startsWith(p + "/") || c.startsWith(p + "\\") || c.startsWith(p + "/");
135
+ }
136
+ /** Resolve a session's authoritative working directory (header cwd first, process cwd last). */
137
+ function sessionCwdOf(ctx, sessionId) {
138
+ const headerCwd = ctx.sessions.get(sessionId)?.header.cwd;
139
+ if (headerCwd !== void 0 && headerCwd !== "") return resolve(headerCwd);
140
+ return process.cwd();
141
+ }
142
+ async function readJsonBody(req) {
143
+ let raw = "";
144
+ for await (const chunk of req) raw += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
145
+ if (raw === "") return {};
146
+ try {
147
+ const parsed = JSON.parse(raw);
148
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
149
+ } catch {}
150
+ throw new FilexError("bad-request", "invalid JSON body");
151
+ }
152
+ function writeJson(res, status, value) {
153
+ res.writeHead(status, { "content-type": "application/json" });
154
+ res.end(JSON.stringify(value));
155
+ }
156
+ function writeOk(res, value) {
157
+ writeJson(res, 200, {
158
+ ok: true,
159
+ value
160
+ });
161
+ }
162
+ function writeError(res, error) {
163
+ if (error instanceof FilexError) {
164
+ writeJson(res, error.status, {
165
+ ok: false,
166
+ error: {
167
+ code: error.code,
168
+ message: error.message
169
+ }
170
+ });
171
+ return;
172
+ }
173
+ writeJson(res, 500, {
174
+ ok: false,
175
+ error: {
176
+ code: "internal",
177
+ message: error instanceof Error ? error.message : String(error)
178
+ }
179
+ });
180
+ }
181
+ /**
182
+ * Recursively walk a directory tree, invoking onFile for every regular file.
183
+ * Skips IGNORED_DIRS; stops at MAX_FILES. The callback may return false to
184
+ * stop early (or a Promise resolving to false / void for async callbacks).
185
+ *
186
+ * `opts.withSize` gates the per-file stat: listing does not need sizes (the
187
+ * client never displays them), and skipping the stat keeps large-workspace
188
+ * listings fast (stat on tens of thousands of files is seconds on Windows).
189
+ * Content search still opts in, because it skips files bigger than the
190
+ * search cap.
191
+ */
192
+ async function walkFiles(cwd, onFile, opts = {}) {
193
+ const withSize = opts.withSize === true;
194
+ let count = 0;
195
+ let truncated = false;
196
+ const queue = [{
197
+ abs: cwd,
198
+ rel: ""
199
+ }];
200
+ while (queue.length > 0) {
201
+ if (truncated) break;
202
+ const cur = queue.pop();
203
+ let entries;
204
+ try {
205
+ entries = await readdir(cur.abs, { withFileTypes: true });
206
+ } catch {
207
+ continue;
208
+ }
209
+ for (let i = entries.length - 1; i >= 0; i--) {
210
+ if (truncated) break;
211
+ const entry = entries[i];
212
+ const childAbs = join(cur.abs, entry.name);
213
+ const childRel = cur.rel === "" ? entry.name : `${cur.rel}/${entry.name}`;
214
+ if (entry.isDirectory()) {
215
+ if (IGNORED_DIRS.has(entry.name)) continue;
216
+ queue.push({
217
+ abs: childAbs,
218
+ rel: childRel
219
+ });
220
+ } else if (entry.isFile()) {
221
+ count++;
222
+ let size = 0;
223
+ if (withSize) try {
224
+ size = (await stat(childAbs)).size;
225
+ } catch {
226
+ size = 0;
227
+ }
228
+ if (onFile(childAbs, childRel, size) === false) truncated = true;
229
+ else if (count >= MAX_FILES) truncated = true;
230
+ }
231
+ }
232
+ }
233
+ return truncated;
234
+ }
235
+ /** Text read with the size cap; binary detection via NUL probe. */
236
+ async function readTextFile(path) {
237
+ const info = await stat(path).catch(() => {
238
+ throw new FilexError("fs-error", `cannot read "${path}": not found`, 404);
239
+ });
240
+ if (info.isDirectory()) throw new FilexError("fs-error", `"${path}" is a directory`);
241
+ const size = info.size;
242
+ const truncated = size > MAX_PREVIEW;
243
+ const handle = await open(path, "r").catch((error) => {
244
+ throw new FilexError("fs-error", `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`);
245
+ });
246
+ try {
247
+ const length = Math.min(size, MAX_PREVIEW);
248
+ const buffer = Buffer.alloc(length);
249
+ const { bytesRead } = await handle.read(buffer, 0, length, 0);
250
+ const slice = buffer.subarray(0, bytesRead);
251
+ if (slice.includes(0)) return {
252
+ kind: "binary",
253
+ size,
254
+ truncated
255
+ };
256
+ return {
257
+ kind: "text",
258
+ content: slice.toString("utf8"),
259
+ size,
260
+ truncated
261
+ };
262
+ } finally {
263
+ await handle.close();
264
+ }
265
+ }
266
+ function escapeRegExp(s) {
267
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
268
+ }
269
+ function buildRegex(pattern, opts) {
270
+ const flags = opts.caseSensitive === true ? "g" : "gi";
271
+ let source = opts.regex === true ? pattern : escapeRegExp(pattern);
272
+ if (opts.wholeWord === true) source = `\\b(?:${source})\\b`;
273
+ return new RegExp(source, flags);
274
+ }
275
+ function globToRegExp(glob) {
276
+ let out = "";
277
+ for (let i = 0; i < glob.length; i++) {
278
+ const ch = glob[i];
279
+ if (ch === "*") {
280
+ if (glob[i + 1] === "*") {
281
+ out += ".*";
282
+ i++;
283
+ } else out += "[^/]*";
284
+ } else if (ch === "?") out += "[^/]";
285
+ else out += escapeRegExp(ch);
286
+ }
287
+ return new RegExp(`^${out}$`);
288
+ }
289
+ const GLOB_META = /[*?{\[]/;
290
+ function parseGlobList(raw) {
291
+ if (!Array.isArray(raw)) return [];
292
+ const out = [];
293
+ for (const item of raw) {
294
+ const token = String(item).trim();
295
+ if (token === "") continue;
296
+ out.push(globToRegExp(GLOB_META.test(token) ? token : `**/${token}/**`));
297
+ }
298
+ return out;
299
+ }
300
+ /**
301
+ * Launch the OS file manager pointing at `target` (a directory). Detached and
302
+ * stdio-ignored so the spawn never blocks the host or leaks pipes; failures
303
+ * are swallowed — the API reports unsupported platforms instead.
304
+ *
305
+ * NOTE: never pass `windowsHide: true` on Windows — explorer.exe honors the
306
+ * startup show-window flag and would open its folder window hidden, making
307
+ * the click look dead. The plain GUI-app spawn shows the window normally.
308
+ */
309
+ function openInSystemFileManager(target) {
310
+ let command;
311
+ let args;
312
+ if (process.platform === "win32") {
313
+ command = "explorer";
314
+ args = [target];
315
+ } else if (process.platform === "darwin") {
316
+ command = "open";
317
+ args = [target];
318
+ } else if (process.platform === "linux") {
319
+ command = "xdg-open";
320
+ args = [target];
321
+ } else return false;
322
+ try {
323
+ const child = spawn(command, args, {
324
+ detached: true,
325
+ stdio: "ignore"
326
+ });
327
+ child.on("error", () => {});
328
+ child.unref();
329
+ return true;
330
+ } catch {
331
+ return false;
332
+ }
333
+ }
334
+ /**
335
+ * Resolve the directory a reveal/open action should point at. Authoritative
336
+ * source: the session's own header cwd. When it is absent (sessions created
337
+ * without workspace metadata fall back to the host process cwd — usually
338
+ * meaningless), accept the loopback-only client's session-list cwd hint so
339
+ * the folder still opens where the user works.
340
+ */
341
+ function revealCwdOf(ctx, sessionId, rawHint) {
342
+ const headerCwd = ctx.sessions.get(sessionId)?.header.cwd;
343
+ const hint = typeof rawHint === "string" && rawHint !== "" ? rawHint : void 0;
344
+ return headerCwd !== void 0 && headerCwd !== "" ? resolve(headerCwd) : hint !== void 0 && isAbsolute(hint) ? resolve(hint) : process.cwd();
345
+ }
346
+ /**
347
+ * Locate the VS Code CLI (`code`). Checks the standard install locations
348
+ * first, then falls back to a PATH scan. Windows `code` is a .cmd shim, so
349
+ * the caller must route it through `cmd /c` (spawn alone cannot execute it).
350
+ */
351
+ async function resolveVscodeCli() {
352
+ const candidates = [];
353
+ if (process.platform === "win32") {
354
+ const local = process.env.LOCALAPPDATA;
355
+ if (local !== void 0) candidates.push(join(local, "Programs", "Microsoft VS Code", "bin", "code.cmd"));
356
+ const pf = process.env.ProgramFiles;
357
+ if (pf !== void 0) candidates.push(join(pf, "Microsoft VS Code", "bin", "code.cmd"));
358
+ const pf86 = process.env["ProgramFiles(x86)"];
359
+ if (pf86 !== void 0) candidates.push(join(pf86, "Microsoft VS Code", "bin", "code.cmd"));
360
+ } else if (process.platform === "darwin") candidates.push("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code");
361
+ else if (process.platform === "linux") candidates.push("/usr/bin/code", "/usr/local/bin/code", "/snap/bin/code");
362
+ for (const candidate of candidates) try {
363
+ await stat(candidate);
364
+ return candidate;
365
+ } catch {}
366
+ const name = process.platform === "win32" ? "code.cmd" : "code";
367
+ const paths = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
368
+ for (const dir of paths) {
369
+ if (dir === "") continue;
370
+ const probe = join(dir, name);
371
+ try {
372
+ await stat(probe);
373
+ return probe;
374
+ } catch {}
375
+ }
376
+ return null;
377
+ }
378
+ /**
379
+ * Launch VS Code pointed at `target` (a directory). On Windows the CLI is a
380
+ * .cmd shim, so it is routed through `cmd /c` with the console hidden (the
381
+ * hidden console belongs to cmd only — the Code GUI window is unaffected).
382
+ */
383
+ function openInVscode(cli, target) {
384
+ try {
385
+ const child = process.platform === "win32" ? spawn("cmd", [
386
+ "/c",
387
+ cli,
388
+ target
389
+ ], {
390
+ detached: true,
391
+ stdio: "ignore",
392
+ windowsHide: true
393
+ }) : spawn(cli, [target], {
394
+ detached: true,
395
+ stdio: "ignore"
396
+ });
397
+ child.on("error", () => {});
398
+ child.unref();
399
+ return true;
400
+ } catch {
401
+ return false;
402
+ }
403
+ }
404
+ function buildApi(ctx) {
405
+ const cwdOf = (payload) => {
406
+ const sessionId = requireString(payload, "sessionId");
407
+ return {
408
+ sessionId,
409
+ cwd: sessionCwdOf(ctx, sessionId)
410
+ };
411
+ };
412
+ return {
413
+ "session.cwd": (payload) => {
414
+ const { sessionId, cwd } = cwdOf(payload);
415
+ return {
416
+ sessionId,
417
+ cwd,
418
+ root: basename(cwd)
419
+ };
420
+ },
421
+ "fs.list": async (payload) => {
422
+ const { cwd } = cwdOf(payload);
423
+ const files = [];
424
+ const truncated = await walkFiles(cwd, (_abs, rel) => {
425
+ files.push({
426
+ name: rel.split("/").pop() ?? rel,
427
+ relPath: rel,
428
+ size: 0
429
+ });
430
+ return true;
431
+ });
432
+ files.sort((a, b) => a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0);
433
+ return {
434
+ root: cwd,
435
+ files,
436
+ truncated
437
+ };
438
+ },
439
+ "fs.read": async (payload) => {
440
+ const { cwd } = cwdOf(payload);
441
+ return readTextFile(resolvePathWithin(cwd, requireString(payload, "path")));
442
+ },
443
+ "fs.write": async (payload) => {
444
+ const { cwd } = cwdOf(payload);
445
+ const raw = requireString(payload, "path");
446
+ const content = requireString(payload, "content");
447
+ const path = resolvePathWithin(cwd, raw);
448
+ const tmp = `${path}.dsh-filex-tmp-${process.pid}`;
449
+ try {
450
+ await mkdir(dirname(path), { recursive: true });
451
+ await writeFile(tmp, content, "utf8");
452
+ await rename(tmp, path);
453
+ } catch (error) {
454
+ await rm(tmp, { force: true }).catch(() => {});
455
+ throw new FilexError("fs-error", `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`);
456
+ }
457
+ return { ok: true };
458
+ },
459
+ "fs.reveal": async (payload) => {
460
+ const { sessionId } = cwdOf(payload);
461
+ const cwd = revealCwdOf(ctx, sessionId, payload.cwd);
462
+ if (!(await stat(cwd).catch(() => {
463
+ throw new FilexError("fs-error", `cannot open "${cwd}": not found`, 404);
464
+ })).isDirectory()) throw new FilexError("fs-error", `"${cwd}" is not a directory`);
465
+ if (!openInSystemFileManager(cwd)) throw new FilexError("unsupported", "current platform has no file-manager launcher", 501);
466
+ return {
467
+ ok: true,
468
+ cwd
469
+ };
470
+ },
471
+ "fs.vscode": async (payload) => {
472
+ const { sessionId } = cwdOf(payload);
473
+ const cwd = revealCwdOf(ctx, sessionId, payload.cwd);
474
+ if (!(await stat(cwd).catch(() => {
475
+ throw new FilexError("fs-error", `cannot open "${cwd}": not found`, 404);
476
+ })).isDirectory()) throw new FilexError("fs-error", `"${cwd}" is not a directory`);
477
+ const cli = await resolveVscodeCli();
478
+ if (cli === null) throw new FilexError("vscode-not-found", "未找到 VS Code 命令行工具(code)。请确认 VS Code 已安装且勾选了“添加到 PATH”", 404);
479
+ if (!openInVscode(cli, cwd)) throw new FilexError("unsupported", "failed to launch the VS Code CLI", 501);
480
+ return {
481
+ ok: true,
482
+ cwd,
483
+ cli
484
+ };
485
+ },
486
+ "fs.capabilities": async () => {
487
+ return { vscode: await resolveVscodeCli() !== null };
488
+ },
489
+ "fs.search": async (payload) => {
490
+ const { cwd } = cwdOf(payload);
491
+ const pattern = requireString(payload, "pattern").trim();
492
+ if (pattern === "") return {
493
+ results: [],
494
+ truncated: false
495
+ };
496
+ const record = payload;
497
+ const opts = typeof record.options === "object" && record.options !== null ? record.options : {};
498
+ let re;
499
+ try {
500
+ re = buildRegex(pattern, opts);
501
+ } catch (error) {
502
+ throw new FilexError("bad-request", `invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
503
+ }
504
+ const include = parseGlobList(record.include);
505
+ const exclude = parseGlobList(record.exclude);
506
+ const results = [];
507
+ let truncated = false;
508
+ await walkFiles(cwd, async (abs, rel, size) => {
509
+ if (include.length > 0 && !include.some((rx) => rx.test(rel))) return true;
510
+ if (exclude.some((rx) => rx.test(rel))) return true;
511
+ if (size > MAX_SEARCH_FILE) return true;
512
+ let text;
513
+ try {
514
+ const result = await readTextFile(abs);
515
+ if (result.kind !== "text") return true;
516
+ text = result.content;
517
+ } catch {
518
+ return true;
519
+ }
520
+ const lines = text.split("\n");
521
+ for (let i = 0; i < lines.length; i++) {
522
+ re.lastIndex = 0;
523
+ const highlights = [];
524
+ let match;
525
+ while ((match = re.exec(lines[i])) !== null) {
526
+ highlights.push({
527
+ start: match.index,
528
+ end: match.index + match[0].length
529
+ });
530
+ if (match[0].length === 0) re.lastIndex++;
531
+ if (highlights.length >= 200) break;
532
+ }
533
+ if (highlights.length > 0) {
534
+ results.push({
535
+ file: rel,
536
+ line: i + 1,
537
+ text: lines[i],
538
+ highlights
539
+ });
540
+ if (results.length >= MAX_SEARCH_MATCHES) {
541
+ truncated = true;
542
+ return false;
543
+ }
544
+ }
545
+ }
546
+ return true;
547
+ }, { withSize: true });
548
+ return {
549
+ results,
550
+ truncated
551
+ };
552
+ }
553
+ };
554
+ }
555
+ function apply(ctx) {
556
+ const fence = (req) => isTrustedApiRequest(req);
557
+ const api = buildApi(ctx);
558
+ ctx.effect(() => ctx.webServer.register({
559
+ kind: "prefix",
560
+ path: "/filex/api",
561
+ handler: async (req, res) => {
562
+ if (!fence(req)) {
563
+ writeJson(res, 403, {
564
+ ok: false,
565
+ error: {
566
+ code: "forbidden",
567
+ message: "forbidden"
568
+ }
569
+ });
570
+ return;
571
+ }
572
+ if (req.method !== "POST") {
573
+ writeJson(res, 405, {
574
+ ok: false,
575
+ error: {
576
+ code: "method-error",
577
+ message: "method not allowed"
578
+ }
579
+ });
580
+ return;
581
+ }
582
+ const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
583
+ const method = pathname.startsWith("/filex/api/") ? pathname.slice(11) : void 0;
584
+ if (method === void 0 || method.includes("/")) {
585
+ writeError(res, new FilexError("not-found", "unknown filex API method", 404));
586
+ return;
587
+ }
588
+ try {
589
+ const payload = await readJsonBody(req);
590
+ const handler = api[method];
591
+ if (handler === void 0) throw new FilexError("not-found", `unknown filex API method "${method}"`, 404);
592
+ writeOk(res, await handler(payload));
593
+ } catch (error) {
594
+ writeError(res, error);
595
+ }
596
+ }
597
+ }), "dsh-file-explorer: /filex/api routes");
598
+ ctx.effect(() => ctx.webServer.register({
599
+ kind: "prefix",
600
+ path: "/filex/file",
601
+ handler: async (req, res) => {
602
+ if (!fence(req)) {
603
+ res.writeHead(403);
604
+ res.end("forbidden");
605
+ return;
606
+ }
607
+ if (req.method !== "GET") {
608
+ res.writeHead(405);
609
+ res.end();
610
+ return;
611
+ }
612
+ try {
613
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
614
+ const sessionId = url.searchParams.get("sessionId");
615
+ const raw = url.searchParams.get("path");
616
+ if (sessionId === null || raw === null) throw new FilexError("bad-request", "sessionId and path are required");
617
+ const path = resolvePathWithin(sessionCwdOf(ctx, sessionId), raw);
618
+ const info = await stat(path);
619
+ if (!info.isFile() || info.size > MAX_MEDIA) throw new FilexError("fs-error", "not a file or too large");
620
+ const lower = path.toLowerCase();
621
+ const type = Object.keys(MEDIA_TYPES).find((ext) => lower.endsWith(ext));
622
+ const headers = {
623
+ "content-type": type ? MEDIA_TYPES[type] : "application/octet-stream",
624
+ "cache-control": "no-cache",
625
+ "x-content-type-options": "nosniff",
626
+ "referrer-policy": "no-referrer"
627
+ };
628
+ if (url.searchParams.get("download") === "1") headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`;
629
+ const body = await readFile(path);
630
+ res.writeHead(200, headers);
631
+ res.end(body);
632
+ } catch (error) {
633
+ writeError(res, error);
634
+ }
635
+ }
636
+ }), "dsh-file-explorer: /filex/file media route");
637
+ }
638
+ //#endregion
639
+ export { apply, inject, isWithin, name };