@valentinkolb/filegate 2.4.0 → 2.5.3

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.
Files changed (88) hide show
  1. package/README.md +109 -512
  2. package/dist/activity.d.ts +15 -0
  3. package/dist/activity.d.ts.map +1 -0
  4. package/dist/activity.js +21 -0
  5. package/dist/activity.js.map +1 -0
  6. package/dist/capabilities.d.ts +9 -0
  7. package/dist/capabilities.d.ts.map +1 -0
  8. package/dist/capabilities.js +11 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/client.d.ts +37 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +77 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/core.d.ts +26 -0
  15. package/dist/core.d.ts.map +1 -0
  16. package/dist/core.js +58 -0
  17. package/dist/core.js.map +1 -0
  18. package/dist/downloads.d.ts +9 -0
  19. package/dist/downloads.d.ts.map +1 -0
  20. package/dist/downloads.js +11 -0
  21. package/dist/downloads.js.map +1 -0
  22. package/dist/errors.d.ts +18 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +42 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/index-client.d.ts +17 -0
  27. package/dist/index-client.d.ts.map +1 -0
  28. package/dist/index-client.js +29 -0
  29. package/dist/index-client.js.map +1 -0
  30. package/dist/index.d.ts +14 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +7 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/nodes.d.ts +27 -0
  35. package/dist/nodes.d.ts.map +1 -0
  36. package/dist/nodes.js +71 -0
  37. package/dist/nodes.js.map +1 -0
  38. package/dist/paths.d.ts +45 -0
  39. package/dist/paths.d.ts.map +1 -0
  40. package/dist/paths.js +71 -0
  41. package/dist/paths.js.map +1 -0
  42. package/dist/search.d.ts +17 -0
  43. package/dist/search.d.ts.map +1 -0
  44. package/dist/search.js +25 -0
  45. package/dist/search.js.map +1 -0
  46. package/dist/stats.d.ts +9 -0
  47. package/dist/stats.d.ts.map +1 -0
  48. package/dist/stats.js +11 -0
  49. package/dist/stats.js.map +1 -0
  50. package/dist/transfers.d.ts +9 -0
  51. package/dist/transfers.d.ts.map +1 -0
  52. package/dist/transfers.js +14 -0
  53. package/dist/transfers.js.map +1 -0
  54. package/dist/types.d.ts +285 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +2 -0
  57. package/dist/types.js.map +1 -0
  58. package/dist/uploads.d.ts +246 -0
  59. package/dist/uploads.d.ts.map +1 -0
  60. package/dist/uploads.js +580 -0
  61. package/dist/uploads.js.map +1 -0
  62. package/dist/utils.d.ts +25 -0
  63. package/dist/utils.d.ts.map +1 -0
  64. package/dist/utils.js +41 -0
  65. package/dist/utils.js.map +1 -0
  66. package/dist/versions.d.ts +76 -0
  67. package/dist/versions.d.ts.map +1 -0
  68. package/dist/versions.js +82 -0
  69. package/dist/versions.js.map +1 -0
  70. package/package.json +36 -40
  71. package/LICENSE +0 -21
  72. package/src/client.ts +0 -436
  73. package/src/config.ts +0 -41
  74. package/src/handlers/files.ts +0 -696
  75. package/src/handlers/indexHandler.ts +0 -107
  76. package/src/handlers/search.ts +0 -144
  77. package/src/handlers/thumbnail.ts +0 -174
  78. package/src/handlers/upload.ts +0 -401
  79. package/src/index.ts +0 -131
  80. package/src/lib/index.ts +0 -325
  81. package/src/lib/openapi.ts +0 -48
  82. package/src/lib/ownership.ts +0 -133
  83. package/src/lib/path.ts +0 -128
  84. package/src/lib/response.ts +0 -10
  85. package/src/lib/scanner.ts +0 -121
  86. package/src/lib/validator.ts +0 -21
  87. package/src/schemas.ts +0 -376
  88. package/src/utils.ts +0 -282
