@valentinkolb/filegate 2.3.6 → 2.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/filegate",
3
- "version": "2.3.6",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/config.ts CHANGED
@@ -29,6 +29,10 @@ export const config = {
29
29
  uploadExpirySecs: int("UPLOAD_EXPIRY_HOURS", 24) * 60 * 60,
30
30
  uploadTempDir: process.env.UPLOAD_TEMP_DIR ?? "/tmp/filegate-uploads",
31
31
  diskCleanupIntervalMs: int("DISK_CLEANUP_INTERVAL_HOURS", 6) * 60 * 60 * 1000,
32
+ indexEnabled: process.env.ENABLE_INDEX !== "false",
33
+ indexDatabaseUrl: process.env.INDEX_DATABASE_URL ?? "sqlite://:memory:",
34
+ indexRescanIntervalMs: int("INDEX_RESCAN_INTERVAL_MINUTES", 30) * 60 * 1000,
35
+ indexScanConcurrency: int("INDEX_SCAN_CONCURRENCY", 4),
32
36
  devUid: optionalInt("DEV_UID_OVERRIDE"),
33
37
  devGid: optionalInt("DEV_GID_OVERRIDE"),
34
38
  get isDev() {
@@ -7,6 +7,15 @@ import { validatePath, validateSameBase } from "../lib/path";
7
7
  import { parseOwnershipBody, applyOwnership, applyOwnershipRecursive } from "../lib/ownership";
8
8
  import { jsonResponse, binaryResponse, requiresAuth } from "../lib/openapi";
9
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";
10
19
  import {
11
20
  FileInfoSchema,
12
21
  DirInfoSchema,
@@ -75,6 +84,44 @@ const getDirSize = async (dirPath: string): Promise<number> => {
75
84
  }
76
85
  };
77
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
+
78
125
  const getFileInfo = async (path: string, relativeTo?: string, computeDirSize?: boolean): Promise<FileInfo> => {
79
126
  const file = Bun.file(path);
80
127
  const s = await stat(path);
@@ -108,9 +155,12 @@ app.get(
108
155
  }),
109
156
  v("query", InfoQuerySchema),
110
157
  async (c) => {
111
- const { path, showHidden, computeSizes } = c.req.valid("query");
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);
112
162
 
113
- const result = await validatePath(path, { allowBasePath: true });
163
+ const result = await validatePath(resolved.path, { allowBasePath: true });
114
164
  if (!result.ok) return c.json({ error: result.error }, result.status);
115
165
 
116
166
  let s;
@@ -121,13 +171,14 @@ app.get(
121
171
  }
122
172
 
123
173
  if (!s.isDirectory()) {
124
- return c.json(await getFileInfo(result.realPath));
174
+ const info = await getFileInfo(result.realPath);
175
+ return c.json(await withFileId(info, result.basePath, result.realPath));
125
176
  }
126
177
 
127
178
  const entries = await readdir(result.realPath, { withFileTypes: true });
128
179
 
129
180
  // Parallel file info retrieval (computeSizes only when requested)
130
- const items = (
181
+ let items = (
131
182
  await Promise.all(
132
183
  entries
133
184
  .filter((e) => showHidden || !e.name.startsWith("."))
@@ -135,9 +186,12 @@ app.get(
135
186
  )
136
187
  ).filter((item): item is FileInfo => item !== null);
137
188
 
189
+ items = await enrichListingItems(items, result.basePath, result.realPath);
190
+
138
191
  const info = await getFileInfo(result.realPath);
192
+ const infoWithId = await withFileId(info, result.basePath, result.realPath);
139
193
  const totalSize = computeSizes ? items.reduce((sum, item) => sum + item.size, 0) : 0;
140
- return c.json({ ...info, size: totalSize, items, total: items.length });
194
+ return c.json({ ...infoWithId, size: totalSize, items, total: items.length });
141
195
  },
142
196
  );
143
197
 
@@ -160,9 +214,12 @@ app.get(
160
214
  }),
161
215
  v("query", ContentQuerySchema),
162
216
  async (c) => {
163
- const { path, inline } = c.req.valid("query");
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);
164
221
 
165
- const result = await validatePath(path);
222
+ const result = await validatePath(resolved.path);
166
223
  if (!result.ok) return c.json({ error: result.error }, result.status);
167
224
 
168
225
  let s;
@@ -209,11 +266,12 @@ app.get(
209
266
  const archive = new Bun.Archive(files);
210
267
  const archiveBlob = await archive.blob();
211
268
 
269
+ const tarName = `${dirName}.tar`;
212
270
  return new Response(archiveBlob, {
213
271
  headers: {
214
272
  "Content-Type": "application/x-tar",
215
- "Content-Disposition": `attachment; filename="${dirName}.tar"`,
216
- "X-File-Name": `${dirName}.tar`,
273
+ "Content-Disposition": `attachment; filename="${encodeURIComponent(tarName)}"; filename*=UTF-8''${encodeURIComponent(tarName)}`,
274
+ "X-File-Name": encodeURIComponent(tarName),
217
275
  },
218
276
  });
219
277
  }
@@ -229,14 +287,15 @@ app.get(
229
287
  }
230
288
 
231
289
  const filename = basename(result.realPath);
290
+ const encodedFilename = encodeURIComponent(filename);
232
291
  const disposition = inline ? "inline" : "attachment";
233
292
 
234
293
  return new Response(file.stream(), {
235
294
  headers: {
236
295
  "Content-Type": file.type,
237
296
  "Content-Length": String(file.size),
238
- "Content-Disposition": `${disposition}; filename="${filename}"`,
239
- "X-File-Name": filename,
297
+ "Content-Disposition": `${disposition}; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`,
298
+ "X-File-Name": encodedFilename,
240
299
  },
241
300
  });
242
301
  },
