@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 +1 -1
- package/src/config.ts +4 -0
- package/src/handlers/files.ts +201 -18
- package/src/handlers/indexHandler.ts +107 -0
- package/src/handlers/search.ts +7 -0
- package/src/handlers/thumbnail.ts +28 -2
- package/src/handlers/upload.ts +23 -1
- package/src/index.ts +38 -1
- package/src/lib/index.ts +325 -0
- package/src/lib/openapi.ts +1 -0
- package/src/lib/scanner.ts +121 -0
- package/src/schemas.ts +51 -4
package/package.json
CHANGED
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() {
|
package/src/handlers/files.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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({ ...
|
|
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="${
|
|
216
|
-
"X-File-Name":
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/handlers/search.ts
CHANGED
|
@@ -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
|
package/src/handlers/upload.ts
CHANGED
|
@@ -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}`);
|
package/src/lib/index.ts
ADDED
|
@@ -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
|
+
};
|
package/src/lib/openapi.ts
CHANGED
|
@@ -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>;
|