@@ -1,696 +0,0 @@
1
- import { Hono } from "hono";
2
- import { describeRoute } from "hono-openapi";
3
- import { readdir, mkdir, rm, rename, cp, stat, access } from "node:fs/promises";
4
- import { join, basename, relative, dirname, extname } from "node:path";
5
- import sanitizeFilename from "sanitize-filename";
6
- import { validatePath, validateSameBase } from "../lib/path";
7
- import { parseOwnershipBody, applyOwnership, applyOwnershipRecursive } from "../lib/ownership";
8
- import { jsonResponse, binaryResponse, requiresAuth } from "../lib/openapi";
9
- import { v } from "../lib/validator";
10
- import {
11
- indexFile,
12
- identifyPath,
13
- resolveId,
14
- removeFromIndex,
15
- removeFromIndexRecursive,
16
- updateIndexPath,
17
- enrichFileInfoBatch,
18
- } from "../lib/index";
19
- import {
20
- FileInfoSchema,
21
- DirInfoSchema,
22
- ErrorSchema,
23
- InfoQuerySchema,
24
- PathQuerySchema,
25
- ContentQuerySchema,
26
- MkdirBodySchema,
27
- TransferBodySchema,
28
- UploadFileHeadersSchema,
29
- type FileInfo,
30
- } from "../schemas";
31
- import { config } from "../config";
32
-
33
- const app = new Hono();
34
-
35
- // Generate a unique path by appending -01, -02, etc. if target exists
36
- const getUniquePath = async (targetPath: string): Promise<string> => {
37
- // Check if target exists
38
- try {
39
- await access(targetPath);
40
- } catch {
41
- // Doesn't exist, use as-is
42
- return targetPath;
43
- }
44
-
45
- const dir = dirname(targetPath);
46
- const ext = extname(targetPath);
47
- const base = basename(targetPath, ext);
48
-
49
- for (let i = 1; i <= 99; i++) {
50
- const suffix = i.toString().padStart(2, "0");
51
- const newPath = join(dir, `${base}-${suffix}${ext}`);
52
- try {
53
- await access(newPath);
54
- } catch {
55
- return newPath;
56
- }
57
- }
58
-
59
- // Fallback: use timestamp if all 99 are taken
60
- const timestamp = Date.now();
61
- return join(dir, `${base}-${timestamp}${ext}`);
62
- };
63
-
64
- // Cross-platform directory size using `du` command
65
- const getDirSize = async (dirPath: string): Promise<number> => {
66
- const isMac = process.platform === "darwin";
67
-
68
- // macOS (BSD): du -sk (kilobytes), Linux (GNU): du -sb (bytes)
69
- const args = isMac ? ["-sk", dirPath] : ["-sb", dirPath];
70
- const multiplier = isMac ? 1024 : 1;
71
-
72
- try {
73
- const proc = Bun.spawn(["du", ...args], {
74
- stdout: "pipe",
75
- stderr: "ignore",
76
- });
77
- const output = await new Response(proc.stdout).text();
78
- await proc.exited;
79
-
80
- const value = parseInt(output.split("\t")[0] ?? "", 10);
81
- return isNaN(value) ? 0 : value * multiplier;
82
- } catch {
83
- return 0;
84
- }
85
- };
86
-
87
- const resolveQueryPath = async (
88
- path: string | undefined,
89
- id: string | undefined,
90
- ): Promise<{ ok: true; path: string } | { ok: false; status: 400 | 404; error: string }> => {
91
- if (id) {
92
- if (!config.indexEnabled) {
93
- return { ok: false, status: 400, error: "index disabled" };
94
- }
95
- const resolved = await resolveId(id);
96
- if (!resolved) return { ok: false, status: 404, error: "not found" };
97
- return { ok: true, path: join(resolved.basePath, resolved.relPath) };
98
- }
99
-
100
- if (!path) {
101
- return { ok: false, status: 400, error: "path or id required" };
102
- }
103
-
104
- return { ok: true, path };
105
- };
106
-
107
- const withFileId = async (info: FileInfo, basePath: string, absPath: string): Promise<FileInfo> => {
108
- if (!config.indexEnabled) return info;
109
- const relPath = relative(basePath, absPath);
110
- const fileId = await identifyPath(basePath, relPath);
111
- return fileId ? { ...info, fileId } : info;
112
- };
113
-
114
- const enrichListingItems = async (items: FileInfo[], basePath: string, dirPath: string): Promise<FileInfo[]> => {
115
- if (!config.indexEnabled || items.length === 0) return items;
116
- const relPaths = items.map((item) => relative(basePath, join(dirPath, item.path)));
117
- const tempItems = items.map((item, i) => ({ ...item, path: relPaths[i] ?? item.path }));
118
- const enriched = await enrichFileInfoBatch(tempItems, basePath);
119
- return items.map((item, i) => {
120
- const fileId = enriched[i]?.fileId;
121
- return fileId ? { ...item, fileId } : item;
122
- });
123
- };
124
-
125
- const getFileInfo = async (path: string, relativeTo?: string, computeDirSize?: boolean): Promise<FileInfo> => {
126
- const file = Bun.file(path);
127
- const s = await stat(path);
128
- const name = basename(path);
129
- const isDir = s.isDirectory();
130
-
131
- return {
132
- name,
133
- path: relativeTo ? relative(relativeTo, path) : path,
134
- type: isDir ? "directory" : "file",
135
- size: isDir ? (computeDirSize ? await getDirSize(path) : 0) : s.size,
136
- mtime: s.mtime.toISOString(),
137
- isHidden: name.startsWith("."),
138
- mimeType: isDir ? undefined : file.type,
139
- };
140
- };
141
-
142
- // GET /info
143
- app.get(
144
- "/info",
145
- describeRoute({
146
- tags: ["Files"],
147
- summary: "Get file or directory info",
148
- ...requiresAuth,
149
- responses: {
150
- 200: jsonResponse(DirInfoSchema, "File or directory info"),
151
- 400: jsonResponse(ErrorSchema, "Bad request"),
152
- 403: jsonResponse(ErrorSchema, "Forbidden"),
153
- 404: jsonResponse(ErrorSchema, "Not found"),
154
- },
155
- }),
156
- v("query", InfoQuerySchema),
157
- async (c) => {
158
- const { path, id, showHidden, computeSizes } = c.req.valid("query");
159
-
160
- const resolved = await resolveQueryPath(path, id);
161
- if (!resolved.ok) return c.json({ error: resolved.error }, resolved.status);
162
-
163
- const result = await validatePath(resolved.path, { allowBasePath: true });
164
- if (!result.ok) return c.json({ error: result.error }, result.status);
165
-
166
- let s;
167
- try {
168
- s = await stat(result.realPath);
169
- } catch {
170
- return c.json({ error: "not found" }, 404);
171
- }
172
-
173
- if (!s.isDirectory()) {
174
- const info = await getFileInfo(result.realPath);
175
- return c.json(await withFileId(info, result.basePath, result.realPath));
176
- }
177
-
178
- const entries = await readdir(result.realPath, { withFileTypes: true });
179
-
180
- // Parallel file info retrieval (computeSizes only when requested)
181
- let items = (
182
- await Promise.all(
183
- entries
184
- .filter((e) => showHidden || !e.name.startsWith("."))
185
- .map((e) => getFileInfo(join(result.realPath, e.name), result.realPath, computeSizes).catch(() => null)),
186
- )
187
- ).filter((item): item is FileInfo => item !== null);
188
-
189
- items = await enrichListingItems(items, result.basePath, result.realPath);
190
-
191
- const info = await getFileInfo(result.realPath);
192
- const infoWithId = await withFileId(info, result.basePath, result.realPath);
193
- const totalSize = computeSizes ? items.reduce((sum, item) => sum + item.size, 0) : 0;
194
- return c.json({ ...infoWithId, size: totalSize, items, total: items.length });
195
- },
196
- );
197
-
198
- // GET /content
199
- app.get(
200
- "/content",
201
- describeRoute({
202
- tags: ["Files"],
203
- summary: "Download file or directory",
204
- description:
205
- "Downloads a file directly or a directory as a TAR archive. Size limit applies to both. Use ?inline=true to display in browser instead of downloading.",
206
- ...requiresAuth,
207
- responses: {
208
- 200: binaryResponse("application/octet-stream", "File content or TAR archive"),
209
- 400: jsonResponse(ErrorSchema, "Bad request"),
210
- 403: jsonResponse(ErrorSchema, "Forbidden"),
211
- 404: jsonResponse(ErrorSchema, "Not found"),
212
- 413: jsonResponse(ErrorSchema, "Content too large"),
213
- },
214
- }),
215
- v("query", ContentQuerySchema),
216
- async (c) => {
217
- const { path, id, inline } = c.req.valid("query");
218
-
219
- const resolved = await resolveQueryPath(path, id);
220
- if (!resolved.ok) return c.json({ error: resolved.error }, resolved.status);
221
-
222
- const result = await validatePath(resolved.path);
223
- if (!result.ok) return c.json({ error: result.error }, result.status);
224
-
225
- let s;
226
- try {
227
- s = await stat(result.realPath);
228
- } catch {
229
- return c.json({ error: "not found" }, 404);
230
- }
231
-
232
- if (s.isDirectory()) {
233
- const size = await getDirSize(result.realPath);
234
- if (size > config.maxDownloadBytes) {
235
- return c.json(
236
- { error: `directory exceeds max download size (${Math.round(config.maxDownloadBytes / 1024 / 1024)}MB)` },
237
- 413,
238
- );
239
- }
240
-
241
- const dirName = basename(result.realPath);
242
-
243
- // Create TAR archive using Bun's native API
244
- const files: Record<string, Blob> = {};
245
-
246
- // Add directory contents recursively
247
- const addDirectoryToArchive = async (dirPath: string, basePath: string) => {
248
- const entries = await readdir(dirPath, { withFileTypes: true });
249
- for (const entry of entries) {
250
- const fullPath = join(dirPath, entry.name);
251
- const archivePath = relative(basePath, fullPath);
252
-
253
- if (entry.isDirectory()) {
254
- await addDirectoryToArchive(fullPath, basePath);
255
- } else if (entry.isFile()) {
256
- // Read file content as Blob - Bun.file() is lazy and doesn't work reliably with Archive
257
- const fileContent = await Bun.file(fullPath).arrayBuffer();
258
- files[archivePath] = new Blob([fileContent]);
259
- }
260
- }
261
- };
262
-
263
- await addDirectoryToArchive(result.realPath, result.realPath);
264
-
265
- // Generate the tar archive
266
- const archive = new Bun.Archive(files);
267
- const archiveBlob = await archive.blob();
268
-
269
- const tarName = `${dirName}.tar`;
270
- return new Response(archiveBlob, {
271
- headers: {
272
- "Content-Type": "application/x-tar",
273
- "Content-Disposition": `attachment; filename="${encodeURIComponent(tarName)}"; filename*=UTF-8''${encodeURIComponent(tarName)}`,
274
- "X-File-Name": encodeURIComponent(tarName),
275
- },
276
- });
277
- }
278
-
279
- const file = Bun.file(result.realPath);
280
- if (!(await file.exists())) return c.json({ error: "not found" }, 404);
281
-
282
- if (file.size > config.maxDownloadBytes) {
283
- return c.json(
284
- { error: `file exceeds max download size (${Math.round(config.maxDownloadBytes / 1024 / 1024)}MB)` },
285
- 413,
286
- );
287
- }
288
-
289
- const filename = basename(result.realPath);
290
- const encodedFilename = encodeURIComponent(filename);
291
- const disposition = inline ? "inline" : "attachment";
292
-
293
- return new Response(file.stream(), {
294
- headers: {
295
- "Content-Type": file.type,
296
- "Content-Length": String(file.size),
297
- "Content-Disposition": `${disposition}; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`,
298
- "X-File-Name": encodedFilename,
299
- },
300
- });
301
- },
302
- );
303
-
304
- // PUT /content
305
- app.put(
306
- "/content",
307
- describeRoute({
308
- tags: ["Files"],
309
- summary: "Upload file",
310
- description:
311
- "Upload a file with optional ownership. Use headers X-File-Path, X-File-Name, and optionally X-Owner-UID, X-Owner-GID, X-File-Mode.",
312
- ...requiresAuth,
313
- responses: {
314
- 201: jsonResponse(FileInfoSchema, "File created"),
315
- 400: jsonResponse(ErrorSchema, "Bad request"),
316
- 403: jsonResponse(ErrorSchema, "Forbidden"),
317
- 413: jsonResponse(ErrorSchema, "File too large"),
318
- },
319
- }),
320
- v("header", UploadFileHeadersSchema),
321
- async (c) => {
322
- const headers = c.req.valid("header");
323
- const dirPath = headers["x-file-path"];
324
- const rawFilename = headers["x-file-name"];
325
-
326
- // Sanitize filename to prevent path traversal
327
- const filename = sanitizeFilename(rawFilename);
328
- if (!filename || filename !== rawFilename) {
329
- return c.json({ error: "invalid filename" }, 400);
330
- }
331
-
332
- const fullPath = join(dirPath, filename);
333
-
334
- // Build ownership from validated headers
335
- const ownership: import("../lib/ownership").Ownership | null =
336
- headers["x-owner-uid"] != null && headers["x-owner-gid"] != null && headers["x-file-mode"] != null
337
- ? {
338
- uid: headers["x-owner-uid"],
339
- gid: headers["x-owner-gid"],
340
- mode: parseInt(headers["x-file-mode"], 8),
341
- dirMode: headers["x-dir-mode"] ? parseInt(headers["x-dir-mode"], 8) : undefined,
342
- }
343
- : null;
344
-
345
- // Validate path and create parent directories with ownership
346
- const result = await validatePath(fullPath, { createParents: true, ownership });
347
- if (!result.ok) return c.json({ error: result.error }, result.status);
348
-
349
- const body = c.req.raw.body;
350
- if (!body) return c.json({ error: "missing body" }, 400);
351
-
352
- // Stream to file instead of buffering in memory
353
- let written = 0;
354
- const file = Bun.file(result.realPath);
355
- const writer = file.writer();
356
-
357
- try {
358
- for await (const chunk of body) {
359
- written += chunk.length;
360
- if (written > config.maxUploadBytes) {
361
- writer.end();
362
- await rm(result.realPath).catch(() => {});
363
- return c.json({ error: "file exceeds max upload size" }, 413);
364
- }
365
- writer.write(chunk);
366
- }
367
- await writer.end();
368
- } catch (e) {
369
- writer.end();
370
- await rm(result.realPath).catch(() => {});
371
- throw e;
372
- }
373
-
374
- const ownershipError = await applyOwnership(result.realPath, ownership);
375
- if (ownershipError) {
376
- await rm(result.realPath).catch(() => {});
377
- return c.json({ error: ownershipError }, 500);
378
- }
379
-
380
- const info = await getFileInfo(result.realPath);
381
-
382
- if (!config.indexEnabled) {
383
- return c.json(info, 201);
384
- }
385
-
386
- try {
387
- const s = await stat(result.realPath);
388
- const relPath = relative(result.basePath, result.realPath);
389
- const outcome = await indexFile(result.basePath, relPath, {
390
- dev: s.dev,
391
- ino: s.ino,
392
- size: s.size,
393
- mtimeMs: s.mtimeMs,
394
- isDirectory: s.isDirectory(),
395
- });
396
- return c.json({ ...info, fileId: outcome.id }, 201);
397
- } catch (err) {
398
- console.error("[Filegate] Index update failed:", err);
399
- return c.json(info, 201);
400
- }
401
- },
402
- );
403
-
404
- // POST /mkdir
405
- app.post(
406
- "/mkdir",
407
- describeRoute({
408
- tags: ["Files"],
409
- summary: "Create directory",
410
- ...requiresAuth,
411
- responses: {
412
- 201: jsonResponse(FileInfoSchema, "Directory created"),
413
- 400: jsonResponse(ErrorSchema, "Bad request"),
414
- 403: jsonResponse(ErrorSchema, "Forbidden"),
415
- },
416
- }),
417
- v("json", MkdirBodySchema),
418
- async (c) => {
419
- const body = c.req.valid("json");
420
-
421
- const result = await validatePath(body.path);
422
- if (!result.ok) return c.json({ error: result.error }, result.status);
423
-
424
- const ownership = parseOwnershipBody(body);
425
-
426
- await mkdir(result.realPath, { recursive: true });
427
-
428
- const ownershipError = await applyOwnership(result.realPath, ownership);
429
- if (ownershipError) {
430
- await rm(result.realPath, { recursive: true }).catch(() => {});
431
- return c.json({ error: ownershipError }, 500);
432
- }
433
-
434
- const info = await getFileInfo(result.realPath);
435
-
436
- if (!config.indexEnabled) {
437
- return c.json(info, 201);
438
- }
439
-
440
- try {
441
- const s = await stat(result.realPath);
442
- const relPath = relative(result.basePath, result.realPath);
443
- const outcome = await indexFile(result.basePath, relPath, {
444
- dev: s.dev,
445
- ino: s.ino,
446
- size: s.size,
447
- mtimeMs: s.mtimeMs,
448
- isDirectory: s.isDirectory(),
449
- });
450
- return c.json({ ...info, fileId: outcome.id }, 201);
451
- } catch (err) {
452
- console.error("[Filegate] Index update failed:", err);
453
- return c.json(info, 201);
454
- }
455
- },
456
- );
457
-
458
- // DELETE /delete
459
- app.delete(
460
- "/delete",
461
- describeRoute({
462
- tags: ["Files"],
463
- summary: "Delete file or directory",
464
- ...requiresAuth,
465
- responses: {
466
- 204: { description: "Deleted" },
467
- 400: jsonResponse(ErrorSchema, "Bad request"),
468
- 403: jsonResponse(ErrorSchema, "Forbidden"),
469
- 404: jsonResponse(ErrorSchema, "Not found"),
470
- },
471
- }),
472
- v("query", PathQuerySchema),
473
- async (c) => {
474
- const { path, id } = c.req.valid("query");
475
-
476
- const resolved = await resolveQueryPath(path, id);
477
- if (!resolved.ok) return c.json({ error: resolved.error }, resolved.status);
478
-
479
- const result = await validatePath(resolved.path);
480
- if (!result.ok) return c.json({ error: result.error }, result.status);
481
-
482
- let s;
483
- try {
484
- s = await stat(result.realPath);
485
- } catch {
486
- return c.json({ error: "not found" }, 404);
487
- }
488
-
489
- if (config.indexEnabled) {
490
- const relPath = relative(result.basePath, result.realPath);
491
- if (s.isDirectory()) {
492
- await removeFromIndexRecursive(result.basePath, relPath).catch((err) => {
493
- console.error("[Filegate] Index remove failed:", err);
494
- });
495
- } else {
496
- await removeFromIndex(result.basePath, relPath).catch((err) => {
497
- console.error("[Filegate] Index remove failed:", err);
498
- });
499
- }
500
- }
501
-
502
- await rm(result.realPath, { recursive: s.isDirectory() });
503
- return c.body(null, 204);
504
- },
505
- );
506
-
507
- // POST /transfer
508
- app.post(
509
- "/transfer",
510
- describeRoute({
511
- tags: ["Files"],
512
- summary: "Move or copy file/directory",
513
- description:
514
- "Transfer files between locations. Mode 'move' requires same base path. " +
515
- "Mode 'copy' allows cross-base transfer when ownership (ownerUid, ownerGid, fileMode) is provided.",
516
- ...requiresAuth,
517
- responses: {
518
- 200: jsonResponse(FileInfoSchema, "Transferred"),
519
- 400: jsonResponse(ErrorSchema, "Bad request"),
520
- 403: jsonResponse(ErrorSchema, "Forbidden"),
521
- 404: jsonResponse(ErrorSchema, "Not found"),
522
- },
523
- }),
524
- v("json", TransferBodySchema),
525
- async (c) => {
526
- const { from, to, mode, ensureUniqueName, ownerUid, ownerGid, fileMode, dirMode } = c.req.valid("json");
527
-
528
- // Build ownership if provided
529
- const ownership =
530
- ownerUid != null && ownerGid != null && fileMode != null
531
- ? {
532
- uid: ownerUid,
533
- gid: ownerGid,
534
- mode: parseInt(fileMode, 8),
535
- dirMode: dirMode ? parseInt(dirMode, 8) : undefined,
536
- }
537
- : null;
538
-
539
- // Move always requires same base
540
- if (mode === "move") {
541
- const result = await validateSameBase(from, to);
542
- if (!result.ok) return c.json({ error: result.error }, result.status);
543
-
544
- try {
545
- await stat(result.realPath);
546
- } catch {
547
- return c.json({ error: "source not found" }, 404);
548
- }
549
-
550
- const sourceRelPath = relative(result.basePath, result.realPath);
551
- const existingId = config.indexEnabled ? await identifyPath(result.basePath, sourceRelPath) : null;
552
-
553
- const targetPath = ensureUniqueName ? await getUniquePath(result.realTo) : result.realTo;
554
-
555
- await mkdir(join(targetPath, ".."), { recursive: true });
556
- await rename(result.realPath, targetPath);
557
-
558
- // Apply ownership if provided (for move within same base)
559
- if (ownership) {
560
- const ownershipError = await applyOwnershipRecursive(targetPath, ownership);
561
- if (ownershipError) {
562
- return c.json({ error: ownershipError }, 500);
563
- }
564
- }
565
-
566
- const info = await getFileInfo(targetPath);
567
-
568
- if (!config.indexEnabled) {
569
- return c.json(info);
570
- }
571
-
572
- try {
573
- const targetRelPath = relative(result.basePath, targetPath);
574
- if (existingId) {
575
- await updateIndexPath(existingId, result.basePath, targetRelPath);
576
- return c.json({ ...info, fileId: existingId });
577
- }
578
-
579
- const s = await stat(targetPath);
580
- const outcome = await indexFile(result.basePath, targetRelPath, {
581
- dev: s.dev,
582
- ino: s.ino,
583
- size: s.size,
584
- mtimeMs: s.mtimeMs,
585
- isDirectory: s.isDirectory(),
586
- });
587
- return c.json({ ...info, fileId: outcome.id });
588
- } catch (err) {
589
- console.error("[Filegate] Index update failed:", err);
590
- return c.json(info);
591
- }
592
- }
593
-
594
- // Copy: check if same base or cross-base with ownership
595
- const sameBaseResult = await validateSameBase(from, to);
596
-
597
- if (sameBaseResult.ok) {
598
- // Same base - no ownership required
599
- try {
600
- await stat(sameBaseResult.realPath);
601
- } catch {
602
- return c.json({ error: "source not found" }, 404);
603
- }
604
-
605
- const targetPath = ensureUniqueName ? await getUniquePath(sameBaseResult.realTo) : sameBaseResult.realTo;
606
-
607
- await mkdir(join(targetPath, ".."), { recursive: true });
608
- await cp(sameBaseResult.realPath, targetPath, { recursive: true });
609
-
610
- // Apply ownership if provided
611
- if (ownership) {
612
- const ownershipError = await applyOwnershipRecursive(targetPath, ownership);
613
- if (ownershipError) {
614
- await rm(targetPath, { recursive: true }).catch(() => {});
615
- return c.json({ error: ownershipError }, 500);
616
- }
617
- }
618
-
619
- const info = await getFileInfo(targetPath);
620
-
621
- if (!config.indexEnabled) {
622
- return c.json(info);
623
- }
624
-
625
- try {
626
- const targetRelPath = relative(sameBaseResult.basePath, targetPath);
627
- const s = await stat(targetPath);
628
- const outcome = await indexFile(sameBaseResult.basePath, targetRelPath, {
629
- dev: s.dev,
630
- ino: s.ino,
631
- size: s.size,
632
- mtimeMs: s.mtimeMs,
633
- isDirectory: s.isDirectory(),
634
- });
635
- return c.json({ ...info, fileId: outcome.id });
636
- } catch (err) {
637
- console.error("[Filegate] Index update failed:", err);
638
- return c.json(info);
639
- }
640
- }
641
-
642
- // Cross-base copy - ownership is required
643
- if (!ownership) {
644
- return c.json({ error: "cross-base copy requires ownership (ownerUid, ownerGid, fileMode)" }, 400);
645
- }
646
-
647
- // Validate source and destination separately
648
- const fromResult = await validatePath(from);
649
- if (!fromResult.ok) return c.json({ error: fromResult.error }, fromResult.status);
650
-
651
- const toResult = await validatePath(to, { createParents: true, ownership });
652
- if (!toResult.ok) return c.json({ error: toResult.error }, toResult.status);
653
-
654
- try {
655
- await stat(fromResult.realPath);
656
- } catch {
657
- return c.json({ error: "source not found" }, 404);
658
- }
659
-
660
- const targetPath = ensureUniqueName ? await getUniquePath(toResult.realPath) : toResult.realPath;
661
-
662
- await mkdir(join(targetPath, ".."), { recursive: true });
663
- await cp(fromResult.realPath, targetPath, { recursive: true });
664
-
665
- // Apply ownership recursively to copied content
666
- const ownershipError = await applyOwnershipRecursive(targetPath, ownership);
667
- if (ownershipError) {
668
- await rm(targetPath, { recursive: true }).catch(() => {});
669
- return c.json({ error: ownershipError }, 500);
670
- }
671
-
672
- const info = await getFileInfo(targetPath);
673
-
674
- if (!config.indexEnabled) {
675
- return c.json(info);
676
- }
677
-
678
- try {
679
- const targetRelPath = relative(toResult.basePath, targetPath);
680
- const s = await stat(targetPath);
681
- const outcome = await indexFile(toResult.basePath, targetRelPath, {
682
- dev: s.dev,
683
- ino: s.ino,
684
- size: s.size,
685
- mtimeMs: s.mtimeMs,
686
- isDirectory: s.isDirectory(),
687
- });
688
- return c.json({ ...info, fileId: outcome.id });
689
- } catch (err) {
690
- console.error("[Filegate] Index update failed:", err);
691
- return c.json(info);
692
- }
693
- },
694
- );
695
-
696
- export default app;