@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 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 (within same base path)
298
- await client.move({ from: "/data/old.txt", to: "/data/new.txt" });
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
- // Copy (within same base path)
301
- await client.copy({ from: "/data/file.txt", to: "/data/backup.txt" });
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/move` | Move file or directory |
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": "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.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: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,21 @@ 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
+ /** 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 & Copy
301
+ // Transfer (Move/Copy)
297
302
  // ==========================================================================
298
303
 
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
- }
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
- async copy(opts: CopyOptions): Promise<FileProxyResponse<FileInfo>> {
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({ from: opts.from, to: opts.to }),
319
+ body: JSON.stringify(body),
313
320
  });
314
321
  return this.handleResponse(res);
315
322
  }
@@ -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
- MoveBodySchema,
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: s.isDirectory() ? "directory" : "file",
59
- size: s.isDirectory() ? 0 : s.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: s.isDirectory() ? undefined : file.type,
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
- return c.json({ ...info, items, total: items.length });
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 /move
390
+ // POST /transfer
361
391
  app.post(
362
- "/move",
392
+ "/transfer",
363
393
  describeRoute({
364
394
  tags: ["Files"],
365
- summary: "Move file or directory",
366
- description: "Move within same base path only",
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, "Moved"),
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", MoveBodySchema),
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
- const result = await validateSameBase(from, to);
380
- if (!result.ok) return c.json({ error: result.error }, result.status);
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
- try {
383
- await stat(result.realPath);
384
- } catch {
385
- return c.json({ error: "source not found" }, 404);
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
- await mkdir(join(result.realTo, ".."), { recursive: true });
389
- await rename(result.realPath, result.realTo);
449
+ // Copy: check if same base or cross-base with ownership
450
+ const sameBaseResult = await validateSameBase(from, to);
390
451
 
391
- return c.json(await getFileInfo(result.realTo));
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
- // 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");
460
+ const targetPath = ensureUniqueName ? await getUniquePath(sameBaseResult.realTo) : sameBaseResult.realTo;
413
461
 
414
- const result = await validateSameBase(from, to);
415
- if (!result.ok) return c.json({ error: result.error }, result.status);
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(result.realPath);
490
+ await stat(fromResult.realPath);
419
491
  } catch {
420
492
  return c.json({ error: "source not found" }, 404);
421
493
  }
422
494
 
423
- await mkdir(join(result.realTo, ".."), { recursive: true });
424
- await cp(result.realPath, result.realTo, { recursive: true });
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(result.realTo));
507
+ return c.json(await getFileInfo(targetPath));
427
508
  },
428
509
  );
429
510
 
@@ -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.object({
8
- error: z.string(),
9
- });
10
-
11
- export const FileTypeSchema = z.enum(["file", "directory"]);
12
-
13
- export const FileInfoSchema = z.object({
14
- name: z.string(),
15
- path: z.string(),
16
- type: FileTypeSchema,
17
- size: z.number(),
18
- mtime: z.iso.datetime(),
19
- isHidden: z.boolean(),
20
- mimeType: z.string().optional(),
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.object({
33
- path: z.string().min(1),
34
- });
35
-
36
- export const ContentQuerySchema = z.object({
37
- path: z.string().min(1),
38
- inline: z
39
- .string()
40
- .optional()
41
- .transform((v) => v === "true"), // default: false (attachment)
42
- });
43
-
44
- export const InfoQuerySchema = z.object({
45
- path: z.string().min(1),
46
- showHidden: z
47
- .string()
48
- .optional()
49
- .transform((v) => v === "true"),
50
- });
51
-
52
- export const SearchQuerySchema = z.object({
53
- paths: z.string().min(1),
54
- pattern: z.string().min(1).max(500),
55
- showHidden: z
56
- .string()
57
- .optional()
58
- .transform((v) => v === "true"),
59
- limit: z
60
- .string()
61
- .optional()
62
- .transform((v) => (v ? parseInt(v, 10) : undefined)),
63
- files: z
64
- .string()
65
- .optional()
66
- .transform((v) => v !== "false"), // default: true
67
- directories: z
68
- .string()
69
- .optional()
70
- .transform((v) => v === "true"), // default: false
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.object({
83
- path: z.string().min(1),
84
- ownerUid: z.number().int().optional(),
85
- ownerGid: z.number().int().optional(),
86
- mode: z
87
- .string()
88
- .regex(/^[0-7]{3,4}$/)
89
- .optional(),
90
- });
91
-
92
- export const MoveBodySchema = z.object({
93
- from: z.string().min(1),
94
- to: z.string().min(1),
95
- });
96
-
97
- export const CopyBodySchema = z.object({
98
- from: z.string().min(1),
99
- to: z.string().min(1),
100
- });
101
-
102
- export const UploadStartBodySchema = z.object({
103
- path: z.string().min(1),
104
- filename: z.string().min(1),
105
- size: z.number().int().positive(),
106
- checksum: z.string().regex(/^sha256:[a-f0-9]{64}$/),
107
- chunkSize: z.number().int().positive(),
108
- ownerUid: z.number().int().optional(),
109
- ownerGid: z.number().int().optional(),
110
- mode: z
111
- .string()
112
- .regex(/^[0-7]{3,4}$/)
113
- .optional(),
114
- dirMode: z
115
- .string()
116
- .regex(/^[0-7]{3,4}$/)
117
- .optional(),
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.object({
125
- basePath: z.string(),
126
- files: z.array(FileInfoSchema),
127
- total: z.number(),
128
- hasMore: z.boolean(),
129
- });
130
-
131
- export const SearchResponseSchema = z.object({
132
- results: z.array(SearchResultSchema),
133
- totalFiles: z.number(),
134
- });
135
-
136
- export const UploadStartResponseSchema = z.object({
137
- uploadId: z.string().regex(/^[a-f0-9]{16}$/),
138
- totalChunks: z.number(),
139
- chunkSize: z.number(),
140
- uploadedChunks: z.array(z.number()),
141
- completed: z.literal(false),
142
- });
143
-
144
- export const UploadChunkProgressSchema = z.object({
145
- chunkIndex: z.number(),
146
- uploadedChunks: z.array(z.number()),
147
- completed: z.literal(false),
148
- });
149
-
150
- export const UploadChunkCompleteSchema = z.object({
151
- completed: z.literal(true),
152
- file: FileInfoSchema.extend({ checksum: z.string() }),
153
- });
154
-
155
- export const UploadChunkResponseSchema = z.union([UploadChunkProgressSchema, UploadChunkCompleteSchema]);
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.object({
162
- "x-file-path": z.string().min(1),
163
- "x-file-name": z.string().min(1),
164
- "x-owner-uid": z.string().regex(/^\d+$/).transform(Number).optional(),
165
- "x-owner-gid": z.string().regex(/^\d+$/).transform(Number).optional(),
166
- "x-file-mode": z
167
- .string()
168
- .regex(/^[0-7]{3,4}$/)
169
- .optional(),
170
- "x-dir-mode": z
171
- .string()
172
- .regex(/^[0-7]{3,4}$/)
173
- .optional(),
174
- });
175
-
176
- export const UploadChunkHeadersSchema = z.object({
177
- "x-upload-id": z.string().regex(/^[a-f0-9]{16}$/),
178
- "x-chunk-index": z.string().regex(/^\d+$/).transform(Number),
179
- "x-chunk-checksum": z
180
- .string()
181
- .regex(/^sha256:[a-f0-9]{64}$/)
182
- .optional(),
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