@valentinkolb/filegate 2.4.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/activity.d.ts +15 -0
  2. package/dist/activity.d.ts.map +1 -0
  3. package/dist/activity.js +21 -0
  4. package/dist/activity.js.map +1 -0
  5. package/dist/capabilities.d.ts +9 -0
  6. package/dist/capabilities.d.ts.map +1 -0
  7. package/dist/capabilities.js +11 -0
  8. package/dist/capabilities.js.map +1 -0
  9. package/dist/client.d.ts +37 -0
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +77 -0
  12. package/dist/client.js.map +1 -0
  13. package/dist/core.d.ts +26 -0
  14. package/dist/core.d.ts.map +1 -0
  15. package/dist/core.js +58 -0
  16. package/dist/core.js.map +1 -0
  17. package/dist/downloads.d.ts +9 -0
  18. package/dist/downloads.d.ts.map +1 -0
  19. package/dist/downloads.js +11 -0
  20. package/dist/downloads.js.map +1 -0
  21. package/dist/errors.d.ts +18 -0
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/errors.js +42 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/index-client.d.ts +17 -0
  26. package/dist/index-client.d.ts.map +1 -0
  27. package/dist/index-client.js +29 -0
  28. package/dist/index-client.js.map +1 -0
  29. package/dist/index.d.ts +14 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +7 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/nodes.d.ts +27 -0
  34. package/dist/nodes.d.ts.map +1 -0
  35. package/dist/nodes.js +71 -0
  36. package/dist/nodes.js.map +1 -0
  37. package/dist/paths.d.ts +45 -0
  38. package/dist/paths.d.ts.map +1 -0
  39. package/dist/paths.js +71 -0
  40. package/dist/paths.js.map +1 -0
  41. package/dist/search.d.ts +17 -0
  42. package/dist/search.d.ts.map +1 -0
  43. package/dist/search.js +25 -0
  44. package/dist/search.js.map +1 -0
  45. package/dist/stats.d.ts +9 -0
  46. package/dist/stats.d.ts.map +1 -0
  47. package/dist/stats.js +11 -0
  48. package/dist/stats.js.map +1 -0
  49. package/dist/transfers.d.ts +9 -0
  50. package/dist/transfers.d.ts.map +1 -0
  51. package/dist/transfers.js +14 -0
  52. package/dist/transfers.js.map +1 -0
  53. package/dist/types.d.ts +285 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +2 -0
  56. package/dist/types.js.map +1 -0
  57. package/dist/uploads.d.ts +246 -0
  58. package/dist/uploads.d.ts.map +1 -0
  59. package/dist/uploads.js +580 -0
  60. package/dist/uploads.js.map +1 -0
  61. package/dist/utils.d.ts +25 -0
  62. package/dist/utils.d.ts.map +1 -0
  63. package/dist/utils.js +41 -0
  64. package/dist/utils.js.map +1 -0
  65. package/dist/versions.d.ts +76 -0
  66. package/dist/versions.d.ts.map +1 -0
  67. package/dist/versions.js +82 -0
  68. package/dist/versions.js.map +1 -0
  69. package/package.json +36 -41
  70. package/LICENSE +0 -21
  71. package/README.md +0 -569
  72. package/src/client.ts +0 -436
  73. package/src/config.ts +0 -41
  74. package/src/handlers/files.ts +0 -696
  75. package/src/handlers/indexHandler.ts +0 -107
  76. package/src/handlers/search.ts +0 -144
  77. package/src/handlers/thumbnail.ts +0 -174
  78. package/src/handlers/upload.ts +0 -401
  79. package/src/index.ts +0 -131
  80. package/src/lib/index.ts +0 -325
  81. package/src/lib/openapi.ts +0 -48
  82. package/src/lib/ownership.ts +0 -133
  83. package/src/lib/path.ts +0 -128
  84. package/src/lib/response.ts +0 -10
  85. package/src/lib/scanner.ts +0 -121
  86. package/src/lib/validator.ts +0 -21
  87. package/src/schemas.ts +0 -376
  88. package/src/utils.ts +0 -282
