@deckbuilderkit/server-mods 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,977 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import type { Dirent } from 'node:fs';
3
+ import { chmod, cp, lstat, mkdir, readdir, readlink, realpath, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, extname, isAbsolute, join, resolve, sep } from 'node:path';
5
+ import { moveToTrash, renameWithRetry, type SaveDialogOptions, saveFileAs, writeFileAtomic } from '@deckbuilderkit/platform';
6
+ import { AppError, forbidden, type Handler, invalid, ndjson, notFound, type Routes, socketTickets, type TailChunk } from '@deckbuilderkit/server-core';
7
+ import type { FsCompressResult, FsDuProgress, FsExtractResult, FsHash, FsInfo } from '@deckbuilderkit/server-core/protocol';
8
+ import { compressArchive, extractArchive, listArchive } from './archive.ts';
9
+ import { isBinary } from './bytes.ts';
10
+ import { decodeText, detectEncoding, encodeText, encodingByBom, TEXT_ENCODINGS, type TextEncodingId } from './encoding.ts';
11
+ import { fromPlatform } from './platform-errors.ts';
12
+ import { readSqlite } from './sqlite.ts';
13
+
14
+ export type { TailChunk };
15
+
16
+ /** How often `du` reports its running totals. */
17
+ const DU_PROGRESS_MS = 150;
18
+
19
+ /** A folder the app registered; every file route is confined to one. */
20
+ export interface FsRoot {
21
+ id: string;
22
+ /** Absolute path. */
23
+ path: string;
24
+ /** Refuses every write route. */
25
+ readOnly?: boolean;
26
+ }
27
+
28
+ export interface RootedFsOptions {
29
+ /** The registered roots, normally from the app's projects record store. */
30
+ roots: () => Promise<FsRoot[]>;
31
+ /** Most bytes `read` returns; a longer file comes back `truncated`. */
32
+ maxReadBytes?: number;
33
+ /** Moves absolute paths to the recycle bin: `moveToTrash` from platform, a fake in tests. */
34
+ trash?: (paths: string[]) => Promise<void>;
35
+ /** False: the delete route takes one path only. Default true. */
36
+ multiDelete?: boolean;
37
+ /** The save dialog: `saveFileAs` from platform, a fake in tests. */
38
+ saveAs?: (opts: SaveDialogOptions) => Promise<string | null>;
39
+ }
40
+
41
+ export interface Resolved {
42
+ root: FsRoot;
43
+ /** The real root joined with the relative path, links not followed. */
44
+ abs: string;
45
+ /** `abs` with every link followed; inside the real root. */
46
+ real: string;
47
+ }
48
+
49
+ export interface FsEntry {
50
+ name: string;
51
+ kind: 'file' | 'dir' | 'link';
52
+ /** Bytes; 0 for folders. */
53
+ size: number;
54
+ /** Epoch ms, rounded. */
55
+ mtime: number;
56
+ /** A link whose target is outside the root (or missing); every route but list refuses it. */
57
+ outside?: true;
58
+ /** What a link inside the root points to. */
59
+ target?: 'file' | 'dir';
60
+ /** Matches an exclude glob; set only when the list asked for all entries. */
61
+ excluded?: true;
62
+ }
63
+
64
+ export const LIST_SORTS = ['default', 'mixed', 'filesFirst', 'type', 'modified'] as const;
65
+ export type ListSort = (typeof LIST_SORTS)[number];
66
+
67
+ /** The `exclude` query value (a JSON array of globs) as matchers; anything malformed gives none. */
68
+ export function parseExclude(raw: string | null): Bun.Glob[] {
69
+ if (!raw) return [];
70
+ try {
71
+ const list: unknown = JSON.parse(raw);
72
+ if (!Array.isArray(list)) return [];
73
+ return list.filter((g): g is string => typeof g === 'string' && g !== '' && g.length <= 200).slice(0, 100).map((g) => new Bun.Glob(g));
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ const extOf = (name: string) => {
80
+ const i = name.lastIndexOf('.');
81
+ return i > 0 ? name.slice(i + 1).toLowerCase() : '';
82
+ };
83
+
84
+ /** Orders the entries of one folder like VS Code's `explorer.sortOrder`; names compare with the collator. */
85
+ export function sortEntries(entries: FsEntry[], sort: ListSort): void {
86
+ const isDir = (e: FsEntry) => e.kind === 'dir' || e.target === 'dir';
87
+ const byName = (a: FsEntry, b: FsEntry) => collator.compare(a.name, b.name);
88
+ const dirsFirst = (a: FsEntry, b: FsEntry) => Number(isDir(b)) - Number(isDir(a));
89
+ switch (sort) {
90
+ case 'mixed':
91
+ entries.sort(byName);
92
+ break;
93
+ case 'filesFirst':
94
+ entries.sort((a, b) => -dirsFirst(a, b) || byName(a, b));
95
+ break;
96
+ case 'type':
97
+ entries.sort((a, b) => dirsFirst(a, b) || (isDir(a) ? 0 : collator.compare(extOf(a.name), extOf(b.name))) || byName(a, b));
98
+ break;
99
+ case 'modified':
100
+ entries.sort((a, b) => dirsFirst(a, b) || b.mtime - a.mtime || byName(a, b));
101
+ break;
102
+ default:
103
+ entries.sort((a, b) => dirsFirst(a, b) || byName(a, b));
104
+ }
105
+ }
106
+
107
+ export interface SearchHit {
108
+ /** Relative to the root, `/`-separated. */
109
+ path: string;
110
+ kind: 'file' | 'dir';
111
+ /** 1-based; set on content hits. */
112
+ line?: number;
113
+ text?: string;
114
+ }
115
+
116
+ export interface RootedFs {
117
+ routes: Routes;
118
+ /** Routes outside the session token check (`unauthenticated` of the server): `GET /api/fs/download` takes the one-time ticket from `POST /api/fs/download-ticket`; `GET /api/fs/view/<id>/*` serves the files around an HTML page for `POST /api/fs/view-ticket`. */
119
+ openRoutes: Routes;
120
+ resolve(rootId: string, rel: string, opts?: { mustExist?: boolean }): Promise<Resolved>;
121
+ }
122
+
123
+ export const badPath = () => invalid('badPath', 'Invalid path.');
124
+ const badRequest = () => invalid('badRequest', 'Invalid request.');
125
+ const MAX_PATHS = 1000;
126
+ const outsideRoot = () => forbidden('outsideRoot', 'This path leads outside the project folder.');
127
+ const fileNotFound = () => notFound('fileNotFound', 'File or folder not found.');
128
+ const exists = () => invalid('exists', 'A file or folder with this name already exists.');
129
+ const notADir = () => invalid('notADir', 'This is not a folder.');
130
+ const notAFile = () => invalid('notAFile', 'This is not a file.');
131
+
132
+ const DEVICE = /^(con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])$/i;
133
+ const WIN_RESERVED = /[<>"|?*\u0000-\u001f]/;
134
+
135
+ /**
136
+ * True when `segment` can't be one name in a path: `..`, an alternate data stream (`:`), a
137
+ * trailing dot or space (Windows drops them, so two names would open one file), a Windows device
138
+ * name with or without extension, or a character Windows forbids.
139
+ */
140
+ function badSegment(segment: string): boolean {
141
+ if (segment === '..' || segment.includes(':') || segment.includes('\0') || /[. ]$/.test(segment)) return true;
142
+ if (DEVICE.test(segment.split('.')[0]!.trimEnd())) return true;
143
+ return process.platform === 'win32' && WIN_RESERVED.test(segment);
144
+ }
145
+
146
+ /** The segments of a client path: relative, split on `/` and `\`, `.` and empty segments dropped. */
147
+ export function segmentsOf(rel: unknown): string[] {
148
+ if (typeof rel !== 'string' || rel.includes('\0') || /^[\\/]/.test(rel) || /^[a-zA-Z]:/.test(rel)) throw badPath();
149
+ const segments = rel.split(/[\\/]/).filter((s) => s !== '' && s !== '.');
150
+ if (segments.some(badSegment)) throw badPath();
151
+ return segments;
152
+ }
153
+
154
+ const validName = (name: unknown): name is string => typeof name === 'string' && !/[\\/]/.test(name) && segmentsOf(name).length === 1;
155
+
156
+ const fold = process.platform === 'win32' || process.platform === 'darwin' ? (p: string) => p.toLowerCase() : (p: string) => p;
157
+
158
+ /** True when the real path `path` is `realRoot` or below it. */
159
+ function inside(path: string, realRoot: string): boolean {
160
+ const p = fold(path);
161
+ const base = fold(realRoot);
162
+ return p === base || p.startsWith(base.endsWith(sep) ? base : base + sep);
163
+ }
164
+
165
+ const errno = (err: unknown) => (err as NodeJS.ErrnoException)?.code;
166
+ const isMissing = (err: unknown) => errno(err) === 'ENOENT' || errno(err) === 'ENOTDIR';
167
+
168
+ /**
169
+ * `realRoot` joined with `segments`, every existing link followed: the realpath of the nearest
170
+ * existing ancestor plus the segments below it.
171
+ */
172
+ async function realOf(realRoot: string, segments: string[], mustExist: boolean): Promise<string> {
173
+ for (let n = segments.length; n > 0; n--) {
174
+ const p = join(realRoot, ...segments.slice(0, n));
175
+ try {
176
+ return join(await realpath(p), ...segments.slice(n));
177
+ } catch (err) {
178
+ if (errno(err) === 'ELOOP') throw outsideRoot();
179
+ if (!isMissing(err)) throw err;
180
+ // A link whose target is missing exists, but where it points can't be checked.
181
+ if (await present(p)) throw outsideRoot();
182
+ if (mustExist) throw fileNotFound();
183
+ }
184
+ }
185
+ return join(realRoot, ...segments);
186
+ }
187
+
188
+ /** The owner may write the file; on Windows false means the read-only attribute is set. */
189
+ const ownerWritable = (mode: number) => (mode & 0o200) !== 0;
190
+
191
+ /** Node fs errors as the AppErrors the UI translates; anything else unchanged. */
192
+ function fsError(err: unknown): unknown {
193
+ if (err instanceof AppError) return err;
194
+ switch (errno(err)) {
195
+ case 'ENOENT':
196
+ return fileNotFound();
197
+ case 'EEXIST':
198
+ case 'ENOTEMPTY':
199
+ return exists();
200
+ case 'ENOTDIR':
201
+ return notADir();
202
+ case 'EISDIR':
203
+ return notAFile();
204
+ case 'EXDEV':
205
+ return invalid('crossDevice', "Can't move between drives.");
206
+ case 'EACCES':
207
+ case 'EPERM':
208
+ case 'EBUSY':
209
+ return forbidden('accessDenied', 'Access denied, or the file is in use.');
210
+ }
211
+ return err;
212
+ }
213
+
214
+ /** `value` as an integer in `[min, max]`; undefined when absent, `badRequest` otherwise. */
215
+ function optionalInt(value: string | null, min: number, max: number): number | undefined {
216
+ if (value === null) return undefined;
217
+ const n = Number(value);
218
+ if (value.trim() === '' || !Number.isSafeInteger(n) || n < min || n > max) throw badRequest();
219
+ return n;
220
+ }
221
+
222
+ const present = (path: string) => lstat(path).then(() => true, () => false);
223
+
224
+ /** `wanted`, or the first free `name copy.ext`, `name copy 2.ext`, ... next to it. */
225
+ async function freeName(wanted: string, isDir: boolean): Promise<string> {
226
+ const ext = isDir ? '' : extname(wanted);
227
+ const stem = basename(wanted, ext);
228
+ let to = wanted;
229
+ for (let n = 1; await present(to); n++) to = join(dirname(wanted), `${stem} copy${n > 1 ? ` ${n}` : ''}${ext}`);
230
+ return to;
231
+ }
232
+
233
+ /** True when an entry below `dir` (a real path) resolves outside it: a link out, or a dangling link aiming out. */
234
+ async function escapes(dir: string): Promise<boolean> {
235
+ for (const e of await readdir(dir, { recursive: true, withFileTypes: true })) {
236
+ const p = join(e.parentPath, e.name);
237
+ let real = await realpath(p).catch(() => null);
238
+ if (real === null) {
239
+ if (!e.isSymbolicLink()) return true;
240
+ real = resolve(dirname(p), await readlink(p));
241
+ }
242
+ if (!inside(real, dir)) return true;
243
+ }
244
+ return false;
245
+ }
246
+
247
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
248
+
249
+ const MAX_RAW_BYTES = 50_000_000;
250
+ const MAX_TAIL_BYTES = 20_000_000;
251
+ const DEFAULT_TAIL_BYTES = 1_000_000;
252
+ const MAX_SAVE_RANGE_BYTES = 200_000_000;
253
+ const MAX_UPLOAD_BYTES = 200_000_000;
254
+ const MAX_SEARCH_HITS = 5000;
255
+ const MAX_NAME_HITS = 200_000;
256
+ const MAX_CONTENT_BYTES = 1_000_000;
257
+ const MAX_HITS_PER_FILE = 20;
258
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
259
+
260
+ const VIEW_TTL_MS = 60 * 60_000;
261
+ const VIEW_TYPES: Record<string, string> = {
262
+ '.html': 'text/html; charset=utf-8',
263
+ '.htm': 'text/html; charset=utf-8',
264
+ '.xhtml': 'text/html; charset=utf-8',
265
+ '.css': 'text/css; charset=utf-8',
266
+ '.js': 'text/javascript; charset=utf-8',
267
+ '.mjs': 'text/javascript; charset=utf-8',
268
+ '.json': 'application/json; charset=utf-8',
269
+ };
270
+
271
+ const TEXT = 'text/plain; charset=utf-8';
272
+ const RAW_TYPES: Record<string, string> = {
273
+ '.png': 'image/png',
274
+ '.jpg': 'image/jpeg',
275
+ '.jpeg': 'image/jpeg',
276
+ '.gif': 'image/gif',
277
+ '.webp': 'image/webp',
278
+ '.avif': 'image/avif',
279
+ '.bmp': 'image/bmp',
280
+ '.ico': 'image/x-icon',
281
+ '.svg': 'image/svg+xml',
282
+ '.pdf': 'application/pdf',
283
+ '.mp4': 'video/mp4',
284
+ '.m4v': 'video/mp4',
285
+ '.webm': 'video/webm',
286
+ '.mov': 'video/quicktime',
287
+ '.mkv': 'video/x-matroska',
288
+ '.avi': 'video/x-msvideo',
289
+ '.mp3': 'audio/mpeg',
290
+ '.wav': 'audio/wav',
291
+ '.flac': 'audio/flac',
292
+ '.ogg': 'audio/ogg',
293
+ '.opus': 'audio/ogg',
294
+ '.m4a': 'audio/mp4',
295
+ '.aac': 'audio/aac',
296
+ '.txt': TEXT,
297
+ '.md': TEXT,
298
+ '.log': TEXT,
299
+ '.csv': TEXT,
300
+ '.json': TEXT,
301
+ };
302
+
303
+ /**
304
+ * Walks the tree below `start` breadth-first and yields name hits (case-insensitive substring of
305
+ * `q`, and `glob` on the name when given) and, with `content`, lines containing `q` in text files
306
+ * up to 1 MB (`glob` then picks the files). Skips `node_modules`, `.git` and links; returns as soon
307
+ * as `signal` aborts. Exported for the tests.
308
+ */
309
+ export async function* searchTree(
310
+ start: { abs: string; rel: string },
311
+ query: { q: string; content: boolean; glob: Bun.Glob | null; exclude?: Bun.Glob[] },
312
+ signal: AbortSignal,
313
+ ): AsyncGenerator<SearchHit> {
314
+ const q = query.q.toLowerCase();
315
+ const nameMatches = (name: string) => (q === '' || name.toLowerCase().includes(q)) && (!query.glob || query.glob.match(name));
316
+ const queue = [start];
317
+ while (queue.length && !signal.aborted) {
318
+ const dir = queue.shift()!;
319
+ let dirents: Dirent[];
320
+ try {
321
+ dirents = await readdir(dir.abs, { withFileTypes: true });
322
+ } catch {
323
+ continue;
324
+ }
325
+ dirents.sort((a, b) => collator.compare(a.name, b.name));
326
+ for (const d of dirents) {
327
+ if (signal.aborted) return;
328
+ if (d.isSymbolicLink() || SKIP_DIRS.has(d.name) || !(d.isDirectory() || d.isFile())) continue;
329
+ const rel = dir.rel ? `${dir.rel}/${d.name}` : d.name;
330
+ if (query.exclude?.some((g) => g.match(rel))) continue;
331
+ const abs = join(dir.abs, d.name);
332
+ if (nameMatches(d.name)) yield { path: rel, kind: d.isDirectory() ? 'dir' : 'file' };
333
+ if (d.isDirectory()) queue.push({ abs, rel });
334
+ else if (query.content && q !== '' && (!query.glob || query.glob.match(d.name))) {
335
+ for (const hit of await contentHits(abs, q)) {
336
+ if (signal.aborted) return;
337
+ yield { path: rel, kind: 'file', ...hit };
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ /** Up to MAX_HITS_PER_FILE lines of a text file containing `q` (lower-cased); [] for big, binary or unreadable files. */
345
+ async function contentHits(file: string, q: string): Promise<Array<{ line: number; text: string }>> {
346
+ try {
347
+ if ((await stat(file)).size > MAX_CONTENT_BYTES) return [];
348
+ const bytes = await Bun.file(file).bytes();
349
+ if (isBinary(bytes)) return [];
350
+ const hits: Array<{ line: number; text: string }> = [];
351
+ const lines = new TextDecoder().decode(bytes).split(/\r?\n/);
352
+ for (let i = 0; i < lines.length && hits.length < MAX_HITS_PER_FILE; i++) {
353
+ const at = lines[i]!.toLowerCase().indexOf(q);
354
+ if (at < 0) continue;
355
+ // Long lines (minified code) are cut to a window around the match.
356
+ const from = Math.max(0, at - 100);
357
+ hits.push({ line: i + 1, text: lines[i]!.slice(from, from + 300).trim() });
358
+ }
359
+ return hits;
360
+ } catch {
361
+ return [];
362
+ }
363
+ }
364
+
365
+ /**
366
+ * File routes confined to the roots the app registers. The client only ever sends a root id and a
367
+ * relative path; `resolve` turns them into a real path inside that root or refuses.
368
+ *
369
+ * GET `/api/fs/list|stat|info|du|hash|read|raw|archive|search|names` take `root` and `path` in the query; POST
370
+ * `/api/fs/create|write|extract|compress|rename|move|delete` take them in the JSON body. POST `/api/fs/upload` takes them
371
+ * in the query and the file's bytes as the body.
372
+ */
373
+ export function rootedFs(opts: RootedFsOptions): RootedFs {
374
+ const maxReadBytes = opts.maxReadBytes ?? 5_000_000;
375
+ const trash = opts.trash ?? moveToTrash;
376
+ const saveAs = opts.saveAs ?? saveFileAs;
377
+ // The OS fetches a dragged-out file with no headers, so the URL carries a ticket instead of the session token.
378
+ const downloads = socketTickets<{ file: string; name: string }>({ ttlMs: 60_000 });
379
+
380
+ /** Open HTML pages by id: the page's project and folder, valid for an hour. */
381
+ const views = new Map<string, { root: string; dir: string[]; expires: number }>();
382
+
383
+ async function locate(rootId: unknown, rel: unknown, mustExist = false) {
384
+ const root = typeof rootId === 'string' ? (await opts.roots()).find((r) => r.id === rootId) : undefined;
385
+ if (!root || !isAbsolute(root.path)) throw notFound('rootNotFound', 'This folder is no longer registered or no longer exists.');
386
+ const segments = segmentsOf(rel);
387
+ // A root that is itself a link or junction is fine: its target is the boundary.
388
+ const realRoot = await realpath(root.path).catch(() => {
389
+ throw notFound('rootNotFound', 'This folder is no longer registered or no longer exists.');
390
+ });
391
+ const abs = join(realRoot, ...segments);
392
+ const real = await realOf(realRoot, segments, mustExist);
393
+ if (!inside(real, realRoot)) throw outsideRoot();
394
+ return { root, realRoot, segments, abs, real };
395
+ }
396
+
397
+ type Located = Awaited<ReturnType<typeof locate>>;
398
+
399
+ /**
400
+ * The entry `loc` names itself, in its real parent folder: a link, not its target, so renaming
401
+ * or deleting a link never touches what it points to. The parent must be inside the root too.
402
+ */
403
+ async function entryPath(loc: Located): Promise<string> {
404
+ if (!loc.segments.length) throw badPath();
405
+ const parent = await realOf(loc.realRoot, loc.segments.slice(0, -1), false);
406
+ if (!inside(parent, loc.realRoot)) throw outsideRoot();
407
+ return join(parent, loc.segments.at(-1)!);
408
+ }
409
+
410
+ const writable = (root: FsRoot) => {
411
+ if (root.readOnly) throw forbidden('rootReadOnly', 'This folder is read-only in the app.');
412
+ };
413
+
414
+ /** The entry at `path` (a link stays a link; one pointing outside `realRoot` is marked); null when it's gone. */
415
+ async function describe(path: string, name: string, realRoot: string): Promise<FsEntry | null> {
416
+ const st = await lstat(path).catch(() => null);
417
+ if (!st) return null;
418
+ if (!st.isSymbolicLink()) {
419
+ const dir = st.isDirectory();
420
+ return { name, kind: dir ? 'dir' : 'file', size: dir ? 0 : st.size, mtime: Math.round(st.mtimeMs) };
421
+ }
422
+ const target = await realpath(path).catch(() => null);
423
+ const tst = target && inside(target, realRoot) ? await stat(target).catch(() => null) : null;
424
+ if (!tst) return { name, kind: 'link', size: 0, mtime: Math.round(st.mtimeMs), outside: true };
425
+ const dir = tst.isDirectory();
426
+ return { name, kind: 'link', size: dir ? 0 : tst.size, mtime: Math.round(tst.mtimeMs), target: dir ? 'dir' : 'file' };
427
+ }
428
+
429
+ async function described(path: string, name: string, realRoot: string): Promise<FsEntry> {
430
+ const entry = await describe(path, name, realRoot);
431
+ if (!entry) throw fileNotFound();
432
+ return entry;
433
+ }
434
+
435
+ /** Refuses `to` when another entry has that name; a case-only rename of `from` itself is fine. */
436
+ async function refuseExisting(from: string, to: string): Promise<void> {
437
+ if (fold(from) !== fold(to) && (await present(to))) throw exists();
438
+ }
439
+
440
+ const fromQuery = (query: URLSearchParams) => ({ root: query.get('root'), path: query.get('path') ?? '' });
441
+ const body = async (json: () => Promise<unknown>) => (await json()) as Record<string, unknown>;
442
+
443
+ // Write routes resolve right before acting and act on the real path. A link swapped in between
444
+ // (by another program writing in the root) can still redirect one operation: that check-then-use
445
+ // window is narrowed, not closed; closing it needs handle-relative calls Node doesn't offer.
446
+ const routes: Routes = {
447
+ 'GET /api/fs/list': async ({ query }) => {
448
+ const { root, path } = fromQuery(query);
449
+ const loc = await locate(root, path, true);
450
+ if (!(await stat(loc.real)).isDirectory()) throw notADir();
451
+ const names = await readdir(loc.real);
452
+ let entries = (await Promise.all(names.map((name) => describe(join(loc.real, name), name, loc.realRoot)))).filter(
453
+ (e): e is FsEntry => e !== null,
454
+ );
455
+ const exclude = parseExclude(query.get('exclude'));
456
+ if (exclude.length) {
457
+ const prefix = loc.segments.length ? `${loc.segments.join('/')}/` : '';
458
+ const hit = (e: FsEntry) => exclude.some((g) => g.match(prefix + e.name));
459
+ if (query.get('all') === '1') for (const e of entries) hit(e) && (e.excluded = true);
460
+ else entries = entries.filter((e) => !hit(e));
461
+ }
462
+ const sort = query.get('sort');
463
+ sortEntries(entries, LIST_SORTS.includes(sort as ListSort) ? (sort as ListSort) : 'default');
464
+ return { entries };
465
+ },
466
+
467
+ 'GET /api/fs/stat': async ({ query }) => {
468
+ const { root, path } = fromQuery(query);
469
+ const loc = await locate(root, path, true);
470
+ return described(loc.segments.length ? await entryPath(loc) : loc.realRoot, basename(loc.abs), loc.realRoot);
471
+ },
472
+
473
+ 'GET /api/fs/info': async ({ query }): Promise<FsInfo> => {
474
+ const { root, path } = fromQuery(query);
475
+ const loc = await locate(root, path, true);
476
+ const entry = loc.segments.length ? await entryPath(loc) : loc.realRoot;
477
+ const st = await lstat(entry).catch(() => {
478
+ throw fileNotFound();
479
+ });
480
+ const born = Math.round(st.birthtimeMs);
481
+ return {
482
+ name: basename(loc.abs),
483
+ fullPath: entry,
484
+ type: st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'dir' : 'file',
485
+ size: st.isDirectory() ? 0 : st.size,
486
+ created: Number.isFinite(born) && born > 0 ? born : null,
487
+ modified: Math.round(st.mtimeMs),
488
+ accessed: Math.round(st.atimeMs),
489
+ readOnly: !!loc.root.readOnly || !ownerWritable(st.mode),
490
+ };
491
+ },
492
+
493
+ // Total size of a folder: running totals, then `done`. Links are counted as neither and not followed.
494
+ 'GET /api/fs/du': async ({ req, query }) => {
495
+ const { root, path } = fromQuery(query);
496
+ const loc = await locate(root, path, true);
497
+ return ndjson(req, async (emit, signal) => {
498
+ const totals: FsDuProgress = { files: 0, dirs: 0, bytes: 0 };
499
+ const top = await stat(loc.real);
500
+ if (!top.isDirectory()) {
501
+ totals.files = 1;
502
+ totals.bytes = top.size;
503
+ }
504
+ const queue = top.isDirectory() ? [loc.real] : [];
505
+ let last = Date.now();
506
+ while (queue.length && !signal.aborted) {
507
+ const dir = queue.pop()!;
508
+ const items = await readdir(dir, { withFileTypes: true }).catch(() => [] as Dirent[]);
509
+ for (const d of items) {
510
+ if (signal.aborted) return;
511
+ if (d.isSymbolicLink()) continue;
512
+ const full = join(dir, d.name);
513
+ if (d.isDirectory()) {
514
+ totals.dirs++;
515
+ queue.push(full);
516
+ } else if (d.isFile()) {
517
+ const st = await lstat(full).catch(() => null);
518
+ if (!st) continue;
519
+ totals.files++;
520
+ totals.bytes += st.size;
521
+ }
522
+ }
523
+ if (Date.now() - last >= DU_PROGRESS_MS) {
524
+ last = Date.now();
525
+ await emit({ ...totals });
526
+ }
527
+ }
528
+ if (!signal.aborted) await emit({ ...totals, done: true });
529
+ });
530
+ },
531
+
532
+ 'GET /api/fs/hash': async ({ req, query }): Promise<FsHash> => {
533
+ const { root, path } = fromQuery(query);
534
+ const loc = await locate(root, path, true);
535
+ if ((await stat(loc.real)).isDirectory()) throw notAFile();
536
+ const hasher = new Bun.CryptoHasher('sha256');
537
+ for await (const chunk of Bun.file(loc.real).stream()) {
538
+ if (req.signal.aborted) throw invalid('aborted', 'The request was cancelled.');
539
+ hasher.update(chunk);
540
+ }
541
+ return { sha256: hasher.digest('hex') };
542
+ },
543
+
544
+ 'GET /api/fs/read': async ({ query }) => {
545
+ const { root, path } = fromQuery(query);
546
+ const loc = await locate(root, path, true);
547
+ const st = await stat(loc.real);
548
+ if (st.isDirectory()) throw notAFile();
549
+ const bytes = new Uint8Array(await Bun.file(loc.real).slice(0, maxReadBytes).arrayBuffer());
550
+ const mtime = Math.round(st.mtimeMs);
551
+ // UTF-16 is full of NUL bytes: its BOM is checked before the binary test.
552
+ if (!encodingByBom(bytes) && isBinary(bytes)) return { binary: true, size: st.size, mtime };
553
+ const truncated = st.size > maxReadBytes;
554
+ const encoding = detectEncoding(bytes, truncated);
555
+ const text = { text: decodeText(bytes, encoding), encoding, size: st.size, mtime, truncated };
556
+ return loc.root.readOnly || !ownerWritable(st.mode) ? { ...text, readOnly: true } : text;
557
+ },
558
+
559
+ 'GET /api/fs/tail': async ({ query }) => {
560
+ const { root, path } = fromQuery(query);
561
+ const from = optionalInt(query.get('from'), 0, Number.MAX_SAFE_INTEGER);
562
+ const max = optionalInt(query.get('max'), -Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER) ?? DEFAULT_TAIL_BYTES;
563
+ const window = Math.min(Math.max(max, 1), MAX_TAIL_BYTES);
564
+ const loc = await locate(root, path, true);
565
+ const st = await stat(loc.real);
566
+ if (st.isDirectory()) throw notAFile();
567
+ const { size } = st;
568
+ const mtime = Math.round(st.mtimeMs);
569
+ if (from !== undefined && from > size) {
570
+ const reset: TailChunk = { reset: true, from: size, to: size, size, mtime, text: '' };
571
+ return reset;
572
+ }
573
+ const file = Bun.file(loc.real);
574
+ let start = from ?? Math.max(0, size - window);
575
+ let bytes = new Uint8Array(await file.slice(start, Math.min(size, start + window)).arrayBuffer());
576
+ if (isBinary(bytes)) throw invalid('notText', 'This file is not a text file.');
577
+ if (from === undefined && start > 0) {
578
+ const cut = bytes.indexOf(10);
579
+ if (cut >= 0) {
580
+ start += cut + 1;
581
+ bytes = bytes.subarray(cut + 1);
582
+ }
583
+ }
584
+ const reachesEof = start + bytes.length >= size;
585
+ const lastNl = bytes.lastIndexOf(10);
586
+ const decoder = new TextDecoder();
587
+ const chunk: TailChunk = { from: start, to: start, size, mtime, text: '' };
588
+ if (lastNl < 0 && bytes.length >= window) {
589
+ chunk.to = start + bytes.length;
590
+ chunk.text = decoder.decode(bytes);
591
+ } else {
592
+ chunk.to = start + lastNl + 1;
593
+ chunk.text = decoder.decode(bytes.subarray(0, lastNl + 1));
594
+ if (reachesEof && lastNl + 1 < bytes.length) chunk.partial = decoder.decode(bytes.subarray(lastNl + 1));
595
+ }
596
+ return chunk;
597
+ },
598
+
599
+ 'POST /api/fs/save-range': async ({ json }) => {
600
+ const b = await body(json);
601
+ if (!Number.isSafeInteger(b.from) || (b.from as number) < 0) throw badRequest();
602
+ const from = b.from as number;
603
+ const loc = await locate(b.root, b.path, true);
604
+ const st = await stat(loc.real);
605
+ if (st.isDirectory()) throw notAFile();
606
+ const size = st.size;
607
+ if (size < from) throw invalid('rangeStale', 'The file is shorter than the bookmark; it was rotated or cleared.');
608
+ if (size - from > MAX_SAVE_RANGE_BYTES) throw invalid('rangeTooLarge', 'The range is too large.');
609
+ const name = basename(loc.real);
610
+ const target = await saveAs({
611
+ suggestedName: `${name.slice(0, name.length - extname(name).length)}-from-${from}.log`,
612
+ filters: [{ name: 'Log', patterns: ['*.log'] }],
613
+ }).catch(fromPlatform);
614
+ if (target === null) return { path: null };
615
+ await writeFileAtomic(target, Bun.file(loc.real).slice(from, size));
616
+ return { path: target, bytes: size - from };
617
+ },
618
+
619
+ 'GET /api/fs/archive': async ({ query }) => {
620
+ const { root, path } = fromQuery(query);
621
+ const loc = await locate(root, path, true);
622
+ if ((await stat(loc.real)).isDirectory()) throw notAFile();
623
+ return listArchive(loc.real);
624
+ },
625
+
626
+ 'GET /api/fs/sqlite': async ({ query }) => {
627
+ const { root, path } = fromQuery(query);
628
+ const loc = await locate(root, path, true);
629
+ if ((await stat(loc.real)).isDirectory()) throw notAFile();
630
+ const offset = Number(query.get('offset') ?? 0);
631
+ try {
632
+ return readSqlite(loc.real, query.get('table'), Number.isFinite(offset) ? offset : 0);
633
+ } catch (err) {
634
+ const code = (err as { code?: string })?.code;
635
+ if (code === 'SQLITE_NOTADB' || code?.startsWith('SQLITE_CORRUPT')) throw invalid('notSqlite', 'The file is not a readable SQLite database.');
636
+ throw fsError(err);
637
+ }
638
+ },
639
+
640
+ 'GET /api/fs/raw': async ({ query }) => {
641
+ const { root, path } = fromQuery(query);
642
+ const loc = await locate(root, path, true);
643
+ const st = await stat(loc.real);
644
+ if (st.isDirectory()) throw notAFile();
645
+ // `limit` returns only the first bytes (the hex view), however large the file.
646
+ const limit = Number(query.get('limit'));
647
+ const head = Number.isSafeInteger(limit) && limit > 0 ? limit : 0;
648
+ if (!head && st.size > MAX_RAW_BYTES) throw invalid('fileTooLarge', 'The file is too large to show.');
649
+ return new Response(head ? Bun.file(loc.real).slice(0, head) : Bun.file(loc.real), {
650
+ headers: {
651
+ 'Content-Type': RAW_TYPES[extname(loc.real).toLowerCase()] ?? 'application/octet-stream',
652
+ 'Content-Disposition': 'inline',
653
+ 'X-Content-Type-Options': 'nosniff',
654
+ // Opened as a document (an SVG), the file runs nothing and loads nothing.
655
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
656
+ 'Cache-Control': 'no-store',
657
+ },
658
+ });
659
+ },
660
+
661
+ 'GET /api/fs/search': async ({ req, query }) => {
662
+ const { root, path } = fromQuery(query);
663
+ const loc = await locate(root, path, true);
664
+ if (!(await stat(loc.real)).isDirectory()) throw notADir();
665
+ const glob = query.get('glob') ? new Bun.Glob(query.get('glob')!) : null;
666
+ const search = { q: query.get('q') ?? '', content: query.get('content') === '1', glob, exclude: parseExclude(query.get('exclude')) };
667
+ return ndjson(req, async (emit, signal) => {
668
+ let count = 0;
669
+ for await (const hit of searchTree({ abs: loc.real, rel: loc.segments.join('/') }, search, signal)) {
670
+ await emit(hit);
671
+ if (++count >= MAX_SEARCH_HITS) break;
672
+ }
673
+ });
674
+ },
675
+
676
+ // Every file and folder path below `path` (excludes applied), for Quick Open and tree find.
677
+ 'GET /api/fs/names': async ({ req, query }) => {
678
+ const { root, path } = fromQuery(query);
679
+ const loc = await locate(root, path, true);
680
+ if (!(await stat(loc.real)).isDirectory()) throw notADir();
681
+ const search = { q: '', content: false, glob: null, exclude: parseExclude(query.get('exclude')) };
682
+ return ndjson(req, async (emit, signal) => {
683
+ let count = 0;
684
+ for await (const hit of searchTree({ abs: loc.real, rel: loc.segments.join('/') }, search, signal)) {
685
+ await emit(hit);
686
+ if (++count >= MAX_NAME_HITS) break;
687
+ }
688
+ });
689
+ },
690
+
691
+ 'POST /api/fs/create': async ({ json }) => {
692
+ const b = await body(json);
693
+ const loc = await locate(b.root, b.path);
694
+ writable(loc.root);
695
+ if (!loc.segments.length) throw exists();
696
+ if ((b.kind !== 'file' && b.kind !== 'dir') || (b.text !== undefined && typeof b.text !== 'string')) throw badRequest();
697
+ await mkdir(dirname(loc.real), { recursive: true });
698
+ if (b.kind === 'dir') await mkdir(loc.real);
699
+ else await writeFile(loc.real, (b.text as string | undefined) ?? '', { flag: 'wx' });
700
+ return described(loc.real, basename(loc.abs), loc.realRoot);
701
+ },
702
+
703
+ // The body is the file's bytes. `path` is the file itself; missing folders above it are created.
704
+ 'POST /api/fs/upload': async ({ req, query }) => {
705
+ const { root, path } = fromQuery(query);
706
+ const loc = await locate(root, path);
707
+ writable(loc.root);
708
+ if (!loc.segments.length) throw badPath();
709
+ const onExisting = query.get('onExisting');
710
+ if (onExisting !== null && onExisting !== 'replace' && onExisting !== 'keepBoth') throw badRequest();
711
+ const tooLarge = () => invalid('uploadTooLarge', 'The file is too large (200 MB at most).');
712
+ if (Number(req.headers.get('content-length') ?? 0) > MAX_UPLOAD_BYTES) throw tooLarge();
713
+ let to = await entryPath(loc);
714
+ const old = await lstat(to).catch(() => null);
715
+ let replaced = false;
716
+ if (old) {
717
+ if (onExisting === null) throw exists();
718
+ if (onExisting === 'keepBoth') to = await freeName(to, false);
719
+ else if (old.isDirectory()) throw notAFile();
720
+ else replaced = true;
721
+ }
722
+ let received = 0;
723
+ const capped = (req.body ?? new Blob([]).stream()).pipeThrough(
724
+ new TransformStream<Uint8Array, Uint8Array>({
725
+ transform(chunk, controller) {
726
+ received += chunk.byteLength;
727
+ if (received > MAX_UPLOAD_BYTES) throw tooLarge();
728
+ controller.enqueue(chunk);
729
+ },
730
+ }),
731
+ );
732
+ if (replaced) {
733
+ // The old file goes to the bin only once the new bytes are fully on disk.
734
+ const staged = `${to}.upload-${crypto.randomUUID()}`;
735
+ try {
736
+ await writeFileAtomic(staged, capped);
737
+ await trash([to]).catch(fromPlatform);
738
+ await renameWithRetry(staged, to);
739
+ } finally {
740
+ await rm(staged, { force: true }).catch(() => {});
741
+ }
742
+ } else await writeFileAtomic(to, capped);
743
+ return described(to, basename(to), loc.realRoot);
744
+ },
745
+
746
+ 'POST /api/fs/download-ticket': async ({ json }) => {
747
+ const b = await body(json);
748
+ const loc = await locate(b.root, b.path, true);
749
+ if (!loc.segments.length || (await stat(loc.real)).isDirectory()) throw notAFile();
750
+ const name = basename(loc.abs);
751
+ return { ticket: downloads.issue({ file: loc.real, name }), name };
752
+ },
753
+
754
+ 'POST /api/fs/view-ticket': async ({ json }) => {
755
+ const b = await body(json);
756
+ const loc = await locate(b.root, b.path, true);
757
+ if (!loc.segments.length || (await stat(loc.real)).isDirectory()) throw notAFile();
758
+ const now = Date.now();
759
+ for (const [id, v] of views) if (v.expires < now) views.delete(id);
760
+ while (views.size >= 50) views.delete(views.keys().next().value!);
761
+ const id = randomBytes(16).toString('hex');
762
+ views.set(id, { root: b.root as string, dir: loc.segments.slice(0, -1), expires: now + VIEW_TTL_MS });
763
+ return { url: `/api/fs/view/${id}/${encodeURIComponent(loc.segments.at(-1)!)}` };
764
+ },
765
+
766
+ 'POST /api/fs/write': async ({ json }) => {
767
+ const b = await body(json);
768
+ const loc = await locate(b.root, b.path);
769
+ writable(loc.root);
770
+ const expected = b.expectedMtime ?? undefined;
771
+ const encoding = (b.encoding ?? 'utf8') as TextEncodingId;
772
+ if (typeof b.text !== 'string' || (expected !== undefined && typeof expected !== 'number') || !TEXT_ENCODINGS.includes(encoding)) throw badRequest();
773
+ const bytes = encodeText(b.text, encoding);
774
+ const st = await stat(loc.real).catch((err) => {
775
+ if (isMissing(err)) return null;
776
+ throw err;
777
+ });
778
+ if (st?.isDirectory()) throw notAFile();
779
+ if (expected !== undefined && (!st || Math.round(st.mtimeMs) !== expected)) {
780
+ throw invalid('fileChanged', 'The file was changed on disk since it was opened.');
781
+ }
782
+ if (!st && !(await stat(dirname(loc.real)).then((p) => p.isDirectory(), () => false))) throw fileNotFound();
783
+ // Like VS Code's unlock: a read-only file is only written when the caller asks to clear the flag.
784
+ if (st && !ownerWritable(st.mode)) {
785
+ if (b.unlock !== true) throw forbidden('fileReadOnly', 'The file is read-only.');
786
+ await chmod(loc.real, st.mode | 0o200).catch((err) => { throw fsError(err); });
787
+ }
788
+ await writeFileAtomic(loc.real, bytes);
789
+ return described(loc.real, basename(loc.abs), loc.realRoot);
790
+ },
791
+
792
+ // `to` is the new folder; it must not exist. Anything that unpacks to a path outside it removes the folder again.
793
+ 'POST /api/fs/extract': async ({ json }) => {
794
+ const b = await body(json);
795
+ const src = await locate(b.root, b.path, true);
796
+ const dest = await locate(b.root, b.to);
797
+ writable(dest.root);
798
+ if ((await stat(src.real)).isDirectory()) throw notAFile();
799
+ const to = await entryPath(dest);
800
+ if (await present(to)) throw exists();
801
+ await mkdir(dirname(to), { recursive: true });
802
+ await mkdir(to);
803
+ try {
804
+ await extractArchive(src.real, to);
805
+ if (await escapes(await realpath(to))) throw invalid('archiveUnsafe', 'The archive has entries that lead outside the target folder.');
806
+ } catch (err) {
807
+ await rm(to, { recursive: true, force: true }).catch(() => {});
808
+ throw err;
809
+ }
810
+ return { path: dest.segments.join('/') } satisfies FsExtractResult;
811
+ },
812
+
813
+ // The archive `name` goes into the one folder all `paths` share; it is written beside it under a temporary name first.
814
+ 'POST /api/fs/compress': async ({ json }) => {
815
+ const b = await body(json);
816
+ if (!Array.isArray(b.paths) || !b.paths.length || b.paths.length > MAX_PATHS) throw badRequest();
817
+ if (b.format !== 'zip' && b.format !== '7z') throw badRequest();
818
+ if (!validName(b.name)) throw badPath();
819
+ const located = await Promise.all(b.paths.map((p) => locate(b.root, p, true)));
820
+ writable(located[0]!.root);
821
+ const entries = await Promise.all(located.map(entryPath));
822
+ const parent = dirname(entries[0]!);
823
+ if (entries.some((e) => fold(dirname(e)) !== fold(parent))) throw invalid('mixedParents', 'All items must be in the same folder.');
824
+ const file = join(parent, b.name);
825
+ if (await present(file)) throw exists();
826
+ const staged = `${file}.compress-${crypto.randomUUID()}`;
827
+ try {
828
+ await compressArchive(parent, [...new Set(entries.map((e) => basename(e)))], staged, b.format);
829
+ if (await present(file)) throw exists();
830
+ await renameWithRetry(staged, file);
831
+ } finally {
832
+ await rm(staged, { force: true }).catch(() => {});
833
+ }
834
+ return { path: [...located[0]!.segments.slice(0, -1), b.name].join('/'), skippedLinks: [] } satisfies FsCompressResult;
835
+ },
836
+
837
+ 'POST /api/fs/rename': async ({ json }) => {
838
+ const b = await body(json);
839
+ const loc = await locate(b.root, b.path, true);
840
+ writable(loc.root);
841
+ if (!validName(b.name)) throw badPath();
842
+ const from = await entryPath(loc);
843
+ const to = join(dirname(from), b.name);
844
+ await refuseExisting(from, to);
845
+ await renameWithRetry(from, to);
846
+ return described(to, b.name, loc.realRoot);
847
+ },
848
+
849
+ 'POST /api/fs/move': async ({ json }) => {
850
+ const b = await body(json);
851
+ const src = await locate(b.root, b.from, true);
852
+ const dest = await locate(b.toRoot ?? b.root, b.to);
853
+ writable(src.root);
854
+ writable(dest.root);
855
+ const from = await entryPath(src);
856
+ const to = await entryPath(dest);
857
+ if ((await lstat(from)).isDirectory() && fold(to).startsWith(fold(from) + sep)) {
858
+ throw invalid('moveIntoSelf', "A folder can't be moved into itself.");
859
+ }
860
+ const onExisting = b.onExisting;
861
+ if (onExisting !== undefined && onExisting !== 'replace' && onExisting !== 'keepBoth') throw badRequest();
862
+ let target = to;
863
+ if (onExisting === 'keepBoth') {
864
+ target = await freeName(to, (await lstat(from)).isDirectory());
865
+ } else if (onExisting === 'replace' && fold(from) !== fold(to) && (await present(to))) {
866
+ if (fold(from).startsWith(fold(to) + sep)) throw invalid('moveIntoSelf', "A folder can't replace the folder it is in.");
867
+ await trash([to]).catch(fromPlatform);
868
+ } else {
869
+ await refuseExisting(from, to);
870
+ }
871
+ try {
872
+ await renameWithRetry(from, target);
873
+ } catch (err) {
874
+ if (errno(err) !== 'EXDEV') throw err;
875
+ // Across volumes: copy, then the source goes to the bin (never a permanent delete). A failed copy leaves the source alone.
876
+ await cp(from, target, { recursive: true, errorOnExist: true, force: false });
877
+ await trash([from]).catch(fromPlatform);
878
+ }
879
+ return described(target, basename(target), dest.realRoot);
880
+ },
881
+
882
+ 'POST /api/fs/copy': async ({ json }) => {
883
+ const b = await body(json);
884
+ const src = await locate(b.root, b.from, true);
885
+ const dest = await locate(b.root, b.to);
886
+ writable(dest.root);
887
+ const from = await entryPath(src);
888
+ const wanted = await entryPath(dest);
889
+ if ((await lstat(from)).isDirectory() && fold(wanted).startsWith(fold(from) + sep)) {
890
+ throw invalid('moveIntoSelf', "A folder can't be copied into itself.");
891
+ }
892
+ // Like a paste in a file manager: an existing name gets " copy", " copy 2", ... instead of an error.
893
+ const to = await freeName(wanted, (await lstat(from)).isDirectory());
894
+ await cp(from, to, { recursive: true, errorOnExist: true, force: false });
895
+ return described(to, basename(to), dest.realRoot);
896
+ },
897
+
898
+ 'POST /api/fs/delete': async ({ json }) => {
899
+ const b = await body(json);
900
+ if (!Array.isArray(b.paths) || !b.paths.length) throw badRequest();
901
+ // outermost() compares all pairs.
902
+ if (opts.multiDelete === false && b.paths.length > 1) throw invalid('multiDeleteOff', 'Delete one item at a time.');
903
+ if (b.paths.length > MAX_PATHS) throw invalid('tooManyItems', 'Too many items at once.');
904
+ const located = await Promise.all(b.paths.map((p) => locate(b.root, p, true)));
905
+ writable(located[0]!.root);
906
+ const paths = await Promise.all(located.map(entryPath));
907
+ await trash(outermost(paths)).catch(fromPlatform);
908
+ return { ok: true };
909
+ },
910
+ };
911
+
912
+ const guard =
913
+ (handler: Handler): Handler =>
914
+ async (ctx) => {
915
+ try {
916
+ return await handler(ctx);
917
+ } catch (err) {
918
+ throw fsError(err);
919
+ }
920
+ };
921
+
922
+ const openRoutes: Routes = {
923
+ 'GET /api/fs/view/*': guard(async ({ url }) => {
924
+ const [, id, ...rest] = url.pathname.slice('/api/fs/view/'.length - 1).split('/');
925
+ const view = views.get(id ?? '');
926
+ if (!view || view.expires < Date.now()) throw forbidden('invalidTicket', 'This preview expired. Open it again.');
927
+ let segments: string[];
928
+ try {
929
+ segments = rest.map(decodeURIComponent);
930
+ } catch {
931
+ throw invalid('badPath', 'Invalid path.');
932
+ }
933
+ const loc = await locate(view.root, [...view.dir, ...segments].join('/'), true);
934
+ if ((await stat(loc.real)).isDirectory()) throw notAFile();
935
+ const ext = extname(loc.real).toLowerCase();
936
+ return new Response(Bun.file(loc.real), {
937
+ headers: {
938
+ 'Content-Type': VIEW_TYPES[ext] ?? RAW_TYPES[ext] ?? 'application/octet-stream',
939
+ 'X-Content-Type-Options': 'nosniff',
940
+ // Scripts run, but in an opaque origin that can reach nothing of the app; nothing leaves the machine.
941
+ 'Content-Security-Policy': "default-src 'self' data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; sandbox allow-scripts",
942
+ 'Cache-Control': 'no-store',
943
+ },
944
+ });
945
+ }),
946
+ 'GET /api/fs/download': guard(async ({ query }) => {
947
+ const found = downloads.redeem(query.get('t') ?? '');
948
+ if (!found) throw forbidden('invalidTicket', 'Invalid or expired download link.');
949
+ await stat(found.file).catch((err) => {
950
+ throw fsError(err);
951
+ });
952
+ return new Response(Bun.file(found.file), {
953
+ headers: {
954
+ 'Content-Type': 'application/octet-stream',
955
+ 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(found.name)}`,
956
+ 'X-Content-Type-Options': 'nosniff',
957
+ 'Cache-Control': 'no-store',
958
+ },
959
+ });
960
+ }),
961
+ };
962
+
963
+ return {
964
+ routes: Object.fromEntries(Object.entries(routes).map(([key, handler]) => [key, guard(handler)])),
965
+ openRoutes,
966
+ resolve: async (rootId, rel, o = {}) => {
967
+ const { root, abs, real } = await locate(rootId, rel, o.mustExist);
968
+ return { root, abs, real };
969
+ },
970
+ };
971
+ }
972
+
973
+ /** `paths` without duplicates and without paths inside another one (the bin takes the folder with them). */
974
+ function outermost(paths: string[]): string[] {
975
+ const folded = paths.map(fold);
976
+ return paths.filter((_, i) => !folded.some((q, j) => (j < i && q === folded[i]) || folded[i]!.startsWith(q + sep)));
977
+ }