@@ -318,7 +377,27 @@ app.put(
318
377
  return c.json({ error: ownershipError }, 500);
319
378
  }
320
379
 
321
- return c.json(await getFileInfo(result.realPath), 201);
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
+ }
322
401
  },
323
402
  );
324
403
 
@@ -352,7 +431,27 @@ app.post(
352
431
  return c.json({ error: ownershipError }, 500);
353
432
  }
354
433
 
355
- return c.json(await getFileInfo(result.realPath), 201);
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
+ }
356
455
  },
357
456
  );
358
457
 
@@ -372,9 +471,12 @@ app.delete(
372
471
  }),
373
472
  v("query", PathQuerySchema),
374
473
  async (c) => {
375
- const { path } = c.req.valid("query");
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);
376
478
 
377
- const result = await validatePath(path);
479
+ const result = await validatePath(resolved.path);
378
480
  if (!result.ok) return c.json({ error: result.error }, result.status);
379
481
 
380
482
  let s;
@@ -384,6 +486,19 @@ app.delete(
384
486
  return c.json({ error: "not found" }, 404);
385
487
  }
386
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
+
387
502
  await rm(result.realPath, { recursive: s.isDirectory() });
388
503
  return c.body(null, 204);
389
504
  },
@@ -432,6 +547,9 @@ app.post(
432
547
  return c.json({ error: "source not found" }, 404);
433
548
  }
434
549
 
550
+ const sourceRelPath = relative(result.basePath, result.realPath);
551
+ const existingId = config.indexEnabled ? await identifyPath(result.basePath, sourceRelPath) : null;
552
+
435
553
  const targetPath = ensureUniqueName ? await getUniquePath(result.realTo) : result.realTo;
436
554
 
437
555
  await mkdir(join(targetPath, ".."), { recursive: true });
@@ -445,7 +563,32 @@ app.post(
445
563
  }
446
564
  }
447
565
 
448
- return c.json(await getFileInfo(targetPath));
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
+ }
449
592
  }
450
593
 
451
594
  // Copy: check if same base or cross-base with ownership
@@ -473,7 +616,27 @@ app.post(
473
616
  }
474
617
  }
475
618
 
476
- return c.json(await getFileInfo(targetPath));
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
+ }
477
640
  }
478
641
 
479
642
  // Cross-base copy - ownership is required
@@ -506,7 +669,27 @@ app.post(
506
669
  return c.json({ error: ownershipError }, 500);
507
670
  }
508
671
 
509
- return c.json(await getFileInfo(targetPath));
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
+ }
510
693
  },
511
694
  );
512
695
 
@@ -0,0 +1,107 @@
1
+ import { Hono } from "hono";
2
+ import { describeRoute } from "hono-openapi";
3
+ import { join } from "node:path";
4
+ import { jsonResponse, requiresAuth } from "../lib/openapi";
5
+ import { v } from "../lib/validator";
6
+ import {
7
+ RescanResponseSchema,
8
+ IndexStatsSchema,
9
+ BulkResolveBodySchema,
10
+ BulkResolveResponseSchema,
11
+ ErrorSchema,
12
+ } from "../schemas";
13
+ import { config } from "../config";
14
+ import { bulkResolve, getIndexStats } from "../lib/index";
15
+ import { scanAll } from "../lib/scanner";
16
+
17
+ const app = new Hono();
18
+
19
+ app.post(
20
+ "/rescan",
21
+ describeRoute({
22
+ tags: ["Index"],
23
+ summary: "Rescan file index",
24
+ ...requiresAuth,
25
+ responses: {
26
+ 200: jsonResponse(RescanResponseSchema, "Rescan completed"),
27
+ 400: jsonResponse(ErrorSchema, "Bad request"),
28
+ 500: jsonResponse(ErrorSchema, "Internal error"),
29
+ },
30
+ }),
31
+ async (c) => {
32
+ if (!config.indexEnabled) {
33
+ return c.json({ error: "index disabled" }, 400);
34
+ }
35
+
36
+ try {
37
+ const result = await scanAll();
38
+ return c.json(result);
39
+ } catch (err) {
40
+ console.error("[Filegate] Index rescan failed:", err);
41
+ return c.json({ error: "rescan failed" }, 500);
42
+ }
43
+ },
44
+ );
45
+
46
+ app.get(
47
+ "/stats",
48
+ describeRoute({
49
+ tags: ["Index"],
50
+ summary: "Get index stats",
51
+ ...requiresAuth,
52
+ responses: {
53
+ 200: jsonResponse(IndexStatsSchema, "Index stats"),
54
+ 400: jsonResponse(ErrorSchema, "Bad request"),
55
+ 500: jsonResponse(ErrorSchema, "Internal error"),
56
+ },
57
+ }),
58
+ async (c) => {
59
+ if (!config.indexEnabled) {
60
+ return c.json({ error: "index disabled" }, 400);
61
+ }
62
+
63
+ try {
64
+ const stats = await getIndexStats();
65
+ return c.json(stats);
66
+ } catch (err) {
67
+ console.error("[Filegate] Index stats failed:", err);
68
+ return c.json({ error: "stats failed" }, 500);
69
+ }
70
+ },
71
+ );
72
+
73
+ app.post(
74
+ "/resolve",
75
+ describeRoute({
76
+ tags: ["Index"],
77
+ summary: "Resolve file IDs to paths",
78
+ ...requiresAuth,
79
+ responses: {
80
+ 200: jsonResponse(BulkResolveResponseSchema, "Resolved paths"),
81
+ 400: jsonResponse(ErrorSchema, "Bad request"),
82
+ 500: jsonResponse(ErrorSchema, "Internal error"),
83
+ },
84
+ }),
85
+ v("json", BulkResolveBodySchema),
86
+ async (c) => {
87
+ if (!config.indexEnabled) {
88
+ return c.json({ error: "index disabled" }, 400);
89
+ }
90
+
91
+ try {
92
+ const { ids } = c.req.valid("json");
93
+ const resolved = await bulkResolve(ids);
94
+ const response: Record<string, string | null> = {};
95
+ for (const id of ids) {
96
+ const entry = resolved[id];
97
+ response[id] = entry ? join(entry.basePath, entry.relPath) : null;
98
+ }
99
+ return c.json(response);
100
+ } catch (err) {
101
+ console.error("[Filegate] Index resolve failed:", err);
102
+ return c.json({ error: "resolve failed" }, 500);
103
+ }
104
+ },
105
+ );
106
+
107
+ export default app;
@@ -15,6 +15,7 @@ import {
15
15
  type SearchResult,
16
16
  } from "../schemas";
