@valentinkolb/filegate 1.1.0 → 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
@@ -108,6 +108,21 @@ With this configuration:
108
108
 
109
109
  Symlinks that point outside base paths are also blocked.
110
110
 
111
+ ### Auto-Create Parent Directories
112
+
113
+ When uploading files, Filegate automatically creates any missing parent directories. This simplifies folder uploads where nested structures need to be created on-the-fly:
114
+
115
+ ```typescript
116
+ // Parent directories /data/new/nested/path will be created automatically
117
+ await client.upload.single({
118
+ path: "/data/new/nested/path",
119
+ filename: "file.txt",
120
+ data: buffer,
121
+ });
122
+ ```
123
+
124
+ This applies to both simple uploads and chunked uploads.
125
+
111
126
  ### File Ownership
112
127
 
113
128
  Filegate can set Unix file ownership on uploaded files:
@@ -129,6 +144,42 @@ Filegate does not validate whether the specified uid/gid exists on the system, n
129
144
 
130
145
  This feature is intended for scenarios like NFS shares exposed through Filegate, where preserving the original permission structure is required.
131
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
+
132
183
  ### Chunked Uploads
133
184
 
134
185
  For large files, use chunked uploads. They support:
@@ -279,11 +330,23 @@ await client.mkdir({ path: "/data/new-folder", mode: "755" });
279
330
  // Delete file or directory
280
331
  await client.delete({ path: "/data/old-file.txt" });
281
332
 
282
- // Move (within same base path)
283
- 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
+ });
284
339
 
285
- // Copy (within same base path)
286
- 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
+ });
287
350
 
288
351
  // Search files with glob patterns
