@valentinkolb/filegate 1.1.1 → 2.1.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/README.md +64 -6
- package/package.json +2 -7
- package/src/client.ts +27 -20
- package/src/handlers/files.ts +134 -53
- package/src/lib/ownership.ts +31 -1
- package/src/schemas.ts +223 -150
package/README.md
CHANGED
|
@@ -144,6 +144,52 @@ Filegate does not validate whether the specified uid/gid exists on the system, n
|
|
|
144
144
|
|
|
145
145
|
This feature is intended for scenarios like NFS shares exposed through Filegate, where preserving the original permission structure is required.
|
|
146
146
|
|
|
147
|
+
### Transfer (Move/Copy)
|
|
148
|
+
|
|
149
|
+
The `transfer` endpoint handles both moving and copying files or directories:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// Move (rename) a file - same base path only
|
|
153
|
+
await client.transfer({
|
|
154
|
+
from: "/data/old-name.txt",
|
|
155
|
+
to: "/data/new-name.txt",
|
|
156
|
+
mode: "move",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Copy within same base path - no ownership required
|
|
160
|
+
await client.transfer({
|
|
161
|
+
from: "/data/file.txt",
|
|
162
|
+
to: "/data/backup/file.txt",
|
|
163
|
+
mode: "copy",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Copy across different base paths - ownership required
|
|
167
|
+
await client.transfer({
|
|
168
|
+
from: "/data/file.txt",
|
|
169
|
+
to: "/backup/file.txt",
|
|
170
|
+
mode: "copy",
|
|
171
|
+
uid: 1000,
|
|
172
|
+
gid: 1000,
|
|
173
|
+
fileMode: "644",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Allow overwriting existing files (default: false)
|
|
177
|
+
await client.transfer({
|
|
178
|
+
from: "/data/new-file.txt",
|
|
179
|
+
to: "/data/existing-file.txt",
|
|
180
|
+
mode: "copy",
|
|
181
|
+
ensureUniqueName: false, // Overwrite if target exists
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Rules:**
|
|
186
|
+
- `mode: "move"` - Only within the same base path (uses filesystem rename)
|
|
187
|
+
- `mode: "copy"` without ownership - Only within the same base path
|
|
188
|
+
- `mode: "copy"` with ownership - Allows cross-base copying (ownership is applied recursively)
|
|
189
|
+
- Both operations work recursively on directories
|
|
190
|
+
- `ensureUniqueName: true` (default) - Appends `-01`, `-02`, etc. if target exists
|
|
191
|
+
- `ensureUniqueName: false` - Overwrites existing target file
|
|
192
|
+
|
|
147
193
|
### Chunked Uploads
|
|
148
194
|
|
|
149
195
|
For large files, use chunked uploads. They support:
|
|
@@ -294,11 +340,24 @@ await client.mkdir({ path: "/data/new-folder", mode: "755" });
|
|
|
294
340
|
// Delete file or directory
|
|
295
341
|
await client.delete({ path: "/data/old-file.txt" });
|
|
296
342
|
|
|
297
|
-
// Move
|
|
298
|
-
await client.
|
|
343
|
+
// Transfer: Move or copy files/directories
|
|
344
|
+
await client.transfer({
|
|
345
|
+
from: "/data/old.txt",
|
|
346
|
+
to: "/data/new.txt",
|
|
347
|
+
mode: "move", // or "copy"
|
|
348
|
+
});
|
|
299
349
|
|
|
300
|
-
//
|
|
301
|
-
await client.
|
|
350
|
+
// Transfer with ownership (required for cross-base copy)
|
|
351
|
+
await client.transfer({
|
|
352
|
+
from: "/data/file.txt",
|
|
353
|
+
to: "/backup/file.txt",
|
|
354
|
+
mode: "copy",
|
|
355
|
+
uid: 1000,
|
|
356
|
+
gid: 1000,
|
|
357
|
+
fileMode: "644",
|
|
358
|
+
dirMode: "755",
|
|
359
|
+
ensureUniqueName: true, // default: append -01, -02 if target exists
|
|
360
|
+
});
|
|
302
361
|
|
|
303
362
|
// Search files with glob patterns
|
|
304
363
|
await client.glob({
|
|
@@ -396,8 +455,7 @@ All `/files/*` endpoints require `Authorization: Bearer <token>`.
|
|
|
396
455
|
| PUT | `/files/content` | Upload file |
|
|
397
456
|
| POST | `/files/mkdir` | Create directory |
|
|
398
457
|
| DELETE | `/files/delete` | Delete file or directory |
|
|
399
|
-
| POST | `/files/
|
|
400
|
-
| POST | `/files/copy` | Copy file or directory |
|
|
458
|
+
| POST | `/files/transfer` | Move or copy file/directory. Cross-base copy requires ownership |
|
|
401
459
|
| GET | `/files/search` | Search with glob pattern. Use `?directories=true` to include folders |
|
|
402
460
|
| POST | `/files/upload/start` | Start or resume chunked upload |
|
|
403
461
|
| POST | `/files/upload/chunk` | Upload a chunk |
|
package/package.json
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentinkolb/filegate",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Secure file proxy for building custom file management systems. Streaming uploads, chunked transfers, Unix permissions.",
|
|
5
|
-
"module": "index.ts",
|
|
3
|
+
"version": "2.1.0",
|
|
6
4
|
"type": "module",
|
|
7
5
|
"license": "MIT",
|
|
8
6
|
"repository": {
|
|
@@ -27,10 +25,7 @@
|
|
|
27
25
|
"test:watch": "bun test --preload ./tests/setup.ts --watch",
|
|
28
26
|
"test:unit": "bun test --preload ./tests/setup.ts tests/lib tests/schemas.test.ts tests/config.test.ts tests/utils.test.ts",
|
|
29
27
|
"test:integration": "bun test tests/integration",
|
|
30
|
-
"test:
|
|
31
|
-
"test:integration:down": "docker compose -f compose.test.yml down -v",
|
|
32
|
-
"test:integration:run": "bun run test:integration:up && bun run test:integration && bun run test:integration:down",
|
|
33
|
-
"test:all": "bun run test:unit && bun run test:integration:run"
|
|
28
|
+
"test:all": "bun run test:unit && bun run test:integration"
|
|
34
29
|
},
|
|
35
30
|
"exports": {
|
|
36
31
|
".": "./src/index.ts",
|
package/src/client.ts
CHANGED
|
@@ -89,16 +89,21 @@ export interface DeleteOptions {
|
|
|
89
89
|
path: string;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
// --- Move ---
|
|
93
|
-
export interface
|
|
94
|
-
from: string;
|
|
95
|
-
to: string;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// --- Copy ---
|
|
99
|
-
export interface CopyOptions {
|
|
92
|
+
// --- Transfer (Move/Copy) ---
|
|
93
|
+
export interface TransferOptions {
|
|
100
94
|
from: string;
|
|
101
95
|
to: string;
|
|
96
|
+
mode: "move" | "copy";
|
|
97
|
+
/** If true (default), appends -01, -02, etc. to avoid overwriting existing files */
|
|
98
|
+
ensureUniqueName?: boolean;
|
|
99
|
+
/** Owner UID - required for cross-base copy */
|
|
100
|
+
uid?: number;
|
|
101
|
+
/** Owner GID - required for cross-base copy */
|
|
102
|
+
gid?: number;
|
|
103
|
+
/** File mode (e.g. "644") - required for cross-base copy */
|
|
104
|
+
fileMode?: string;
|
|
105
|
+
/** Directory mode (e.g. "755") - if not set, derived from fileMode */
|
|
106
|
+
dirMode?: string;
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
// --- Glob (Search) ---
|
|
@@ -293,23 +298,25 @@ export class Filegate {
|
|
|
293
298
|
}
|
|
294
299
|
|
|
295
300
|
// ==========================================================================
|
|
296
|
-
// Move
|
|
301
|
+
// Transfer (Move/Copy)
|
|
297
302
|
// ==========================================================================
|
|
298
303
|
|
|
299
|
-
async
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
304
|
+
async transfer(opts: TransferOptions): Promise<FileProxyResponse<FileInfo>> {
|
|
305
|
+
const body: Record<string, unknown> = {
|
|
306
|
+
from: opts.from,
|
|
307
|
+
to: opts.to,
|
|
308
|
+
mode: opts.mode,
|
|
309
|
+
};
|
|
310
|
+
if (opts.ensureUniqueName !== undefined) body.ensureUniqueName = opts.ensureUniqueName;
|
|
311
|
+
if (opts.uid !== undefined) body.ownerUid = opts.uid;
|
|
312
|
+
if (opts.gid !== undefined) body.ownerGid = opts.gid;
|
|
313
|
+
if (opts.fileMode) body.fileMode = opts.fileMode;
|
|
314
|
+
if (opts.dirMode) body.dirMode = opts.dirMode;
|
|
307
315
|
|
|
308
|
-
|
|
309
|
-
const res = await this._fetch(`${this.url}/files/copy`, {
|
|
316
|
+
const res = await this._fetch(`${this.url}/files/transfer`, {
|
|
310
317
|
method: "POST",
|
|
311
318
|
headers: this.jsonHdrs(),
|
|
312
|
-
body: JSON.stringify(
|
|
319
|
+
body: JSON.stringify(body),
|
|
313
320
|
});
|
|
314
321
|
return this.handleResponse(res);
|
|
315
322
|
}
|
package/src/handlers/files.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { describeRoute } from "hono-openapi";
|
|
3
|
-
import { readdir, mkdir, rm, rename, cp, stat } from "node:fs/promises";
|
|
4
|
-
import { join, basename, relative } from "node:path";
|
|
3
|
+
import { readdir, mkdir, rm, rename, cp, stat, access } from "node:fs/promises";
|
|
4
|
+
import { join, basename, relative, dirname, extname } from "node:path";
|
|
5
5
|
import sanitizeFilename from "sanitize-filename";
|
|
6
6
|
import { validatePath, validateSameBase } from "../lib/path";
|
|
7
|
-
import { parseOwnershipBody, applyOwnership } from "../lib/ownership";
|
|
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
10
|
import {
|
|
@@ -15,8 +15,7 @@ import {
|
|
|
15
15
|
PathQuerySchema,
|
|
16
16
|
ContentQuerySchema,
|
|
17
17
|
MkdirBodySchema,
|
|
18
|
-
|
|
19
|
-
CopyBodySchema,
|
|
18
|
+
TransferBodySchema,
|
|
20
19
|
UploadFileHeadersSchema,
|
|
21
20
|
type FileInfo,
|
|
22
21
|
} from "../schemas";
|
|
@@ -24,6 +23,35 @@ import { config } from "../config";
|
|
|
24
23
|
|
|
25
24
|
const app = new Hono();
|
|
26
25
|
|
|
26
|
+
// Generate a unique path by appending -01, -02, etc. if target exists
|
|
27
|
+
const getUniquePath = async (targetPath: string): Promise<string> => {
|
|
28
|
+
// Check if target exists
|
|
29
|
+
try {
|
|
30
|
+
await access(targetPath);
|
|
31
|
+
} catch {
|
|
32
|
+
// Doesn't exist, use as-is
|
|
33
|
+
return targetPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dir = dirname(targetPath);
|
|
37
|
+
const ext = extname(targetPath);
|
|
38
|
+
const base = basename(targetPath, ext);
|
|
39
|
+
|
|
40
|
+
for (let i = 1; i <= 99; i++) {
|
|
41
|
+
const suffix = i.toString().padStart(2, "0");
|
|
42
|
+
const newPath = join(dir, `${base}-${suffix}${ext}`);
|
|
43
|
+
try {
|
|
44
|
+
await access(newPath);
|
|
45
|
+
} catch {
|
|
46
|
+
return newPath;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fallback: use timestamp if all 99 are taken
|
|
51
|
+
const timestamp = Date.now();
|
|
52
|
+
return join(dir, `${base}-${timestamp}${ext}`);
|
|
53
|
+
};
|
|
54
|
+
|
|
27
55
|
// Cross-platform directory size using `du` command
|
|
28
56
|
const getDirSize = async (dirPath: string): Promise<number> => {
|
|
29
57
|
const isMac = process.platform === "darwin";
|
|
@@ -47,19 +75,20 @@ const getDirSize = async (dirPath: string): Promise<number> => {
|
|
|
47
75
|
}
|
|
48
76
|
};
|
|
49
77
|
|
|
50
|
-
const getFileInfo = async (path: string, relativeTo?: string): Promise<FileInfo> => {
|
|
78
|
+
const getFileInfo = async (path: string, relativeTo?: string, computeDirSize?: boolean): Promise<FileInfo> => {
|
|
51
79
|
const file = Bun.file(path);
|
|
52
80
|
const s = await stat(path);
|
|
53
81
|
const name = basename(path);
|
|
82
|
+
const isDir = s.isDirectory();
|
|
54
83
|
|
|
55
84
|
return {
|
|
56
85
|
name,
|
|
57
86
|
path: relativeTo ? relative(relativeTo, path) : path,
|
|
58
|
-
type:
|
|
59
|
-
size:
|
|
87
|
+
type: isDir ? "directory" : "file",
|
|
88
|
+
size: isDir ? (computeDirSize ? await getDirSize(path) : 0) : s.size,
|
|
60
89
|
mtime: s.mtime.toISOString(),
|
|
61
90
|
isHidden: name.startsWith("."),
|
|
62
|
-
mimeType:
|
|
91
|
+
mimeType: isDir ? undefined : file.type,
|
|
63
92
|
};
|
|
64
93
|
};
|
|
65
94
|
|
|
@@ -102,12 +131,13 @@ app.get(
|
|
|
102
131
|
await Promise.all(
|
|
103
132
|
entries
|
|
104
133
|
.filter((e) => showHidden || !e.name.startsWith("."))
|
|
105
|
-
.map((e) => getFileInfo(join(result.realPath, e.name), result.realPath).catch(() => null)),
|
|
134
|
+
.map((e) => getFileInfo(join(result.realPath, e.name), result.realPath, true).catch(() => null)),
|
|
106
135
|
)
|
|
107
136
|
).filter((item): item is FileInfo => item !== null);
|
|
108
137
|
|
|
109
138
|
const info = await getFileInfo(result.realPath);
|
|
110
|
-
|
|
139
|
+
const totalSize = items.reduce((sum, item) => sum + item.size, 0);
|
|
140
|
+
return c.json({ ...info, size: totalSize, items, total: items.length });
|
|
111
141
|
},
|
|
112
142
|
);
|
|
113
143
|
|
|
@@ -357,73 +387,124 @@ app.delete(
|
|
|
357
387
|
},
|
|
358
388
|
);
|
|
359
389
|
|
|
360
|
-
// POST /
|
|
390
|
+
// POST /transfer
|
|
361
391
|
app.post(
|
|
362
|
-
"/
|
|
392
|
+
"/transfer",
|
|
363
393
|
describeRoute({
|
|
364
394
|
tags: ["Files"],
|
|
365
|
-
summary: "Move
|
|
366
|
-
description:
|
|
395
|
+
summary: "Move or copy file/directory",
|
|
396
|
+
description:
|
|
397
|
+
"Transfer files between locations. Mode 'move' requires same base path. " +
|
|
398
|
+
"Mode 'copy' allows cross-base transfer when ownership (ownerUid, ownerGid, fileMode) is provided.",
|
|
367
399
|
...requiresAuth,
|
|
368
400
|
responses: {
|
|
369
|
-
200: jsonResponse(FileInfoSchema, "
|
|
401
|
+
200: jsonResponse(FileInfoSchema, "Transferred"),
|
|
370
402
|
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
371
403
|
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
372
404
|
404: jsonResponse(ErrorSchema, "Not found"),
|
|
373
405
|
},
|
|
374
406
|
}),
|
|
375
|
-
v("json",
|
|
407
|
+
v("json", TransferBodySchema),
|
|
376
408
|
async (c) => {
|
|
377
|
-
const { from, to } = c.req.valid("json");
|
|
409
|
+
const { from, to, mode, ensureUniqueName, ownerUid, ownerGid, fileMode, dirMode } = c.req.valid("json");
|
|
378
410
|
|
|
379
|
-
|
|
380
|
-
|
|
411
|
+
// Build ownership if provided
|
|
412
|
+
const ownership =
|
|
413
|
+
ownerUid != null && ownerGid != null && fileMode != null
|
|
414
|
+
? {
|
|
415
|
+
uid: ownerUid,
|
|
416
|
+
gid: ownerGid,
|
|
417
|
+
mode: parseInt(fileMode, 8),
|
|
418
|
+
dirMode: dirMode ? parseInt(dirMode, 8) : undefined,
|
|
419
|
+
}
|
|
420
|
+
: null;
|
|
381
421
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return c.json({ error:
|
|
422
|
+
// Move always requires same base
|
|
423
|
+
if (mode === "move") {
|
|
424
|
+
const result = await validateSameBase(from, to);
|
|
425
|
+
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
await stat(result.realPath);
|
|
429
|
+
} catch {
|
|
430
|
+
return c.json({ error: "source not found" }, 404);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const targetPath = ensureUniqueName ? await getUniquePath(result.realTo) : result.realTo;
|
|
434
|
+
|
|
435
|
+
await mkdir(join(targetPath, ".."), { recursive: true });
|
|
436
|
+
await rename(result.realPath, targetPath);
|
|
437
|
+
|
|
438
|
+
// Apply ownership if provided (for move within same base)
|
|
439
|
+
if (ownership) {
|
|
440
|
+
const ownershipError = await applyOwnershipRecursive(targetPath, ownership);
|
|
441
|
+
if (ownershipError) {
|
|
442
|
+
return c.json({ error: ownershipError }, 500);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return c.json(await getFileInfo(targetPath));
|
|
386
447
|
}
|
|
387
448
|
|
|
388
|
-
|
|
389
|
-
await
|
|
449
|
+
// Copy: check if same base or cross-base with ownership
|
|
450
|
+
const sameBaseResult = await validateSameBase(from, to);
|
|
390
451
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
452
|
+
if (sameBaseResult.ok) {
|
|
453
|
+
// Same base - no ownership required
|
|
454
|
+
try {
|
|
455
|
+
await stat(sameBaseResult.realPath);
|
|
456
|
+
} catch {
|
|
457
|
+
return c.json({ error: "source not found" }, 404);
|
|
458
|
+
}
|
|
394
459
|
|
|
395
|
-
|
|
396
|
-
app.post(
|
|
397
|
-
"/copy",
|
|
398
|
-
describeRoute({
|
|
399
|
-
tags: ["Files"],
|
|
400
|
-
summary: "Copy file or directory",
|
|
401
|
-
description: "Copy within same base path only",
|
|
402
|
-
...requiresAuth,
|
|
403
|
-
responses: {
|
|
404
|
-
200: jsonResponse(FileInfoSchema, "Copied"),
|
|
405
|
-
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
406
|
-
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
407
|
-
404: jsonResponse(ErrorSchema, "Not found"),
|
|
408
|
-
},
|
|
409
|
-
}),
|
|
410
|
-
v("json", CopyBodySchema),
|
|
411
|
-
async (c) => {
|
|
412
|
-
const { from, to } = c.req.valid("json");
|
|
460
|
+
const targetPath = ensureUniqueName ? await getUniquePath(sameBaseResult.realTo) : sameBaseResult.realTo;
|
|
413
461
|
|
|
414
|
-
|
|
415
|
-
|
|
462
|
+
await mkdir(join(targetPath, ".."), { recursive: true });
|
|
463
|
+
await cp(sameBaseResult.realPath, targetPath, { recursive: true });
|
|
464
|
+
|
|
465
|
+
// Apply ownership if provided
|
|
466
|
+
if (ownership) {
|
|
467
|
+
const ownershipError = await applyOwnershipRecursive(targetPath, ownership);
|
|
468
|
+
if (ownershipError) {
|
|
469
|
+
await rm(targetPath, { recursive: true }).catch(() => {});
|
|
470
|
+
return c.json({ error: ownershipError }, 500);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return c.json(await getFileInfo(targetPath));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Cross-base copy - ownership is required
|
|
478
|
+
if (!ownership) {
|
|
479
|
+
return c.json({ error: "cross-base copy requires ownership (ownerUid, ownerGid, fileMode)" }, 400);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Validate source and destination separately
|
|
483
|
+
const fromResult = await validatePath(from);
|
|
484
|
+
if (!fromResult.ok) return c.json({ error: fromResult.error }, fromResult.status);
|
|
485
|
+
|
|
486
|
+
const toResult = await validatePath(to, { createParents: true, ownership });
|
|
487
|
+
if (!toResult.ok) return c.json({ error: toResult.error }, toResult.status);
|
|
416
488
|
|
|
417
489
|
try {
|
|
418
|
-
await stat(
|
|
490
|
+
await stat(fromResult.realPath);
|
|
419
491
|
} catch {
|
|
420
492
|
return c.json({ error: "source not found" }, 404);
|
|
421
493
|
}
|
|
422
494
|
|
|
423
|
-
await
|
|
424
|
-
|
|
495
|
+
const targetPath = ensureUniqueName ? await getUniquePath(toResult.realPath) : toResult.realPath;
|
|
496
|
+
|
|
497
|
+
await mkdir(join(targetPath, ".."), { recursive: true });
|
|
498
|
+
await cp(fromResult.realPath, targetPath, { recursive: true });
|
|
499
|
+
|
|
500
|
+
// Apply ownership recursively to copied content
|
|
501
|
+
const ownershipError = await applyOwnershipRecursive(targetPath, ownership);
|
|
502
|
+
if (ownershipError) {
|
|
503
|
+
await rm(targetPath, { recursive: true }).catch(() => {});
|
|
504
|
+
return c.json({ error: ownershipError }, 500);
|
|
505
|
+
}
|
|
425
506
|
|
|
426
|
-
return c.json(await getFileInfo(
|
|
507
|
+
return c.json(await getFileInfo(targetPath));
|
|
427
508
|
},
|
|
428
509
|
);
|
|
429
510
|
|
package/src/lib/ownership.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { chown, chmod } from "node:fs/promises";
|
|
1
|
+
import { chown, chmod, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
import { config } from "../config";
|
|
4
5
|
|
|
@@ -101,3 +102,32 @@ export const applyOwnershipToNewDirs = async (
|
|
|
101
102
|
current = dirname(current);
|
|
102
103
|
}
|
|
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/schemas.ts
CHANGED
|
@@ -4,71 +4,89 @@ import { z } from "zod";
|
|
|
4
4
|
// Common
|
|
5
5
|
// ============================================================================
|
|
6
6
|
|
|
7
|
-
export const ErrorSchema = z
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
7
|
+
export const ErrorSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
error: z.string().describe("Error message describing what went wrong"),
|
|
10
|
+
})
|
|
11
|
+
.describe("Error response returned when a request fails");
|
|
12
|
+
|
|
13
|
+
export const FileTypeSchema = z.enum(["file", "directory"]).describe("Type of filesystem entry");
|
|
14
|
+
|
|
15
|
+
export const FileInfoSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
name: z.string().describe("Filename or directory name"),
|
|
18
|
+
path: z.string().describe("Relative path from the base directory"),
|
|
19
|
+
type: FileTypeSchema,
|
|
20
|
+
size: z.number().describe("File size in bytes, or total directory size for directories"),
|
|
21
|
+
mtime: z.iso.datetime().describe("Last modification time in ISO 8601 format"),
|
|
22
|
+
isHidden: z.boolean().describe("True if the name starts with a dot"),
|
|
23
|
+
mimeType: z.string().optional().describe("MIME type of the file (only for files)"),
|
|
24
|
+
})
|
|
25
|
+
.describe("Information about a file or directory");
|
|
22
26
|
|
|
23
27
|
export const DirInfoSchema = FileInfoSchema.extend({
|
|
24
|
-
items: z.array(FileInfoSchema),
|
|
25
|
-
total: z.number(),
|
|
26
|
-
});
|
|
28
|
+
items: z.array(FileInfoSchema).describe("List of files and directories in this directory"),
|
|
29
|
+
total: z.number().describe("Total number of items in the directory"),
|
|
30
|
+
}).describe("Directory information including its contents");
|
|
27
31
|
|
|
28
32
|
// ============================================================================
|
|
29
33
|
// Query Params
|
|
30
34
|
// ============================================================================
|
|
31
35
|
|
|
32
|
-
export const PathQuerySchema = z
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
36
|
+
export const PathQuerySchema = z
|
|
37
|
+
.object({
|
|
38
|
+
path: z.string().min(1).describe("Absolute path to the file or directory"),
|
|
39
|
+
})
|
|
40
|
+
.describe("Query parameters for path-based operations");
|
|
41
|
+
|
|
42
|
+
export const ContentQuerySchema = z
|
|
43
|
+
.object({
|
|
44
|
+
path: z.string().min(1).describe("Absolute path to the file or directory to download"),
|
|
45
|
+
inline: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.transform((v) => v === "true")
|
|
49
|
+
.describe("If 'true', display in browser instead of downloading (Content-Disposition: inline)"),
|
|
50
|
+
})
|
|
51
|
+
.describe("Query parameters for content download");
|
|
52
|
+
|
|
53
|
+
export const InfoQuerySchema = z
|
|
54
|
+
.object({
|
|
55
|
+
path: z.string().min(1).describe("Absolute path to the file or directory"),
|
|
56
|
+
showHidden: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.transform((v) => v === "true")
|
|
60
|
+
.describe("If 'true', include hidden files (starting with dot) in directory listings"),
|
|
61
|
+
})
|
|
62
|
+
.describe("Query parameters for file/directory info");
|
|
63
|
+
|
|
64
|
+
export const SearchQuerySchema = z
|
|
65
|
+
.object({
|
|
66
|
+
paths: z.string().min(1).describe("Comma-separated list of base paths to search in"),
|
|
67
|
+
pattern: z.string().min(1).max(500).describe("Glob pattern to match files (e.g., '*.txt', '**/*.pdf')"),
|
|
68
|
+
showHidden: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.transform((v) => v === "true")
|
|
72
|
+
.describe("If 'true', include hidden files in search results"),
|
|
73
|
+
limit: z
|
|
74
|
+
.string()
|
|
75
|
+
.optional()
|
|
76
|
+
.transform((v) => (v ? parseInt(v, 10) : undefined))
|
|
77
|
+
.describe("Maximum number of results to return"),
|
|
78
|
+
files: z
|
|
79
|
+
.string()
|
|
80
|
+
.optional()
|
|
81
|
+
.transform((v) => v !== "false")
|
|
82
|
+
.describe("If 'false', exclude files from results (default: true)"),
|
|
83
|
+
directories: z
|
|
84
|
+
.string()
|
|
85
|
+
.optional()
|
|
86
|
+
.transform((v) => v === "true")
|
|
87
|
+
.describe("If 'true', include directories in results (default: false)"),
|
|
88
|
+
})
|
|
89
|
+
.describe("Query parameters for glob-based file search");
|
|
72
90
|
|
|
73
91
|
/** Count recursive wildcards (**) in a glob pattern */
|
|
74
92
|
export const countRecursiveWildcards = (pattern: string): number => {
|
|
@@ -79,108 +97,163 @@ export const countRecursiveWildcards = (pattern: string): number => {
|
|
|
79
97
|
// Request Bodies
|
|
80
98
|
// ============================================================================
|
|
81
99
|
|
|
82
|
-
export const MkdirBodySchema = z
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
100
|
+
export const MkdirBodySchema = z
|
|
101
|
+
.object({
|
|
102
|
+
path: z.string().min(1).describe("Absolute path of the directory to create"),
|
|
103
|
+
ownerUid: z.number().int().optional().describe("Unix user ID to set as owner"),
|
|
104
|
+
ownerGid: z.number().int().optional().describe("Unix group ID to set as owner"),
|
|
105
|
+
mode: z
|
|
106
|
+
.string()
|
|
107
|
+
.regex(/^[0-7]{3,4}$/)
|
|
108
|
+
.optional()
|
|
109
|
+
.describe("Unix permission mode (e.g., '755' or '0755')"),
|
|
110
|
+
})
|
|
111
|
+
.describe("Request body for creating a directory");
|
|
112
|
+
|
|
113
|
+
export const TransferModeSchema = z
|
|
114
|
+
.enum(["move", "copy"])
|
|
115
|
+
.describe("Transfer operation type: 'move' (rename) or 'copy' (duplicate)");
|
|
116
|
+
|
|
117
|
+
export const TransferBodySchema = z
|
|
118
|
+
.object({
|
|
119
|
+
from: z.string().min(1).describe("Source path of the file or directory"),
|
|
120
|
+
to: z.string().min(1).describe("Destination path for the file or directory"),
|
|
121
|
+
mode: TransferModeSchema,
|
|
122
|
+
ensureUniqueName: z
|
|
123
|
+
.boolean()
|
|
124
|
+
.default(true)
|
|
125
|
+
.describe("If true, append -01, -02, etc. to avoid overwriting existing files (default: true)"),
|
|
126
|
+
ownerUid: z.number().int().optional().describe("Unix user ID for ownership (required for cross-base copy)"),
|
|
127
|
+
ownerGid: z.number().int().optional().describe("Unix group ID for ownership (required for cross-base copy)"),
|
|
128
|
+
fileMode: z
|
|
129
|
+
.string()
|
|
130
|
+
.regex(/^[0-7]{3,4}$/)
|
|
131
|
+
.optional()
|
|
132
|
+
.describe("Unix permission mode for files (e.g., '644', required for cross-base copy)"),
|
|
133
|
+
dirMode: z
|
|
134
|
+
.string()
|
|
135
|
+
.regex(/^[0-7]{3,4}$/)
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("Unix permission mode for directories (e.g., '755', defaults to fileMode if not set)"),
|
|
138
|
+
})
|
|
139
|
+
.describe("Request body for moving or copying files/directories");
|
|
140
|
+
|
|
141
|
+
export const UploadStartBodySchema = z
|
|
142
|
+
.object({
|
|
143
|
+
path: z.string().min(1).describe("Directory path where the file will be uploaded"),
|
|
144
|
+
filename: z.string().min(1).describe("Name of the file to upload"),
|
|
145
|
+
size: z.number().int().positive().describe("Total size of the file in bytes"),
|
|
146
|
+
checksum: z
|
|
147
|
+
.string()
|
|
148
|
+
.regex(/^sha256:[a-f0-9]{64}$/)
|
|
149
|
+
.describe("SHA-256 checksum of the entire file (format: 'sha256:<64 hex chars>')"),
|
|
150
|
+
chunkSize: z.number().int().positive().describe("Size of each chunk in bytes"),
|
|
151
|
+
ownerUid: z.number().int().optional().describe("Unix user ID to set as owner"),
|
|
152
|
+
ownerGid: z.number().int().optional().describe("Unix group ID to set as owner"),
|
|
153
|
+
mode: z
|
|
154
|
+
.string()
|
|
155
|
+
.regex(/^[0-7]{3,4}$/)
|
|
156
|
+
.optional()
|
|
157
|
+
.describe("Unix permission mode for the uploaded file (e.g., '644')"),
|
|
158
|
+
dirMode: z
|
|
159
|
+
.string()
|
|
160
|
+
.regex(/^[0-7]{3,4}$/)
|
|
161
|
+
.optional()
|
|
162
|
+
.describe("Unix permission mode for auto-created parent directories (e.g., '755')"),
|
|
163
|
+
})
|
|
164
|
+
.describe("Request body to start or resume a chunked upload");
|
|
119
165
|
|
|
120
166
|
// ============================================================================
|
|
121
167
|
// Response Schemas
|
|
122
168
|
// ============================================================================
|
|
123
169
|
|
|
124
|
-
export const SearchResultSchema = z
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
170
|
+
export const SearchResultSchema = z
|
|
171
|
+
.object({
|
|
172
|
+
basePath: z.string().describe("Base path that was searched"),
|
|
173
|
+
files: z.array(FileInfoSchema).describe("List of matching files and directories"),
|
|
174
|
+
total: z.number().describe("Number of matches found in this base path"),
|
|
175
|
+
hasMore: z.boolean().describe("True if there are more results beyond the limit"),
|
|
176
|
+
})
|
|
177
|
+
.describe("Search results for a single base path");
|
|
178
|
+
|
|
179
|
+
export const SearchResponseSchema = z
|
|
180
|
+
.object({
|
|
181
|
+
results: z.array(SearchResultSchema).describe("Search results grouped by base path"),
|
|
182
|
+
totalFiles: z.number().describe("Total number of matches across all base paths"),
|
|
183
|
+
})
|
|
184
|
+
.describe("Complete search response with results from all searched paths");
|
|
185
|
+
|
|
186
|
+
export const UploadStartResponseSchema = z
|
|
187
|
+
.object({
|
|
188
|
+
uploadId: z
|
|
189
|
+
.string()
|
|
190
|
+
.regex(/^[a-f0-9]{16}$/)
|
|
191
|
+
.describe("Unique identifier for this upload session"),
|
|
192
|
+
totalChunks: z.number().describe("Total number of chunks expected"),
|
|
193
|
+
chunkSize: z.number().describe("Size of each chunk in bytes"),
|
|
194
|
+
uploadedChunks: z.array(z.number()).describe("Indices of chunks already uploaded (for resume)"),
|
|
195
|
+
completed: z.literal(false).describe("Always false for start response"),
|
|
196
|
+
})
|
|
197
|
+
.describe("Response when starting or resuming a chunked upload");
|
|
198
|
+
|
|
199
|
+
export const UploadChunkProgressSchema = z
|
|
200
|
+
.object({
|
|
201
|
+
chunkIndex: z.number().describe("Index of the chunk that was just uploaded"),
|
|
202
|
+
uploadedChunks: z.array(z.number()).describe("All chunk indices uploaded so far"),
|
|
203
|
+
completed: z.literal(false).describe("False while upload is still in progress"),
|
|
204
|
+
})
|
|
205
|
+
.describe("Response after uploading a chunk (upload not yet complete)");
|
|
206
|
+
|
|
207
|
+
export const UploadChunkCompleteSchema = z
|
|
208
|
+
.object({
|
|
209
|
+
completed: z.literal(true).describe("True when all chunks have been uploaded"),
|
|
210
|
+
file: FileInfoSchema.extend({
|
|
211
|
+
checksum: z.string().describe("SHA-256 checksum of the assembled file"),
|
|
212
|
+
}).describe("Information about the completed file"),
|
|
213
|
+
})
|
|
214
|
+
.describe("Response after uploading the final chunk");
|
|
215
|
+
|
|
216
|
+
export const UploadChunkResponseSchema = z
|
|
217
|
+
.union([UploadChunkProgressSchema, UploadChunkCompleteSchema])
|
|
218
|
+
.describe("Response after uploading a chunk (either progress or completion)");
|
|
156
219
|
|
|
157
220
|
// ============================================================================
|
|
158
221
|
// Header Schemas
|
|
159
222
|
// ============================================================================
|
|
160
223
|
|
|
161
|
-
export const UploadFileHeadersSchema = z
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
"
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
224
|
+
export const UploadFileHeadersSchema = z
|
|
225
|
+
.object({
|
|
226
|
+
"x-file-path": z.string().min(1).describe("Directory path where the file will be uploaded"),
|
|
227
|
+
"x-file-name": z.string().min(1).describe("Name of the file to upload"),
|
|
228
|
+
"x-owner-uid": z.string().regex(/^\d+$/).transform(Number).optional().describe("Unix user ID to set as owner"),
|
|
229
|
+
"x-owner-gid": z.string().regex(/^\d+$/).transform(Number).optional().describe("Unix group ID to set as owner"),
|
|
230
|
+
"x-file-mode": z
|
|
231
|
+
.string()
|
|
232
|
+
.regex(/^[0-7]{3,4}$/)
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("Unix permission mode for the file (e.g., '644')"),
|
|
235
|
+
"x-dir-mode": z
|
|
236
|
+
.string()
|
|
237
|
+
.regex(/^[0-7]{3,4}$/)
|
|
238
|
+
.optional()
|
|
239
|
+
.describe("Unix permission mode for auto-created directories (e.g., '755')"),
|
|
240
|
+
})
|
|
241
|
+
.describe("Headers for simple file upload");
|
|
242
|
+
|
|
243
|
+
export const UploadChunkHeadersSchema = z
|
|
244
|
+
.object({
|
|
245
|
+
"x-upload-id": z
|
|
246
|
+
.string()
|
|
247
|
+
.regex(/^[a-f0-9]{16}$/)
|
|
248
|
+
.describe("Upload session ID from the start response"),
|
|
249
|
+
"x-chunk-index": z.string().regex(/^\d+$/).transform(Number).describe("Zero-based index of this chunk"),
|
|
250
|
+
"x-chunk-checksum": z
|
|
251
|
+
.string()
|
|
252
|
+
.regex(/^sha256:[a-f0-9]{64}$/)
|
|
253
|
+
.optional()
|
|
254
|
+
.describe("SHA-256 checksum of this chunk for verification (format: 'sha256:<64 hex chars>')"),
|
|
255
|
+
})
|
|
256
|
+
.describe("Headers for uploading a chunk");
|
|
184
257
|
|
|
185
258
|
// ============================================================================
|
|
186
259
|
// Types
|