17
17
  import { config } from "../config";
18
+ import { enrichFileInfoBatch } from "../lib/index";
18
19
 
19
20
  const app = new Hono();
20
21
 
@@ -128,6 +129,12 @@ app.get(
128
129
  validPaths.map((p) => searchInPath(p, pattern, showHidden, limit, files, directories)),
129
130
  );
130
131
 
132
+ if (config.indexEnabled) {
133
+ for (const result of results) {
134
+ result.files = await enrichFileInfoBatch(result.files, result.basePath);
135
+ }
136
+ }
137
+
131
138
  const totalFiles = results.reduce((sum, r) => sum + r.total, 0);
132
139
 
133
140
  return c.json({ results, totalFiles });
@@ -1,11 +1,14 @@
1
1
  import { Hono } from "hono";
2
2
  import { describeRoute } from "hono-openapi";
3
3
  import { stat } from "node:fs/promises";
4
+ import { join } from "node:path";
4
5
  import sharp from "sharp";
5
6
  import { validatePath } from "../lib/path";
6
7
  import { jsonResponse, requiresAuth } from "../lib/openapi";
7
8
  import { v } from "../lib/validator";
8
9
  import { ImageThumbnailQuerySchema, ErrorSchema } from "../schemas";
10
+ import { config } from "../config";
11
+ import { resolveId } from "../lib/index";
9
12
 
10
13
  const app = new Hono();
11
14
 
@@ -35,6 +38,26 @@ const FORMAT_MIME: Record<string, string> = {
35
38
  avif: "image/avif",
36
39
  };
37
40
 
41
+ const resolveQueryPath = async (
42
+ path: string | undefined,
43
+ id: string | undefined,
44
+ ): Promise<{ ok: true; path: string } | { ok: false; status: 400 | 404; error: string }> => {
45
+ if (id) {
46
+ if (!config.indexEnabled) {
47
+ return { ok: false, status: 400, error: "index disabled" };
48
+ }
49
+ const resolved = await resolveId(id);
50
+ if (!resolved) return { ok: false, status: 404, error: "not found" };
51
+ return { ok: true, path: join(resolved.basePath, resolved.relPath) };
52
+ }
53
+
54
+ if (!path) {
55
+ return { ok: false, status: 400, error: "path or id required" };
56
+ }
57
+
58
+ return { ok: true, path };
59
+ };
60
+
38
61
  // GET /thumbnail/image - Generate image thumbnail
39
62
  app.get(
40
63
  "/image",
@@ -62,10 +85,13 @@ app.get(
62
85
  }),
63
86
  v("query", ImageThumbnailQuerySchema),
