@valentinkolb/filegate 1.1.1 → 2.0.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 +53 -6
- package/package.json +2 -7
- package/src/client.ts +24 -20
- package/src/handlers/files.ts +95 -50
- package/src/lib/ownership.ts +31 -1
- package/src/schemas.ts +13 -5
package/README.md
CHANGED
|
@@ -144,6 +144,42 @@ 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
|
+
|
|
177
|
+
**Rules:**
|
|
178
|
+
- `mode: "move"` - Only within the same base path (uses filesystem rename)
|
|
179
|
+
- `mode: "copy"` without ownership - Only within the same base path
|
|
180
|
+
- `mode: "copy"` with ownership - Allows cross-base copying (ownership is applied recursively)
|
|
181
|
+
- Both operations work recursively on directories
|
|
182
|
+
|
|
147
183
|
### Chunked Uploads
|
|
148
184
|
|
|
149
185
|
For large files, use chunked uploads. They support:
|
|
@@ -294,11 +330,23 @@ await client.mkdir({ path: "/data/new-folder", mode: "755" });
|
|
|
294
330
|
// Delete file or directory
|
|
295
331
|
await client.delete({ path: "/data/old-file.txt" });
|
|
296
332
|
|
|
297
|
-
// Move
|
|
298
|
-
await client.
|
|
333
|
+
// Transfer: Move or copy files/directories
|
|
334
|
+
await client.transfer({
|
|
335
|
+
from: "/data/old.txt",
|
|
336
|
+
to: "/data/new.txt",
|
|
337
|
+
mode: "move", // or "copy"
|
|
338
|
+
});
|
|
299
339
|
|
|
300
|
-
//
|
|
301
|
-
await client.
|
|
340
|
+
// Transfer with ownership (required for cross-base copy)
|
|
341
|
+
await client.transfer({
|
|
342
|
+
from: "/data/file.txt",
|
|
343
|
+
to: "/backup/file.txt",
|
|
344
|
+
mode: "copy",
|
|
345
|
+
uid: 1000,
|
|
346
|
+
gid: 1000,
|
|
347
|
+
fileMode: "644",
|
|
348
|
+
dirMode: "755",
|
|
349
|
+
});
|
|
302
350
|
|
|
303
351
|
// Search files with glob patterns
|
|
304
352
|
await client.glob({
|
|
@@ -396,8 +444,7 @@ All `/files/*` endpoints require `Authorization: Bearer <token>`.
|
|
|
396
444
|
| PUT | `/files/content` | Upload file |
|
|
397
445
|
| POST | `/files/mkdir` | Create directory |
|
|
398
446
|
| DELETE | `/files/delete` | Delete file or directory |
|
|
399
|
-
| POST | `/files/
|
|
400
|
-
| POST | `/files/copy` | Copy file or directory |
|
|
447
|
+
| POST | `/files/transfer` | Move or copy file/directory. Cross-base copy requires ownership |
|
|
401
448
|
| GET | `/files/search` | Search with glob pattern. Use `?directories=true` to include folders |
|
|
402
449
|
| POST | `/files/upload/start` | Start or resume chunked upload |
|
|
403
450
|
| 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.0.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,19 @@ 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
|
+
/** Owner UID - required for cross-base copy */
|
|
98
|
+
uid?: number;
|
|
99
|
+
/** Owner GID - required for cross-base copy */
|
|
100
|
+
gid?: number;
|
|
101
|
+
/** File mode (e.g. "644") - required for cross-base copy */
|
|
102
|
+
fileMode?: string;
|
|
103
|
+
/** Directory mode (e.g. "755") - if not set, derived from fileMode */
|
|
104
|
+
dirMode?: string;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
// --- Glob (Search) ---
|
|
@@ -293,23 +296,24 @@ export class Filegate {
|
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
// ==========================================================================
|
|
296
|
-
// Move
|
|
299
|
+
// Transfer (Move/Copy)
|
|
297
300
|
// ==========================================================================
|
|
298
301
|
|
|
299
|
-
async
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
302
|
+
async transfer(opts: TransferOptions): Promise<FileProxyResponse<FileInfo>> {
|
|
303
|
+
const body: Record<string, unknown> = {
|
|
304
|
+
from: opts.from,
|
|
305
|
+
to: opts.to,
|
|
306
|
+
mode: opts.mode,
|
|
307
|
+
};
|
|
308
|
+
if (opts.uid !== undefined) body.ownerUid = opts.uid;
|
|
309
|
+
if (opts.gid !== undefined) body.ownerGid = opts.gid;
|
|
310
|
+
if (opts.fileMode) body.fileMode = opts.fileMode;
|
|
311
|
+
if (opts.dirMode) body.dirMode = opts.dirMode;
|
|
307
312
|
|
|
308
|
-
|
|
309
|
-
const res = await this._fetch(`${this.url}/files/copy`, {
|
|
313
|
+
const res = await this._fetch(`${this.url}/files/transfer`, {
|
|
310
314
|
method: "POST",
|
|
311
315
|
headers: this.jsonHdrs(),
|
|
312
|
-
body: JSON.stringify(
|
|
316
|
+
body: JSON.stringify(body),
|
|
313
317
|
});
|
|
314
318
|
return this.handleResponse(res);
|
|
315
319
|
}
|
package/src/handlers/files.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readdir, mkdir, rm, rename, cp, stat } from "node:fs/promises";
|
|
|
4
4
|
import { join, basename, relative } 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";
|
|
@@ -47,19 +46,20 @@ const getDirSize = async (dirPath: string): Promise<number> => {
|
|
|
47
46
|
}
|
|
48
47
|
};
|
|
49
48
|
|
|
50
|
-
const getFileInfo = async (path: string, relativeTo?: string): Promise<FileInfo> => {
|
|
49
|
+
const getFileInfo = async (path: string, relativeTo?: string, computeDirSize?: boolean): Promise<FileInfo> => {
|
|
51
50
|
const file = Bun.file(path);
|
|
52
51
|
const s = await stat(path);
|
|
53
52
|
const name = basename(path);
|
|
53
|
+
const isDir = s.isDirectory();
|
|
54
54
|
|
|
55
55
|
return {
|
|
56
56
|
name,
|
|
57
57
|
path: relativeTo ? relative(relativeTo, path) : path,
|
|
58
|
-
type:
|
|
59
|
-
size:
|
|
58
|
+
type: isDir ? "directory" : "file",
|
|
59
|
+
size: isDir ? (computeDirSize ? await getDirSize(path) : 0) : s.size,
|
|
60
60
|
mtime: s.mtime.toISOString(),
|
|
61
61
|
isHidden: name.startsWith("."),
|
|
62
|
-
mimeType:
|
|
62
|
+
mimeType: isDir ? undefined : file.type,
|
|
63
63
|
};
|
|
64
64
|
};
|
|
65
65
|
|
|
@@ -102,7 +102,7 @@ app.get(
|
|
|
102
102
|
await Promise.all(
|
|
103
103
|
entries
|
|
104
104
|
.filter((e) => showHidden || !e.name.startsWith("."))
|
|
105
|
-
.map((e) => getFileInfo(join(result.realPath, e.name), result.realPath).catch(() => null)),
|
|
105
|
+
.map((e) => getFileInfo(join(result.realPath, e.name), result.realPath, true).catch(() => null)),
|
|
106
106
|
)
|
|
107
107
|
).filter((item): item is FileInfo => item !== null);
|
|
108
108
|
|
|
@@ -357,73 +357,118 @@ app.delete(
|
|
|
357
357
|
},
|
|
358
358
|
);
|
|
359
359
|
|
|
360
|
-
// POST /
|
|
360
|
+
// POST /transfer
|
|
361
361
|
app.post(
|
|
362
|
-
"/
|
|
362
|
+
"/transfer",
|
|
363
363
|
describeRoute({
|
|
364
364
|
tags: ["Files"],
|
|
365
|
-
summary: "Move
|
|
366
|
-
description:
|
|
365
|
+
summary: "Move or copy file/directory",
|
|
366
|
+
description:
|
|
367
|
+
"Transfer files between locations. Mode 'move' requires same base path. " +
|
|
368
|
+
"Mode 'copy' allows cross-base transfer when ownership (ownerUid, ownerGid, fileMode) is provided.",
|
|
367
369
|
...requiresAuth,
|
|
368
370
|
responses: {
|
|
369
|
-
200: jsonResponse(FileInfoSchema, "
|
|
371
|
+
200: jsonResponse(FileInfoSchema, "Transferred"),
|
|
370
372
|
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
371
373
|
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
372
374
|
404: jsonResponse(ErrorSchema, "Not found"),
|
|
373
375
|
},
|
|
374
376
|
}),
|
|
375
|
-
v("json",
|
|
377
|
+
v("json", TransferBodySchema),
|
|
376
378
|
async (c) => {
|
|
377
|
-
const { from, to } = c.req.valid("json");
|
|
379
|
+
const { from, to, mode, ownerUid, ownerGid, fileMode, dirMode } = c.req.valid("json");
|
|
378
380
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
+
// Build ownership if provided
|
|
382
|
+
const ownership =
|
|
383
|
+
ownerUid != null && ownerGid != null && fileMode != null
|
|
384
|
+
? {
|
|
385
|
+
uid: ownerUid,
|
|
386
|
+
gid: ownerGid,
|
|
387
|
+
mode: parseInt(fileMode, 8),
|
|
388
|
+
dirMode: dirMode ? parseInt(dirMode, 8) : undefined,
|
|
389
|
+
}
|
|
390
|
+
: null;
|
|
381
391
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return c.json({ error:
|
|
392
|
+
// Move always requires same base
|
|
393
|
+
if (mode === "move") {
|
|
394
|
+
const result = await validateSameBase(from, to);
|
|
395
|
+
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
await stat(result.realPath);
|
|
399
|
+
} catch {
|
|
400
|
+
return c.json({ error: "source not found" }, 404);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await mkdir(join(result.realTo, ".."), { recursive: true });
|
|
404
|
+
await rename(result.realPath, result.realTo);
|
|
405
|
+
|
|
406
|
+
// Apply ownership if provided (for move within same base)
|
|
407
|
+
if (ownership) {
|
|
408
|
+
const ownershipError = await applyOwnershipRecursive(result.realTo, ownership);
|
|
409
|
+
if (ownershipError) {
|
|
410
|
+
return c.json({ error: ownershipError }, 500);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return c.json(await getFileInfo(result.realTo));
|
|
386
415
|
}
|
|
387
416
|
|
|
388
|
-
|
|
389
|
-
await
|
|
417
|
+
// Copy: check if same base or cross-base with ownership
|
|
418
|
+
const sameBaseResult = await validateSameBase(from, to);
|
|
390
419
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
420
|
+
if (sameBaseResult.ok) {
|
|
421
|
+
// Same base - no ownership required
|
|
422
|
+
try {
|
|
423
|
+
await stat(sameBaseResult.realPath);
|
|
424
|
+
} catch {
|
|
425
|
+
return c.json({ error: "source not found" }, 404);
|
|
426
|
+
}
|
|
394
427
|
|
|
395
|
-
|
|
396
|
-
|
|
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");
|
|
428
|
+
await mkdir(join(sameBaseResult.realTo, ".."), { recursive: true });
|
|
429
|
+
await cp(sameBaseResult.realPath, sameBaseResult.realTo, { recursive: true });
|
|
413
430
|
|
|
414
|
-
|
|
415
|
-
|
|
431
|
+
// Apply ownership if provided
|
|
432
|
+
if (ownership) {
|
|
433
|
+
const ownershipError = await applyOwnershipRecursive(sameBaseResult.realTo, ownership);
|
|
434
|
+
if (ownershipError) {
|
|
435
|
+
await rm(sameBaseResult.realTo, { recursive: true }).catch(() => {});
|
|
436
|
+
return c.json({ error: ownershipError }, 500);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return c.json(await getFileInfo(sameBaseResult.realTo));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Cross-base copy - ownership is required
|
|
444
|
+
if (!ownership) {
|
|
445
|
+
return c.json({ error: "cross-base copy requires ownership (ownerUid, ownerGid, fileMode)" }, 400);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Validate source and destination separately
|
|
449
|
+
const fromResult = await validatePath(from);
|
|
450
|
+
if (!fromResult.ok) return c.json({ error: fromResult.error }, fromResult.status);
|
|
451
|
+
|
|
452
|
+
const toResult = await validatePath(to, { createParents: true, ownership });
|
|
453
|
+
if (!toResult.ok) return c.json({ error: toResult.error }, toResult.status);
|
|
416
454
|
|
|
417
455
|
try {
|
|
418
|
-
await stat(
|
|
456
|
+
await stat(fromResult.realPath);
|
|
419
457
|
} catch {
|
|
420
458
|
return c.json({ error: "source not found" }, 404);
|
|
421
459
|
}
|
|
422
460
|
|
|
423
|
-
await mkdir(join(
|
|
424
|
-
await cp(
|
|
461
|
+
await mkdir(join(toResult.realPath, ".."), { recursive: true });
|
|
462
|
+
await cp(fromResult.realPath, toResult.realPath, { recursive: true });
|
|
463
|
+
|
|
464
|
+
// Apply ownership recursively to copied content
|
|
465
|
+
const ownershipError = await applyOwnershipRecursive(toResult.realPath, ownership);
|
|
466
|
+
if (ownershipError) {
|
|
467
|
+
await rm(toResult.realPath, { recursive: true }).catch(() => {});
|
|
468
|
+
return c.json({ error: ownershipError }, 500);
|
|
469
|
+
}
|
|
425
470
|
|
|
426
|
-
return c.json(await getFileInfo(
|
|
471
|
+
return c.json(await getFileInfo(toResult.realPath));
|
|
427
472
|
},
|
|
428
473
|
);
|
|
429
474
|
|
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
|
@@ -89,14 +89,22 @@ export const MkdirBodySchema = z.object({
|
|
|
89
89
|
.optional(),
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
export const
|
|
93
|
-
from: z.string().min(1),
|
|
94
|
-
to: z.string().min(1),
|
|
95
|
-
});
|
|
92
|
+
export const TransferModeSchema = z.enum(["move", "copy"]);
|
|
96
93
|
|
|
97
|
-
export const
|
|
94
|
+
export const TransferBodySchema = z.object({
|
|
98
95
|
from: z.string().min(1),
|
|
99
96
|
to: z.string().min(1),
|
|
97
|
+
mode: TransferModeSchema,
|
|
98
|
+
ownerUid: z.number().int().optional(),
|
|
99
|
+
ownerGid: z.number().int().optional(),
|
|
100
|
+
fileMode: z
|
|
101
|
+
.string()
|
|
102
|
+
.regex(/^[0-7]{3,4}$/)
|
|
103
|
+
.optional(),
|
|
104
|
+
dirMode: z
|
|
105
|
+
.string()
|
|
106
|
+
.regex(/^[0-7]{3,4}$/)
|
|
107
|
+
.optional(),
|
|
100
108
|
});
|
|
101
109
|
|
|
102
110
|
export const UploadStartBodySchema = z.object({
|