@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 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 (within same base path)
298
- await client.move({ from: "/data/old.txt", to: "/data/new.txt" });
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
- // Copy (within same base path)
301
- await client.copy({ from: "/data/file.txt", to: "/data/backup.txt" });
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/move` | Move file or directory |
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": "1.1.1",
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:integration:up": "docker compose -f compose.test.yml up -d --build --wait",
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 MoveOptions {
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 & Copy
299
+ // Transfer (Move/Copy)
297
300
  // ==========================================================================
298
301
 
299
- async move(opts: MoveOptions): Promise<FileProxyResponse<FileInfo>> {
300
- const res = await this._fetch(`${this.url}/files/move`, {
301
- method: "POST",
302
- headers: this.jsonHdrs(),
303
- body: JSON.stringify({ from: opts.from, to: opts.to }),
304
- });
305
- return this.handleResponse(res);
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
- async copy(opts: CopyOptions): Promise<FileProxyResponse<FileInfo>> {
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({ from: opts.from, to: opts.to }),
316
+ body: JSON.stringify(body),
313
317
  });
314
318
  return this.handleResponse(res);
315
319
  }
@@ -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
- MoveBodySchema,
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: s.isDirectory() ? "directory" : "file",
59
- size: s.isDirectory() ? 0 : s.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: s.isDirectory() ? undefined : file.type,
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 /move
360
+ // POST /transfer
361
361
  app.post(
362
- "/move",
362
+ "/transfer",
363
363
  describeRoute({
364
364
  tags: ["Files"],
365
- summary: "Move file or directory",
366
- description: "Move within same base path only",
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, "Moved"),
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", MoveBodySchema),
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
- const result = await validateSameBase(from, to);
380
- if (!result.ok) return c.json({ error: result.error }, result.status);
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
- try {
383
- await stat(result.realPath);
384
- } catch {
385
- return c.json({ error: "source not found" }, 404);
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
- await mkdir(join(result.realTo, ".."), { recursive: true });
389
- await rename(result.realPath, result.realTo);
417
+ // Copy: check if same base or cross-base with ownership
418
+ const sameBaseResult = await validateSameBase(from, to);
390
419
 
391
- return c.json(await getFileInfo(result.realTo));
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
- // POST /copy
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");
428
+ await mkdir(join(sameBaseResult.realTo, ".."), { recursive: true });
429
+ await cp(sameBaseResult.realPath, sameBaseResult.realTo, { recursive: true });
413
430
 
414
- const result = await validateSameBase(from, to);
415
- if (!result.ok) return c.json({ error: result.error }, result.status);
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(result.realPath);
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(result.realTo, ".."), { recursive: true });
424
- await cp(result.realPath, result.realTo, { recursive: true });
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(result.realTo));
471
+ return c.json(await getFileInfo(toResult.realPath));
427
472
  },
428
473
  );
429
474
 
@@ -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 MoveBodySchema = z.object({
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 CopyBodySchema = z.object({
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({