64
87
  async (c) => {
65
- const { path, width, height, fit, position, format, quality } = c.req.valid("query");
88
+ const { path, id, width, height, fit, position, format, quality } = c.req.valid("query");
89
+
90
+ const resolved = await resolveQueryPath(path, id);
91
+ if (!resolved.ok) return c.json({ error: resolved.error }, resolved.status);
66
92
 
67
93
  // Validate path
68
- const result = await validatePath(path);
94
+ const result = await validatePath(resolved.path);
69
95
  if (!result.ok) return c.json({ error: result.error }, result.status);
70
96
 
71
97
  // Check if file exists and is a file
@@ -1,12 +1,13 @@
1
1
  import { Hono } from "hono";
2
2
  import { describeRoute } from "hono-openapi";
3
3
  import { mkdir, readdir, rm, stat, rename, readFile, writeFile } from "node:fs/promises";
4
- import { join, dirname } from "node:path";
4
+ import { join, dirname, relative } from "node:path";
5
5
  import { getSemaphore } from "@henrygd/semaphore";
6
6
  import { validatePath } from "../lib/path";
7
7
  import { applyOwnership, type Ownership } from "../lib/ownership";
8
8
  import { jsonResponse, requiresAuth } from "../lib/openapi";
9
9
  import { v } from "../lib/validator";
10
+ import { indexFile } from "../lib/index";
10
11
  import {
11
12
  UploadStartBodySchema,
12
13
  UploadStartResponseSchema,
@@ -326,6 +327,26 @@ app.post(
326
327
  const fullPath = join(meta.path, meta.filename);
327
328
  const file = Bun.file(fullPath);
328
329
  const s = await stat(fullPath);
330
+ let fileId: string | undefined;
331
+
332
+ if (config.indexEnabled) {
333
+ const pathResult = await validatePath(fullPath);
334
+ if (pathResult.ok) {
335
+ try {
336
+ const relPath = relative(pathResult.basePath, pathResult.realPath);
337
+ const outcome = await indexFile(pathResult.basePath, relPath, {
338
+ dev: s.dev,
339
+ ino: s.ino,
340
+ size: s.size,
341
+ mtimeMs: s.mtimeMs,
342
+ isDirectory: s.isDirectory(),
343
+ });
344
+ fileId = outcome.id;
345
+ } catch (err) {
346
+ console.error("[Filegate] Index update failed:", err);
347
+ }
348
+ }
349
+ }
329
350
 
330
351
  return c.json({
331
352
  completed: true as const,
@@ -338,6 +359,7 @@ app.post(
338
359
  isHidden: meta.filename.startsWith("."),
339
360
  checksum: meta.checksum,
340
361
  mimeType: file.type,
362
+ ...(fileId ? { fileId } : {}),
341
363
  },
342
364
  });
343
365
  }
package/src/index.ts CHANGED
@@ -12,6 +12,9 @@ import filesRoutes from "./handlers/files";
12
12
  import searchRoutes from "./handlers/search";
13
13
  import uploadRoutes, { cleanupOrphanedChunks } from "./handlers/upload";
14
14
  import thumbnailRoutes from "./handlers/thumbnail";
15
+ import indexRoutes from "./handlers/indexHandler";
16
+ import { initIndex, closeIndex } from "./lib/index";
17
+ import { scanAll } from "./lib/scanner";
15
18
 
16
19
  // Dev mode warning
17
20
  if (config.isDev) {
@@ -27,10 +30,42 @@ console.log(`[Filegate] ALLOWED_BASE_PATHS: ${config.allowedPaths.join(", ")}`);
27
30
  console.log(`[Filegate] MAX_UPLOAD_MB: ${config.maxUploadBytes / 1024 / 1024}`);
28
31
  console.log(`[Filegate] PORT: ${config.port}`);
29
32
 
33
+ if (config.indexEnabled) {
34
+ await initIndex(config.indexDatabaseUrl);
35
+ console.log(`[Filegate] INDEX_DATABASE_URL: ${config.indexDatabaseUrl}`);
36
+ }
37
+
30
38
  // Periodic disk cleanup for orphaned chunks (every 6h by default)
31
39
  setInterval(cleanupOrphanedChunks, config.diskCleanupIntervalMs);
32
40
  setTimeout(cleanupOrphanedChunks, 10_000); // Run 10s after startup
33
41
 
42
+ if (config.indexEnabled) {
43
+ const runRescan = async () => {
44
+ try {
45
+ const result = await scanAll();
46
+ console.log(
47
+ `[Filegate] Index rescan: scanned=${result.scanned} skipped=${result.skipped} added=${result.added} moved=${result.moved} removed=${result.removed} durationMs=${result.durationMs}`,
48
+ );
49
+ } catch (err) {
50
+ console.error("[Filegate] Index rescan failed:", err);
51
+ }
52
+ };
53
+
54
+ setInterval(runRescan, config.indexRescanIntervalMs);
55
+ setTimeout(runRescan, 15_000);
56
+
57
+ const shutdown = async () => {
58
+ try {
59
+ await closeIndex();
60
+ } catch (err) {
61
+ console.error("[Filegate] Index shutdown failed:", err);
62
+ }
63
+ };
64
+
65
+ process.on("SIGINT", shutdown);
66
+ process.on("SIGTERM", shutdown);
67
+ }
68
+
34
69
  // Main app
35
70
  const app = new Hono();
36
71
 
@@ -58,7 +93,8 @@ const api = new Hono()
58
93
  .route("/", filesRoutes)
59
94
  .route("/", searchRoutes)
60
95
  .route("/upload", uploadRoutes)
61
- .route("/thumbnail", thumbnailRoutes);
96
+ .route("/thumbnail", thumbnailRoutes)
97
+ .route("/index", indexRoutes);
62
98
 
63
99
  app.route("/files", api);
64
100
 
@@ -88,6 +124,7 @@ app.onError((err, c) => {
88
124
  export default {
89
125
  port: config.port,
90
126
  fetch: app.fetch,
127
+ development: false,
91
128
  };
92
129
 
93
130
  console.log(`[Filegate] Listening on http://localhost:${config.port}`);
@@ -0,0 +1,325 @@
1
+ import { SQL } from "bun";
2
+ import type { FileInfo } from "../schemas";
3
+
4
+ type StatInfo = {
5
+ dev: number;
6
+ ino: number;
7
+ size: number;
8
+ mtimeMs: number;
9
+ isDirectory: boolean;
10
+ };
11
+
12
+ export type IndexOutcome = {
13
+ id: string;
14
+ action: "existing" | "moved" | "added";
15
+ };
16
+
17
+ export type IndexStats = {
18
+ totalFiles: number;
19
+ totalDirs: number;
20
+ dbSizeBytes: number;
21
+ lastScanAt: number | null;
22
+ };
23
+
24
+ export type ScanState = {
25
+ mtimeMs: number;
26
+ scannedAt: number;
27
+ };
28
+
29
+ let sql: InstanceType<typeof SQL> | null = null;
30
+
31
+ const getSql = (): InstanceType<typeof SQL> => {
32
+ if (!sql) throw new Error("index not initialized");
33
+ return sql;
34
+ };
35
+
36
+ const isSqliteUrl = (url: string): boolean => {
37
+ return url === ":memory:" || url.startsWith("sqlite:") || url.startsWith("file:");
38
+ };
39
+
40
+ const escapeLike = (value: string): string => value.replace(/[\\%_]/g, "\\$&");
41
+
42
+ // --- Init ---
43
+ export const initIndex = async (databaseUrl: string): Promise<void> => {
44
+ sql = new SQL(databaseUrl);
45
+
46
+ if (isSqliteUrl(databaseUrl)) {
47
+ await sql`PRAGMA journal_mode = WAL`.catch(() => {});
48
+ await sql`PRAGMA synchronous = NORMAL`.catch(() => {});
49
+ }
50
+
51
+ await sql`
52
+ CREATE TABLE IF NOT EXISTS file_index (
53
+ id TEXT PRIMARY KEY,
54
+ base_path TEXT NOT NULL,
55
+ rel_path TEXT NOT NULL,
56
+ dev INTEGER NOT NULL,
57
+ ino INTEGER NOT NULL,
58
+ size INTEGER NOT NULL,
59
+ mtime_ms INTEGER NOT NULL,
60
+ is_dir INTEGER NOT NULL DEFAULT 0,
61
+ indexed_at INTEGER NOT NULL,
62
+ UNIQUE(base_path, rel_path)
63
+ )
64
+ `;
65
+
66
+ await sql`CREATE INDEX IF NOT EXISTS idx_file_dev_ino ON file_index(dev, ino)`;
67
+ await sql`CREATE INDEX IF NOT EXISTS idx_file_base ON file_index(base_path)`;
68
+
69
+ await sql`
70
+ CREATE TABLE IF NOT EXISTS scan_state (
71
+ base_path TEXT NOT NULL,
72
+ dir_path TEXT NOT NULL,
73
+ mtime_ms INTEGER NOT NULL,
74
+ scanned_at INTEGER NOT NULL,
75
+ PRIMARY KEY(base_path, dir_path)
76
+ )
77
+ `;
78
+ };
79
+
80
+ export const closeIndex = async (): Promise<void> => {
81
+ await sql?.close();
82
+ sql = null;
83
+ };
84
+
85
+ // --- UUID v7 Generator ---
86
+ export const generateId = (): string => {
87
+ const bytes = new Uint8Array(16);
88
+ const now = Date.now();
89
+
90
+ let time = now;
91
+ for (let i = 5; i >= 0; i--) {
92
+ bytes[i] = time & 0xff;
93
+ time = Math.floor(time / 256);
94
+ }
95
+
96
+ globalThis.crypto.getRandomValues(bytes.subarray(6));
97
+
98
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x70;
99
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
100
+
101
+ const hex = Array.from(bytes)
102
+ .map((b) => b.toString(16).padStart(2, "0"))
103
+ .join("");
104
+
105
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
106
+ };
107
+
108
+ // --- Core CRUD ---
109
+ export const indexFile = async (
110
+ basePath: string,
111
+ relPath: string,
112
+ s: StatInfo,
113
+ indexedAt: number = Date.now(),
114
+ ): Promise<IndexOutcome> => {
115
+ const db = getSql();
116
+
117
+ const [existingByPath] = await db`
118
+ SELECT id FROM file_index WHERE base_path = ${basePath} AND rel_path = ${relPath}
119
+ `;
120
+
121
+ if (existingByPath) {
122
+ await db`
123
+ UPDATE file_index
124
+ SET dev = ${s.dev}, ino = ${s.ino}, size = ${s.size}, mtime_ms = ${s.mtimeMs},
125
+ is_dir = ${s.isDirectory ? 1 : 0}, indexed_at = ${indexedAt}
126
+ WHERE id = ${existingByPath.id}
127
+ `;
128
+ return { id: existingByPath.id, action: "existing" };
129
+ }
130
+
131
+ const [existingByInode] = await db`
132
+ SELECT id FROM file_index WHERE dev = ${s.dev} AND ino = ${s.ino}
133
+ `;
134
+
135
+ if (existingByInode) {
136
+ await db`
137
+ UPDATE file_index
138
+ SET base_path = ${basePath}, rel_path = ${relPath}, size = ${s.size}, mtime_ms = ${s.mtimeMs},
139
+ is_dir = ${s.isDirectory ? 1 : 0}, indexed_at = ${indexedAt}
140
+ WHERE id = ${existingByInode.id}
141
+ `;
142
+ return { id: existingByInode.id, action: "moved" };
143
+ }
144
+
145
+ const id = generateId();
146
+ await db`
147
+ INSERT INTO file_index (id, base_path, rel_path, dev, ino, size, mtime_ms, is_dir, indexed_at)
148
+ VALUES (${id}, ${basePath}, ${relPath}, ${s.dev}, ${s.ino}, ${s.size}, ${s.mtimeMs},
149
+ ${s.isDirectory ? 1 : 0}, ${indexedAt})
150
+ `;
151
+
152
+ return { id, action: "added" };
153
+ };
154
+
155
+ export const resolveId = async (id: string): Promise<{ basePath: string; relPath: string } | null> => {
156
+ const db = getSql();
157
+ const [row] = await db`SELECT base_path, rel_path FROM file_index WHERE id = ${id}`;
158
+ return row ? { basePath: row.base_path, relPath: row.rel_path } : null;
159
+ };
160
+
161
+ export const identifyPath = async (basePath: string, relPath: string): Promise<string | null> => {
162
+ const db = getSql();
163
+ const [row] = await db`SELECT id FROM file_index WHERE base_path = ${basePath} AND rel_path = ${relPath}`;
164
+ return row?.id ?? null;
165
+ };
166
+
167
+ export const updateIndexPath = async (id: string, newBasePath: string, newRelPath: string): Promise<void> => {
168
+ const db = getSql();
169
+ await db`UPDATE file_index SET base_path = ${newBasePath}, rel_path = ${newRelPath} WHERE id = ${id}`;
170
+ };
171
+
172
+ export const removeFromIndex = async (basePath: string, relPath: string): Promise<void> => {
173
+ const db = getSql();
174
+ await db`DELETE FROM file_index WHERE base_path = ${basePath} AND rel_path = ${relPath}`;
175
+ };
176
+
177
+ export const removeFromIndexRecursive = async (basePath: string, relPath: string): Promise<void> => {
178
+ const db = getSql();
179
+
180
+ if (!relPath) {
181
+ await db`DELETE FROM file_index WHERE base_path = ${basePath}`;
182
+ return;
183
+ }
184
+
185
+ const prefix = `${escapeLike(relPath)}/`;
186
+ const likePattern = `${prefix}%`;
187
+
188
+ await db`
189
+ DELETE FROM file_index
190
+ WHERE base_path = ${basePath}
191
+ AND (rel_path = ${relPath} OR rel_path LIKE ${likePattern} ESCAPE '\\')
192
+ `;
193
+ };
194
+
195
+ // --- Scan State ---
196
+ export const getScanState = async (basePath: string, dirPath: string): Promise<ScanState | null> => {
197
+ const db = getSql();
198
+ const [row] = await db`
199
+ SELECT mtime_ms, scanned_at FROM scan_state WHERE base_path = ${basePath} AND dir_path = ${dirPath}
200
+ `;
201
+ return row ? { mtimeMs: row.mtime_ms, scannedAt: row.scanned_at } : null;
202
+ };
203
+
204
+ export const setScanState = async (basePath: string, dirPath: string, mtimeMs: number, scannedAt: number): Promise<void> => {
205
+ const db = getSql();
206
+ const [row] = await db`
207
+ SELECT 1 as present FROM scan_state WHERE base_path = ${basePath} AND dir_path = ${dirPath}
208
+ `;
209
+ if (row) {
210
+ await db`
211
+ UPDATE scan_state SET mtime_ms = ${mtimeMs}, scanned_at = ${scannedAt}
212
+ WHERE base_path = ${basePath} AND dir_path = ${dirPath}
213
+ `;
214
+ return;
215
+ }
216
+
217
+ await db`
218
+ INSERT INTO scan_state (base_path, dir_path, mtime_ms, scanned_at)
219
+ VALUES (${basePath}, ${dirPath}, ${mtimeMs}, ${scannedAt})
220
+ `;
221
+ };
222
+
223
+ export const touchIndexedAtUnderDir = async (
224
+ basePath: string,
225
+ dirPath: string,
226
+ indexedAt: number,
227
+ ): Promise<void> => {
228
+ const db = getSql();
229
+
230
+ if (!dirPath) {
231
+ await db`UPDATE file_index SET indexed_at = ${indexedAt} WHERE base_path = ${basePath}`;
232
+ return;
233
+ }
234
+
235
+ const prefix = `${escapeLike(dirPath)}/`;
236
+ const likePattern = `${prefix}%`;
237
+
238
+ await db`
239
+ UPDATE file_index
240
+ SET indexed_at = ${indexedAt}
241
+ WHERE base_path = ${basePath}
242
+ AND (rel_path = ${dirPath} OR rel_path LIKE ${likePattern} ESCAPE '\\')
243
+ `;
244
+ };
245
+
246
+ export const removeStaleEntries = async (basePath: string, beforeMs: number): Promise<number> => {
247
+ const db = getSql();
248
+ const [countRow] = await db`
249
+ SELECT COUNT(*) as count FROM file_index WHERE base_path = ${basePath} AND indexed_at < ${beforeMs}
250
+ `;
251
+ const count = Number(countRow?.count ?? 0);
252
+ await db`DELETE FROM file_index WHERE base_path = ${basePath} AND indexed_at < ${beforeMs}`;
253
+ return count;
254
+ };
255
+
256
+ // --- Batch ---
257
+ export const bulkResolve = async (
258
+ ids: string[],
259
+ ): Promise<Record<string, { basePath: string; relPath: string } | null>> => {
260
+ if (ids.length === 0) return {};
261
+
262
+ const db = getSql();
263
+ const rows = await db`SELECT id, base_path, rel_path FROM file_index WHERE id IN ${db(ids)}`;
264
+ const map = new Map<string, { basePath: string; relPath: string }>();
265
+
266
+ for (const row of rows) {
267
+ map.set(row.id, { basePath: row.base_path, relPath: row.rel_path });
268
+ }
269
+
270
+ const result: Record<string, { basePath: string; relPath: string } | null> = {};
271
+ for (const id of ids) {
272
+ result[id] = map.get(id) ?? null;
273
+ }
274
+
275
+ return result;
276
+ };
277
+
278
+ export const enrichFileInfo = async (info: FileInfo, basePath: string): Promise<FileInfo> => {
279
+ const fileId = await identifyPath(basePath, info.path);
280
+ return fileId ? { ...info, fileId } : info;
281
+ };
282
+
283
+ export const enrichFileInfoBatch = async (infos: FileInfo[], basePath: string): Promise<FileInfo[]> => {
284
+ if (infos.length === 0) return infos;
285
+ const db = getSql();
286
+ const paths = infos.map((i) => i.path);
287
+ const rows = await db`
288
+ SELECT rel_path, id FROM file_index WHERE base_path = ${basePath} AND rel_path IN ${db(paths)}
289
+ `;
290
+ const pathToId = new Map<string, string>();
291
+ for (const row of rows as { rel_path: string; id: string }[]) {
292
+ pathToId.set(row.rel_path, row.id);
293
+ }
294
+ return infos.map((info) => {
295
+ const fileId = pathToId.get(info.path);
296
+ return fileId ? { ...info, fileId } : info;
297
+ });
298
+ };
299
+
300
+ // --- Stats ---
301
+ export const getIndexStats = async (): Promise<IndexStats> => {
302
+ const db = getSql();
303
+
304
+ const [fileRow] = await db`SELECT COUNT(*) as count FROM file_index WHERE is_dir = 0`;
305
+ const [dirRow] = await db`SELECT COUNT(*) as count FROM file_index WHERE is_dir = 1`;
306
+ const [scanRow] = await db`SELECT MAX(scanned_at) as last_scan_at FROM scan_state`;
307
+
308
+ let dbSizeBytes = 0;
309
+ try {
310
+ const [pageCountRow] = await db`PRAGMA page_count`;
311
+ const [pageSizeRow] = await db`PRAGMA page_size`;
312
+ const pageCount = Number(pageCountRow?.page_count ?? 0);
313
+ const pageSize = Number(pageSizeRow?.page_size ?? 0);
314
+ dbSizeBytes = pageCount * pageSize;
315
+ } catch {
316
+ dbSizeBytes = 0;
317
+ }
318
+
319
+ return {
320
+ totalFiles: Number(fileRow?.count ?? 0),
321
+ totalDirs: Number(dirRow?.count ?? 0),
322
+ dbSizeBytes,
323
+ lastScanAt: scanRow?.last_scan_at ?? null,
324
+ };
325
+ };
@@ -28,6 +28,7 @@ export const openApiMeta: Partial<GenerateSpecOptions> = {
28
28
  { name: "Files", description: "File operations (info, download, upload, mkdir, move, copy, delete)" },
29
29
  { name: "Search", description: "File search with glob patterns" },
30
30
  { name: "Upload", description: "Resumable chunked uploads" },
31
+ { name: "Index", description: "File index management" },
31
32
  ],
32
33
  components: {
33
34
  securitySchemes: {
@@ -0,0 +1,121 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { config } from "../config";
4
+ import { validatePath } from "./path";
5
+ import {
6
+ indexFile,
7
+ getScanState,
8
+ setScanState,
9
+ touchIndexedAtUnderDir,
10
+ removeStaleEntries,
11
+ } from "./index";
12
+
13
+ export type ScanResult = {
14
+ scanned: number;
15
+ skipped: number;
16
+ added: number;
17
+ moved: number;
18
+ removed: number;
19
+ durationMs: number;
20
+ };
21
+
22
+ const emptyResult = (): ScanResult => ({
23
+ scanned: 0,
24
+ skipped: 0,
25
+ added: 0,
26
+ moved: 0,
27
+ removed: 0,
28
+ durationMs: 0,
29
+ });
30
+
31
+ export const scanBasePath = async (basePath: string): Promise<ScanResult> => {
32
+ const start = performance.now();
33
+ const scanStart = Date.now();
34
+ const result = emptyResult();
35
+
36
+ const queue: string[] = [basePath];
37
+ const concurrency = Math.max(1, config.indexScanConcurrency);
38
+
39
+ const processDir = async (dirPath: string) => {
40
+ const dirStat = await stat(dirPath).catch(() => null);
41
+ if (!dirStat || !dirStat.isDirectory()) return;
42
+
43
+ const relDir = relative(basePath, dirPath);
44
+ const scanState = await getScanState(basePath, relDir);
45
+
46
+ if (scanState && scanState.mtimeMs === dirStat.mtimeMs) {
47
+ result.skipped++;
48
+ await touchIndexedAtUnderDir(basePath, relDir, scanStart);
49
+ await setScanState(basePath, relDir, dirStat.mtimeMs, scanStart);
50
+ return;
51
+ }
52
+
53
+ result.scanned++;
54
+
55
+ const entries = await readdir(dirPath, { withFileTypes: true });
56
+ for (const entry of entries) {
57
+ const entryPath = join(dirPath, entry.name);
58
+ const entryStat = await stat(entryPath).catch(() => null);
59
+ if (!entryStat) continue;
60
+
61
+ const relPath = relative(basePath, entryPath);
62
+ const outcome = await indexFile(
63
+ basePath,
64
+ relPath,
65
+ {
66
+ dev: entryStat.dev,
67
+ ino: entryStat.ino,
68
+ size: entryStat.size,
69
+ mtimeMs: entryStat.mtimeMs,
70
+ isDirectory: entryStat.isDirectory(),
71
+ },
72
+ scanStart,
73
+ );
74
+
75
+ if (outcome.action === "added") result.added++;
76
+ if (outcome.action === "moved") result.moved++;
77
+
78
+ if (entry.isDirectory()) {
79
+ queue.push(entryPath);
80
+ }
81
+ }
82
+
83
+ await setScanState(basePath, relDir, dirStat.mtimeMs, scanStart);
84
+ };
85
+
86
+ const workers = Array.from({ length: concurrency }, async () => {
87
+ while (true) {
88
+ const dir = queue.shift();
89
+ if (!dir) break;
90
+ await processDir(dir);
91
+ }
92
+ });
93
+
94
+ await Promise.all(workers);
95
+
96
+ result.removed = await removeStaleEntries(basePath, scanStart);
97
+ result.durationMs = Math.round(performance.now() - start);
98
+
99
+ return result;
100
+ };
101
+
102
+ export const scanAll = async (): Promise<ScanResult> => {
103
+ const start = performance.now();
104
+ const aggregate = emptyResult();
105
+
106
+ for (const base of config.allowedPaths) {
107
+ const baseResult = await validatePath(base, { allowBasePath: true });
108
+ if (!baseResult.ok) {
109
+ throw new Error(`invalid base path: ${baseResult.error}`);
110
+ }
111
+ const result = await scanBasePath(baseResult.basePath);
112
+ aggregate.scanned += result.scanned;
113
+ aggregate.skipped += result.skipped;
114
+ aggregate.added += result.added;
115
+ aggregate.moved += result.moved;
116
+ aggregate.removed += result.removed;
117
+ }
118
+
119
+ aggregate.durationMs = Math.round(performance.now() - start);
120
+ return aggregate;
121
+ };
package/src/schemas.ts CHANGED
@@ -21,6 +21,7 @@ export const FileInfoSchema = z
21
21
  mtime: z.iso.datetime().describe("Last modification time in ISO 8601 format"),
22
22
  isHidden: z.boolean().describe("True if the name starts with a dot"),
23
23
  mimeType: z.string().optional().describe("MIME type of the file (only for files)"),
24
+ fileId: z.string().optional().describe("Stable file identifier (UUID v7)"),
24
25
  })
25
26
  .describe("Information about a file or directory");
26
27
 
@@ -35,24 +36,33 @@ export const DirInfoSchema = FileInfoSchema.extend({
35
36
 
36
37
  export const PathQuerySchema = z
37
38
  .object({
38
- path: z.string().min(1).describe("Absolute path to the file or directory"),
39
+ path: z.string().min(1).optional().describe("Absolute path to the file or directory"),
40
+ id: z.string().min(1).optional().describe("Stable file identifier (UUID v7)"),
41
+ })
42
+ .refine((v) => (v.path ? !v.id : !!v.id), {
43
+ message: "exactly one of 'path' or 'id' must be provided",
39
44
  })
40
45
  .describe("Query parameters for path-based operations");
41
46
 
42
47
  export const ContentQuerySchema = z
43
48
  .object({
44
- path: z.string().min(1).describe("Absolute path to the file or directory to download"),
49
+ path: z.string().min(1).optional().describe("Absolute path to the file or directory to download"),
50
+ id: z.string().min(1).optional().describe("Stable file identifier (UUID v7)"),
45
51
  inline: z
46
52
  .string()
47
53
  .optional()
48
54
  .transform((v) => v === "true")
49
55
  .describe("If 'true', display in browser instead of downloading (Content-Disposition: inline)"),
50
56
  })
57
+ .refine((v) => (v.path ? !v.id : !!v.id), {
58
+ message: "exactly one of 'path' or 'id' must be provided",
59
+ })
51
60
  .describe("Query parameters for content download");
52
61
 
53
62
  export const InfoQuerySchema = z
54
63
  .object({
55
- path: z.string().min(1).describe("Absolute path to the file or directory"),
64
+ path: z.string().min(1).optional().describe("Absolute path to the file or directory"),
65
+ id: z.string().min(1).optional().describe("Stable file identifier (UUID v7)"),
56
66
  showHidden: z
57
67
  .string()
58
68
  .optional()
@@ -64,6 +74,9 @@ export const InfoQuerySchema = z
64
74
  .transform((v) => v === "true")
65
75
  .describe("If 'true', compute recursive sizes for directories (slower, default: false)"),
66
76
  })
77
+ .refine((v) => (v.path ? !v.id : !!v.id), {
78
+ message: "exactly one of 'path' or 'id' must be provided",
79
+ })
67
80
  .describe("Query parameters for file/directory info");
68
81
 
69
82
  export const SearchQuerySchema = z
@@ -188,6 +201,36 @@ export const SearchResponseSchema = z
188
201
  })
189
202
  .describe("Complete search response with results from all searched paths");
190
203
 
204
+ export const RescanResponseSchema = z
205
+ .object({
206
+ scanned: z.number().describe("Directories scanned"),
207
+ skipped: z.number().describe("Directories skipped (mtime unchanged)"),
208
+ added: z.number().describe("New files indexed"),
209
+ moved: z.number().describe("Moves detected"),
210
+ removed: z.number().describe("Stale index entries removed"),
211
+ durationMs: z.number().describe("Total scan duration in milliseconds"),
212
+ })
213
+ .describe("Index rescan result");
214
+
215
+ export const IndexStatsSchema = z
216
+ .object({
217
+ totalFiles: z.number().describe("Number of indexed files"),
218
+ totalDirs: z.number().describe("Number of indexed directories"),
219
+ dbSizeBytes: z.number().describe("Database size in bytes (0 if unavailable)"),
220
+ lastScanAt: z.number().nullable().describe("Last scan timestamp (ms since epoch)"),
221
+ })
222
+ .describe("Index statistics");
223
+
224
+ export const BulkResolveBodySchema = z
225
+ .object({
226
+ ids: z.array(z.string().min(1)).describe("List of file IDs to resolve"),
227
+ })
228
+ .describe("Bulk resolve request");
229
+
230
+ export const BulkResolveResponseSchema = z
231
+ .record(z.string(), z.string().nullable())
232
+ .describe("Map of file ID to absolute path or null if missing");
233
+
191
234
  export const UploadStartResponseSchema = z
192
235
  .object({
193
236
  uploadId: z
@@ -278,7 +321,8 @@ export const ThumbnailFormatSchema = z.enum(["webp", "jpeg", "png", "avif"]).des
278
321
 
279
322
  export const ImageThumbnailQuerySchema = z
280
323
  .object({
281
- path: z.string().min(1).describe("Absolute path to the image file"),
324
+ path: z.string().min(1).optional().describe("Absolute path to the image file"),
325
+ id: z.string().min(1).optional().describe("Stable file identifier (UUID v7)"),
282
326
  width: z
283
327
  .string()
284
328
  .optional()
@@ -313,6 +357,9 @@ export const ImageThumbnailQuerySchema = z
313
357
  .refine((v) => v >= 1 && v <= 100, "quality must be between 1 and 100")
314
358
  .describe("Output quality 1-100 (default: 80)"),
315
359
  })
360
+ .refine((v) => (v.path ? !v.id : !!v.id), {
361
+ message: "exactly one of 'path' or 'id' must be provided",
362
+ })
316
363
  .describe("Query parameters for image thumbnail generation");
317
364
 
318
365
  export type ImageThumbnailQuery = z.infer<typeof ImageThumbnailQuerySchema>;