@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 +68 -6
- package/package.json +2 -7
- package/src/client.ts +30 -20
- package/src/handlers/files.ts +100 -54
- package/src/handlers/search.ts +1 -1
- package/src/handlers/upload.ts +14 -6
- package/src/lib/ownership.ts +76 -7
- package/src/lib/path.ts +28 -3
- package/src/schemas.ts +21 -5
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
|
|
283
|
-
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
|
+
});
|
|
284
339
|
|
|
285
|
-
//
|
|
286
|
-
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
|
+
});
|
|
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/
|
|
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": "
|
|
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
|
@@ -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
|
|
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
|
|
299
|
+
// Transfer (Move/Copy)
|
|
291
300
|
// ==========================================================================
|
|
292
301
|
|
|
293
|
-
async
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
|
|
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
|
-
|
|
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(
|
|
316
|
+
body: JSON.stringify(body),
|
|
307
317
|
});
|
|
308
318
|
return this.handleResponse(res);
|
|
309
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
|
|
|
@@ -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
|
-
|
|
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 /
|
|
360
|
+
// POST /transfer
|
|
360
361
|
app.post(
|
|
361
|
-
"/
|
|
362
|
+
"/transfer",
|
|
362
363
|
describeRoute({
|
|
363
364
|
tags: ["Files"],
|
|
364
|
-
summary: "Move
|
|
365
|
-
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.",
|
|
366
369
|
...requiresAuth,
|
|
367
370
|
responses: {
|
|
368
|
-
200: jsonResponse(FileInfoSchema, "
|
|
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",
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
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));
|
|
385
415
|
}
|
|
386
416
|
|
|
387
|
-
|
|
388
|
-
await
|
|
417
|
+
// Copy: check if same base or cross-base with ownership
|
|
418
|
+
const sameBaseResult = await validateSameBase(from, to);
|
|
389
419
|
|
|
390
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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(
|
|
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(
|
|
423
|
-
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
|
+
}
|
|
424
470
|
|
|
425
|
-
return c.json(await getFileInfo(
|
|
471
|
+
return c.json(await getFileInfo(toResult.realPath));
|
|
426
472
|
},
|
|
427
473
|
);
|
|
428
474
|
|
package/src/handlers/search.ts
CHANGED
|
@@ -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);
|
package/src/handlers/upload.ts
CHANGED
|
@@ -152,7 +152,20 @@ app.post(
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
const fullPath = join(body.path, body.filename);
|
|
155
|
-
|
|
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,
|
package/src/lib/ownership.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
37
|
+
* Optionally creates parent directories with ownership.
|
|
28
38
|
*/
|
|
29
|
-
export const validatePath = async (path: string,
|
|
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
|
|
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({
|
|
@@ -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({
|