package/src/lib/index.ts DELETED
@@ -1,325 +0,0 @@
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
- };
@@ -1,48 +0,0 @@
1
- import { resolver, type GenerateSpecOptions } from "hono-openapi";
2
- import type { ZodType } from "zod";
3
- import { config } from "../config";
4
-
5
- /** JSON response helper for OpenAPI docs */
6
- export const jsonResponse = <T extends ZodType>(schema: T, description: string) => ({
7
- description,
8
- content: { "application/json": { schema: resolver(schema) } },
9
- });
10
-
11
- /** Binary/stream response helper */
12
- export const binaryResponse = (mimeType: string, description: string) => ({
13
- description,
14
- content: { [mimeType]: { schema: { type: "string" as const, format: "binary" } } },
15
- });
16
-
17
- /** OpenAPI spec metadata */
18
- export const openApiMeta: Partial<GenerateSpecOptions> = {
19
- documentation: {
20
- info: {
21
- title: "File Proxy API",
22
- version: "1.0.0",
23
- description: "Secure file proxy with streaming uploads/downloads and resumable chunked uploads",
24
- },
25
- servers: [{ url: `http://localhost:${config.port}`, description: "File Proxy Server" }],
26
- tags: [
27
- { name: "Health", description: "Health check endpoint" },
28
- { name: "Files", description: "File operations (info, download, upload, mkdir, move, copy, delete)" },
29
- { name: "Search", description: "File search with glob patterns" },
30
- { name: "Upload", description: "Resumable chunked uploads" },
31
- { name: "Index", description: "File index management" },
32
- ],
33
- components: {
34
- securitySchemes: {
35
- bearerAuth: {
36
- type: "http",
37
- scheme: "bearer",
38
- description: "Bearer token authentication",
39
- },
40
- },
41
- },
42
- },
43
- };
44
-
45
- /** Security requirement for authenticated routes */
46
- export const requiresAuth = {
47
- security: [{ bearerAuth: [] as string[] }],
48
- };
@@ -1,133 +0,0 @@
1
- import { chown, chmod, readdir, stat } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { dirname } from "node:path";
4
- import { config } from "../config";
5
-
6
- export type Ownership = {
7
- uid: number;
8
- gid: number;
9
- mode: number; // octal, e.g. 0o600
10
- dirMode?: number; // octal, optional - if not set, derived from mode
11
- };
12
-
13
- // Derive directory mode from file mode (add x where r is set)
14
- // 644 → 755, 600 → 700, 664 → 775
15
- export const fileModeToDirectoryMode = (fileMode: number): number => {
16
- let dirMode = fileMode;
17
- if (fileMode & 0o400) dirMode |= 0o100; // owner read → owner exec
18
- if (fileMode & 0o040) dirMode |= 0o010; // group read → group exec
19
- if (fileMode & 0o004) dirMode |= 0o001; // other read → other exec
20
- return dirMode;
21
- };
22
-
23
- // Get effective directory mode (explicit or derived from file mode)
24
- export const getEffectiveDirMode = (ownership: Ownership): number => {
25
- return ownership.dirMode ?? fileModeToDirectoryMode(ownership.mode);
26
- };
27
-
28
- export const parseOwnershipHeaders = (req: Request): Ownership | null => {
29
- const uid = req.headers.get("X-Owner-UID");
30
- const gid = req.headers.get("X-Owner-GID");
31
- const mode = req.headers.get("X-File-Mode");
32
- const dirMode = req.headers.get("X-Dir-Mode");
33
-
34
- if (!uid || !gid || !mode) return null;
35
-
36
- const parsedMode = parseInt(mode, 8);
37
- if (isNaN(parsedMode)) return null;
38
-
39
- const parsedDirMode = dirMode ? parseInt(dirMode, 8) : undefined;
40
- if (dirMode && isNaN(parsedDirMode!)) return null;
41
-
42
- return {
43
- uid: parseInt(uid, 10),
44
- gid: parseInt(gid, 10),
45
- mode: parsedMode,
46
- dirMode: parsedDirMode,
47
- };
48
- };
49
-
50
- export const parseOwnershipBody = (body: {
51
- ownerUid?: number;
52
- ownerGid?: number;
53
- mode?: string;
54
- dirMode?: string;
55
- }): Ownership | null => {
56
- if (body.ownerUid == null || body.ownerGid == null || !body.mode) return null;
57
-
58
- const mode = parseInt(body.mode, 8);
59
- if (isNaN(mode)) return null;
60
-
61
- const dirMode = body.dirMode ? parseInt(body.dirMode, 8) : undefined;
62
- if (body.dirMode && isNaN(dirMode!)) return null;
63
-
64
- return { uid: body.ownerUid, gid: body.ownerGid, mode, dirMode };
65
- };
66
-
67
- export const applyOwnership = async (path: string, ownership: Ownership | null): Promise<string | null> => {
68
- if (!ownership) return null;
69
-
70
- const uid = config.devUid ?? ownership.uid;
71
- const gid = config.devGid ?? ownership.gid;
72
-
73
- if (config.isDev) {
74
- console.log(
75
- `[DEV] chown ${path}: ${ownership.uid}->${uid}, ${ownership.gid}->${gid}, mode=${ownership.mode.toString(8)}`,
76
- );
77
- }
78
-
79
- try {
80
- await chown(path, uid, gid);
81
- await chmod(path, ownership.mode);
82
- return null;
83
- } catch (e: any) {
84
- if (e.code === "EPERM") return "permission denied (not root?)";
85
- if (e.code === "EINVAL") return `invalid uid=${uid} or gid=${gid}`;
86
- return `ownership failed: ${e.message}`;
87
- }
88
- };
89
-
90
- // Apply ownership to directory chain from targetDir up to (not including) basePath
91
- export const applyOwnershipToNewDirs = async (
92
- targetDir: string,
93
- basePath: string,
94
- ownership: Ownership,
95
- ): Promise<void> => {
96
- const dirMode = getEffectiveDirMode(ownership);
97
- const dirOwnership: Ownership = { ...ownership, mode: dirMode };
98
-
99
- let current = targetDir;
100
- while (current !== basePath && current.startsWith(basePath + "/")) {
101
- await applyOwnership(current, dirOwnership);
102
- current = dirname(current);
103
- }
104
- };
105
-
106
- // Apply ownership recursively to a file or directory (and all its contents)
107
- export const applyOwnershipRecursive = async (path: string, ownership: Ownership | null): Promise<string | null> => {
108
- if (!ownership) return null;
109
-
110
- const s = await stat(path);
111
-
112
- if (s.isDirectory()) {
113
- // Apply directory mode to the directory itself
114
- const dirMode = getEffectiveDirMode(ownership);
115
- const dirOwnership: Ownership = { ...ownership, mode: dirMode };
116
- const err = await applyOwnership(path, dirOwnership);
117
- if (err) return err;
118
-
119
- // Recursively apply to contents
120
- const entries = await readdir(path, { withFileTypes: true });
121
- for (const entry of entries) {
122
- const fullPath = join(path, entry.name);
123
- const err = await applyOwnershipRecursive(fullPath, ownership);
124
- if (err) return err;
125
- }
126
- } else {
127
- // Apply file mode to files
128
- const err = await applyOwnership(path, ownership);
129
- if (err) return err;
130
- }
131
-
132
- return null;
133
- };
package/src/lib/path.ts DELETED
@@ -1,128 +0,0 @@
1
- import { realpath, mkdir } from "node:fs/promises";
2
- import { join, normalize, dirname, basename } from "node:path";
3
- import { config } from "../config";
4
- import { applyOwnershipToNewDirs, type Ownership } from "./ownership";
5
-
6
- export type PathResult =
7
- | { ok: true; realPath: string; basePath: string }
8
- | { ok: false; error: string; status: 400 | 403 | 404 };
9
-
10
- // Cache for resolved base paths (they don't change at runtime)
11
- const realBaseCache = new Map<string, string>();
12
-
13
- const getRealBase = async (basePath: string): Promise<string | null> => {
14
- const cached = realBaseCache.get(basePath);
15
- if (cached) return cached;
16
-
17
- try {
18
- const realBase = await realpath(basePath);
19
- realBaseCache.set(basePath, realBase);
20
- return realBase;
21
- } catch {
22
- return null;
23
- }
24
- };
25
-
26
- export type ValidatePathOptions = {
27
- /** If true, allows operating on the base path itself */
28
- allowBasePath?: boolean;
29
- /** If true, creates parent directories if they don't exist */
30
- createParents?: boolean;
31
- /** Ownership to apply to newly created directories */
32
- ownership?: Ownership | null;
33
- };
34
-
35
- /**
36
- * Validates path is within allowed base paths, resolves symlinks.
37
- * Optionally creates parent directories with ownership.
38
- */
39
- export const validatePath = async (path: string, options: ValidatePathOptions = {}): Promise<PathResult> => {
40
- const { allowBasePath = false, createParents = false, ownership = null } = options;
41
- const cleaned = normalize(path);
42
-
43
- // Find matching base
44
- let basePath: string | null = null;
45
- for (const base of config.allowedPaths) {
46
- const cleanBase = normalize(base);
47
- if (cleaned === cleanBase) {
48
- if (!allowBasePath) {
49
- return { ok: false, error: "cannot operate on base path", status: 403 };
50
- }
51
- basePath = cleanBase;
52
- break;
53
- }
54
- if (cleaned.startsWith(cleanBase + "/")) {
55
- basePath = cleanBase;
56
- break;
57
- }
58
- }
59
-
60
- if (!basePath) {
61
- return { ok: false, error: "path not allowed", status: 403 };
62
- }
63
-
64
- // Create parent directories if requested (AFTER base validation)
65
- if (createParents) {
66
- const parentPath = dirname(cleaned);
67
- await mkdir(parentPath, { recursive: true });
68
-
69
- // Apply ownership to newly created directories
70
- if (ownership) {
71
- const realBase = await getRealBase(basePath);
72
- if (realBase) {
73
- await applyOwnershipToNewDirs(parentPath, realBase, ownership);
74
- }
75
- }
76
- }
77
-
78
- // Resolve symlinks
79
- let realPath: string;
80
- try {
81
- realPath = await realpath(cleaned);
82
- } catch (e: any) {
83
- if (e.code === "ENOENT") {
84
- // Path doesn't exist yet - validate parent
85
- try {
86
- const parentReal = await realpath(dirname(cleaned));
87
- realPath = join(parentReal, basename(cleaned));
88
- } catch {
89
- return { ok: false, error: "parent path not found", status: 404 };
90
- }
91
- } else {
92
- return { ok: false, error: "path resolution failed", status: 400 };
93
- }
94
- }
95
-
96
- // Verify still within base after symlink resolution (cached)
97
- const realBase = await getRealBase(basePath);
98
- if (!realBase) {
99
- return { ok: false, error: "base path invalid", status: 500 as any };
100
- }
101
-
102
- if (realPath !== realBase && !realPath.startsWith(realBase + "/")) {
103
- return { ok: false, error: "symlink escape not allowed", status: 403 };
104
- }
105
-
106
- return { ok: true, realPath, basePath: realBase };
107
- };
108
-
109
- export type SameBaseResult =
110
- | { ok: true; realPath: string; basePath: string; realTo: string }
111
- | { ok: false; error: string; status: 400 | 403 | 404 };
112
-
113
- /**
114
- * Validates both paths are in the same base (for move/copy).
115
- */
116
- export const validateSameBase = async (from: string, to: string): Promise<SameBaseResult> => {
117
- const fromResult = await validatePath(from);
118
- if (!fromResult.ok) return fromResult;
119
-
120
- const toResult = await validatePath(to);
121
- if (!toResult.ok) return toResult;
122
-
123
- if (fromResult.basePath !== toResult.basePath) {
124
- return { ok: false, error: "cross-basepath not allowed", status: 400 };
125
- }
126
-
127
- return { ...fromResult, realTo: toResult.realPath };
128
- };
@@ -1,10 +0,0 @@
1
- export const json = <T>(data: T, status = 200) =>
2
- Response.json(data, { status });
3
-
4
- export const error = (msg: string, status = 400) =>
5
- Response.json({ error: msg }, { status });
6
-
7
- export const notFound = (msg = "not found") => error(msg, 404);
8
- export const forbidden = (msg = "forbidden") => error(msg, 403);
9
- export const badRequest = (msg: string) => error(msg, 400);
10
- export const serverError = (msg = "internal error") => error(msg, 500);