289
352
  await client.glob({
@@ -381,8 +444,7 @@ All `/files/*` endpoints require `Authorization: Bearer <token>`.
381
444
  | PUT | `/files/content` | Upload file |
382
445
  | POST | `/files/mkdir` | Create directory |
383
446
  | DELETE | `/files/delete` | Delete file or directory |
384
- | POST | `/files/move` | Move file or directory |
385
- | POST | `/files/copy` | Copy file or directory |
447
+ | POST | `/files/transfer` | Move or copy file/directory. Cross-base copy requires ownership |
386
448
  | GET | `/files/search` | Search with glob pattern. Use `?directories=true` to include folders |
387
449
  | POST | `/files/upload/start` | Start or resume chunked upload |
388
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.0",
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
@@ -50,6 +50,8 @@ export interface UploadSingleOptions {
50
50
  uid?: number;
51
51
  gid?: number;
52
52
  mode?: string;
53
+ /** Directory mode for auto-created parent directories (e.g. "755"). If not set, derived from mode. */
54
+ dirMode?: string;
53
55
  }
54
56
 
55
57
  // --- Upload Chunked Start ---
@@ -62,6 +64,8 @@ export interface UploadChunkedStartOptions {
62
64
  uid?: number;
63
65
  gid?: number;
64
66
  mode?: string;
67
+ /** Directory mode for auto-created parent directories (e.g. "755"). If not set, derived from mode. */
68
+ dirMode?: string;
65
69
  }
66
70
 
67
71
  // --- Upload Chunked Send ---
@@ -85,16 +89,19 @@ export interface DeleteOptions {
85
89
  path: string;
86
90
  }
87
91
 
88
- // --- Move ---
89
- export interface MoveOptions {
90
- from: string;
91
- to: string;
92
- }
93
-
94
- // --- Copy ---
95
- export interface CopyOptions {
92
+ // --- Transfer (Move/Copy) ---
93
+ export interface TransferOptions {
96
94
  from: string;
97
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;
98
105
  }
99
106
 
100
107
  // --- Glob (Search) ---
@@ -131,6 +138,7 @@ class UploadClient {
131
138
  if (opts.uid !== undefined) uploadHdrs["X-Owner-UID"] = String(opts.uid);
132
139
  if (opts.gid !== undefined) uploadHdrs["X-Owner-GID"] = String(opts.gid);
133
140
  if (opts.mode) uploadHdrs["X-File-Mode"] = opts.mode;
141
+ if (opts.dirMode) uploadHdrs["X-Dir-Mode"] = opts.dirMode;
134
142
 
135
143
  const res = await this._fetch(`${this.url}/files/content`, {
136
144
  method: "PUT",
@@ -151,6 +159,7 @@ class UploadClient {
151
159
  ownerUid: opts.uid,
152
160
  ownerGid: opts.gid,
153
161
  mode: opts.mode,
162
+ dirMode: opts.dirMode,
154
163
  };
155
164
 
156
165
  const res = await this._fetch(`${this.url}/files/upload/start`, {
@@ -287,23 +296,24 @@ export class Filegate {
287
296
  }
288
297
 
289
298
  // ==========================================================================
290
- // Move & Copy
299
+ // Transfer (Move/Copy)
291
300
  // ==========================================================================
292
301
 
293
- async move(opts: MoveOptions): Promise<FileProxyResponse<FileInfo>> {
294
- const res = await this._fetch(`${this.url}/files/move`, {
295
- method: "POST",
296
- headers: this.jsonHdrs(),
297
- body: JSON.stringify({ from: opts.from, to: opts.to }),
298
- });
299
- return this.handleResponse(res);
300
- }
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;
301
312
 
302
- async copy(opts: CopyOptions): Promise<FileProxyResponse<FileInfo>> {
303
- const res = await this._fetch(`${this.url}/files/copy`, {
313
+ const res = await this._fetch(`${this.url}/files/transfer`, {
304
314
  method: "POST",
305
315
  headers: this.jsonHdrs(),
306
- body: JSON.stringify({ from: opts.from, to: opts.to }),
316
+ body: JSON.stringify(body),
307
317
  });
308
318
  return this.handleResponse(res);
309
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
 
@@ -81,7 +81,7 @@ app.get(
81
81
  async (c) => {
82
82
  const { path, showHidden } = c.req.valid("query");
83
83
 
84
- const result = await validatePath(path, true);
84
+ const result = await validatePath(path, { allowBasePath: true });
85
85
  if (!result.ok) return c.json({ error: result.error }, result.status);
86
86
 
87
87
  let s;
@@ -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
 
@@ -239,8 +239,6 @@ app.put(
239
239
  }
240
240
 
241
241
  const fullPath = join(dirPath, filename);
242
- const result = await validatePath(fullPath);
243
- if (!result.ok) return c.json({ error: result.error }, result.status);
244
242
 
245
243
  // Build ownership from validated headers
246
244
  const ownership: import("../lib/ownership").Ownership | null =
@@ -249,10 +247,13 @@ app.put(
249
247
  uid: headers["x-owner-uid"],
250
248
  gid: headers["x-owner-gid"],
251
249
  mode: parseInt(headers["x-file-mode"], 8),
250
+ dirMode: headers["x-dir-mode"] ? parseInt(headers["x-dir-mode"], 8) : undefined,
252
251
  }
253
252
  : null;
254
253
 
255
- await mkdir(dirPath, { recursive: true });
254
+ // Validate path and create parent directories with ownership
255
+ const result = await validatePath(fullPath, { createParents: true, ownership });
256
+ if (!result.ok) return c.json({ error: result.error }, result.status);
256
257
 
257
258
  const body = c.req.raw.body;
258
259
  if (!body) return c.json({ error: "missing body" }, 400);
@@ -356,73 +357,118 @@ app.delete(
356
357
  },
357
358
  );
358
359
 
359
- // POST /move
360
+ // POST /transfer
360
361
  app.post(
361
- "/move",
362
+ "/transfer",
362
363
  describeRoute({
363
364
  tags: ["Files"],
364
- summary: "Move file or directory",
365
- 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.",
366
369
  ...requiresAuth,
367
370
  responses: {
368
- 200: jsonResponse(FileInfoSchema, "Moved"),
371
+ 200: jsonResponse(FileInfoSchema, "Transferred"),
369
372
  400: jsonResponse(ErrorSchema, "Bad request"),
370
373
  403: jsonResponse(ErrorSchema, "Forbidden"),
371
374
  404: jsonResponse(ErrorSchema, "Not found"),
372
375
  },
373
376
  }),
374
- v("json", MoveBodySchema),
377
+ v("json", TransferBodySchema),
375
378
  async (c) => {
376
- const { from, to } = c.req.valid("json");
379
+ const { from, to, mode, ownerUid, ownerGid, fileMode, dirMode } = c.req.valid("json");
377
380
 
378
- const result = await validateSameBase(from, to);
379
- 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;
380
391
 
381
- try {
382
- await stat(result.realPath);
383
- } catch {
384
- 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));
385
415
  }
386
416
 
387
- await mkdir(join(result.realTo, ".."), { recursive: true });
388
- await rename(result.realPath, result.realTo);
417
+ // Copy: check if same base or cross-base with ownership
418
+ const sameBaseResult = await validateSameBase(from, to);
389
419
 
390
- return c.json(await getFileInfo(result.realTo));
391
- },
392
- );
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
+ }
393
427
 
394
- // POST /copy
395
- app.post(
396
- "/copy",
397
- describeRoute({
398
- tags: ["Files"],
399
- summary: "Copy file or directory",
400
- description: "Copy within same base path only",
401
- ...requiresAuth,
402
- responses: {
403
- 200: jsonResponse(FileInfoSchema, "Copied"),
404
- 400: jsonResponse(ErrorSchema, "Bad request"),
405
- 403: jsonResponse(ErrorSchema, "Forbidden"),
406
- 404: jsonResponse(ErrorSchema, "Not found"),
407
- },
408
- }),
409
- v("json", CopyBodySchema),
410
- async (c) => {
411
- const { from, to } = c.req.valid("json");
428
+ await mkdir(join(sameBaseResult.realTo, ".."), { recursive: true });
429
+ await cp(sameBaseResult.realPath, sameBaseResult.realTo, { recursive: true });
412
430
 
413
- const result = await validateSameBase(from, to);
414
- 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);
415
454
 
416
455
  try {
417
- await stat(result.realPath);
456
+ await stat(fromResult.realPath);
418
457
  } catch {
419
458
  return c.json({ error: "source not found" }, 404);
420
459
  }
421
460
 
422
- await mkdir(join(result.realTo, ".."), { recursive: true });
423
- 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
+ }
424
470
 
425
- return c.json(await getFileInfo(result.realTo));
471
+ return c.json(await getFileInfo(toResult.realPath));
426
472
  },
427
473
  );
428
474
 
@@ -114,7 +114,7 @@ app.get(
114
114
  const validPaths: string[] = [];
115
115
 
116
116
  for (const p of paths) {
117
- const result = await validatePath(p, true);
117
+ const result = await validatePath(p, { allowBasePath: true });
118
118
  if (!result.ok) return c.json({ error: result.error }, result.status);
119
119
 
120
120
  const s = await stat(result.realPath).catch(() => null);
@@ -152,7 +152,20 @@ app.post(
152
152
  }
153
153
 
154
154
  const fullPath = join(body.path, body.filename);
155
- const pathResult = await validatePath(fullPath);
155
+
156
+ // Build ownership from body
157
+ const ownership: Ownership | null =
158
+ body.ownerUid != null && body.ownerGid != null && body.mode
159
+ ? {
160
+ uid: body.ownerUid,
161
+ gid: body.ownerGid,
162
+ mode: parseInt(body.mode, 8),
163
+ dirMode: body.dirMode ? parseInt(body.dirMode, 8) : undefined,
164
+ }
165
+ : null;
166
+
167
+ // Validate path and create parent directories with ownership
168
+ const pathResult = await validatePath(fullPath, { createParents: true, ownership });
156
169
  if (!pathResult.ok) return c.json({ error: pathResult.error }, pathResult.status);
157
170
 
158
171
  // Deterministic upload ID - same file/path/checksum = same ID (enables resume)
@@ -178,11 +191,6 @@ app.post(
178
191
  const chunkSize = body.chunkSize;
179
192
  const totalChunks = Math.ceil(body.size / chunkSize);
180
193
 
181
- const ownership: Ownership | null =
182
- body.ownerUid != null && body.ownerGid != null && body.mode
183
- ? { uid: body.ownerUid, gid: body.ownerGid, mode: parseInt(body.mode, 8) }
184
- : null;
185
-
186
194
  const meta: UploadMeta = {
187
195
  uploadId,
188
196
  path: body.path,
@@ -1,26 +1,49 @@
1
- import { chown, chmod } from "node:fs/promises";
1
+ import { chown, chmod, readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { dirname } from "node:path";
2
4
  import { config } from "../config";
3
5
 
4
6
  export type Ownership = {
5
7
  uid: number;
6
8
  gid: number;
7
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);
8
26
  };
9
27
 
10
28
  export const parseOwnershipHeaders = (req: Request): Ownership | null => {
11
29
  const uid = req.headers.get("X-Owner-UID");
12
30
  const gid = req.headers.get("X-Owner-GID");
13
31
  const mode = req.headers.get("X-File-Mode");
32
+ const dirMode = req.headers.get("X-Dir-Mode");
14
33
 
15
34
  if (!uid || !gid || !mode) return null;
16
35
 
17
36
  const parsedMode = parseInt(mode, 8);
18
37
  if (isNaN(parsedMode)) return null;
19
38
 
39
+ const parsedDirMode = dirMode ? parseInt(dirMode, 8) : undefined;
40
+ if (dirMode && isNaN(parsedDirMode!)) return null;
41
+
20
42
  return {
21
43
  uid: parseInt(uid, 10),
22
44
  gid: parseInt(gid, 10),
23
45
  mode: parsedMode,
46
+ dirMode: parsedDirMode,
24
47
  };
25
48
  };
26
49
 
@@ -28,19 +51,20 @@ export const parseOwnershipBody = (body: {
28
51
  ownerUid?: number;
29
52
  ownerGid?: number;
30
53
  mode?: string;
54
+ dirMode?: string;
31
55
  }): Ownership | null => {
32
56
  if (body.ownerUid == null || body.ownerGid == null || !body.mode) return null;
33
57
 
34
58
  const mode = parseInt(body.mode, 8);
35
59
  if (isNaN(mode)) return null;
36
60
 
37
- return { uid: body.ownerUid, gid: body.ownerGid, mode };
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 };
38
65
  };
39
66
 
40
- export const applyOwnership = async (
41
- path: string,
42
- ownership: Ownership | null
43
- ): Promise<string | null> => {
67
+ export const applyOwnership = async (path: string, ownership: Ownership | null): Promise<string | null> => {
44
68
  if (!ownership) return null;
45
69
 
46
70
  const uid = config.devUid ?? ownership.uid;
@@ -48,7 +72,7 @@ export const applyOwnership = async (
48
72
 
49
73
  if (config.isDev) {
50
74
  console.log(
51
- `[DEV] chown ${path}: ${ownership.uid}->${uid}, ${ownership.gid}->${gid}, mode=${ownership.mode.toString(8)}`
75
+ `[DEV] chown ${path}: ${ownership.uid}->${uid}, ${ownership.gid}->${gid}, mode=${ownership.mode.toString(8)}`,
52
76
  );
53
77
  }
54
78
 
@@ -62,3 +86,48 @@ export const applyOwnership = async (
62
86
  return `ownership failed: ${e.message}`;
63
87
  }
64
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 CHANGED
@@ -1,6 +1,7 @@
1
- import { realpath } from "node:fs/promises";
1
+ import { realpath, mkdir } from "node:fs/promises";
2
2
  import { join, normalize, dirname, basename } from "node:path";
3
3
  import { config } from "../config";
4
+ import { applyOwnershipToNewDirs, type Ownership } from "./ownership";
4
5
 
5
6
  export type PathResult =
6
7
  | { ok: true; realPath: string; basePath: string }
@@ -22,11 +23,21 @@ const getRealBase = async (basePath: string): Promise<string | null> => {
22
23
  }
23
24
  };
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
+
25
35
  /**
26
36
  * Validates path is within allowed base paths, resolves symlinks.
27
- * @param allowBasePath - if true, allows operating on the base path itself
37
+ * Optionally creates parent directories with ownership.
28
38
  */
29
- export const validatePath = async (path: string, allowBasePath = false): Promise<PathResult> => {
39
+ export const validatePath = async (path: string, options: ValidatePathOptions = {}): Promise<PathResult> => {
40
+ const { allowBasePath = false, createParents = false, ownership = null } = options;
30
41
  const cleaned = normalize(path);
31
42
 
32
43
  // Find matching base
@@ -50,6 +61,20 @@ export const validatePath = async (path: string, allowBasePath = false): Promise
50
61
  return { ok: false, error: "path not allowed", status: 403 };
51
62
  }
52
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
+
53
78
  // Resolve symlinks
54
79
  let realPath: string;
55
80
  try {
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({
@@ -111,6 +119,10 @@ export const UploadStartBodySchema = z.object({
111
119
  .string()
112
120
  .regex(/^[0-7]{3,4}$/)
113
121
  .optional(),
122
+ dirMode: z
123
+ .string()
124
+ .regex(/^[0-7]{3,4}$/)
125
+ .optional(),
114
126
  });
115
127
 
116
128
  // ============================================================================
@@ -163,6 +175,10 @@ export const UploadFileHeadersSchema = z.object({
163
175
  .string()
164
176
  .regex(/^[0-7]{3,4}$/)
165
177
  .optional(),
178
+ "x-dir-mode": z
179
+ .string()
180
+ .regex(/^[0-7]{3,4}$/)
181
+ .optional(),
166
182
  });
167
183
 
168
184
  export const UploadChunkHeadersSchema = z.object({