@valentinkolb/filegate 2.3.6 → 2.5.2
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/dist/activity.d.ts +15 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +21 -0
- package/dist/activity.js.map +1 -0
- package/dist/capabilities.d.ts +9 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +11 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/client.d.ts +37 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +77 -0
- package/dist/client.js.map +1 -0
- package/dist/core.d.ts +26 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +58 -0
- package/dist/core.js.map +1 -0
- package/dist/downloads.d.ts +9 -0
- package/dist/downloads.d.ts.map +1 -0
- package/dist/downloads.js +11 -0
- package/dist/downloads.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +42 -0
- package/dist/errors.js.map +1 -0
- package/dist/index-client.d.ts +17 -0
- package/dist/index-client.d.ts.map +1 -0
- package/dist/index-client.js +29 -0
- package/dist/index-client.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/nodes.d.ts +27 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +71 -0
- package/dist/nodes.js.map +1 -0
- package/dist/paths.d.ts +45 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +71 -0
- package/dist/paths.js.map +1 -0
- package/dist/search.d.ts +17 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +25 -0
- package/dist/search.js.map +1 -0
- package/dist/stats.d.ts +9 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +11 -0
- package/dist/stats.js.map +1 -0
- package/dist/transfers.d.ts +9 -0
- package/dist/transfers.d.ts.map +1 -0
- package/dist/transfers.js +14 -0
- package/dist/transfers.js.map +1 -0
- package/dist/types.d.ts +285 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/uploads.d.ts +246 -0
- package/dist/uploads.d.ts.map +1 -0
- package/dist/uploads.js +580 -0
- package/dist/uploads.js.map +1 -0
- package/dist/utils.d.ts +25 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +41 -0
- package/dist/utils.js.map +1 -0
- package/dist/versions.d.ts +76 -0
- package/dist/versions.d.ts.map +1 -0
- package/dist/versions.js +82 -0
- package/dist/versions.js.map +1 -0
- package/package.json +36 -41
- package/LICENSE +0 -21
- package/README.md +0 -569
- package/src/client.ts +0 -436
- package/src/config.ts +0 -37
- package/src/handlers/files.ts +0 -513
- package/src/handlers/search.ts +0 -137
- package/src/handlers/thumbnail.ts +0 -148
- package/src/handlers/upload.ts +0 -379
- package/src/index.ts +0 -94
- package/src/lib/openapi.ts +0 -47
- package/src/lib/ownership.ts +0 -133
- package/src/lib/path.ts +0 -128
- package/src/lib/response.ts +0 -10
- package/src/lib/validator.ts +0 -21
- package/src/schemas.ts +0 -329
- package/src/utils.ts +0 -282
package/src/handlers/files.ts
DELETED
|
@@ -1,513 +0,0 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
2
|
-
import { describeRoute } from "hono-openapi";
|
|
3
|
-
import { readdir, mkdir, rm, rename, cp, stat, access } from "node:fs/promises";
|
|
4
|
-
import { join, basename, relative, dirname, extname } from "node:path";
|
|
5
|
-
import sanitizeFilename from "sanitize-filename";
|
|
6
|
-
import { validatePath, validateSameBase } from "../lib/path";
|
|
7
|
-
import { parseOwnershipBody, applyOwnership, applyOwnershipRecursive } from "../lib/ownership";
|
|
8
|
-
import { jsonResponse, binaryResponse, requiresAuth } from "../lib/openapi";
|
|
9
|
-
import { v } from "../lib/validator";
|
|
10
|
-
import {
|
|
11
|
-
FileInfoSchema,
|
|
12
|
-
DirInfoSchema,
|
|
13
|
-
ErrorSchema,
|
|
14
|
-
InfoQuerySchema,
|
|
15
|
-
PathQuerySchema,
|
|
16
|
-
ContentQuerySchema,
|
|
17
|
-
MkdirBodySchema,
|
|
18
|
-
TransferBodySchema,
|
|
19
|
-
UploadFileHeadersSchema,
|
|
20
|
-
type FileInfo,
|
|
21
|
-
} from "../schemas";
|
|
22
|
-
import { config } from "../config";
|
|
23
|
-
|
|
24
|
-
const app = new Hono();
|
|
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
|
-
|
|
55
|
-
// Cross-platform directory size using `du` command
|
|
56
|
-
const getDirSize = async (dirPath: string): Promise<number> => {
|
|
57
|
-
const isMac = process.platform === "darwin";
|
|
58
|
-
|
|
59
|
-
// macOS (BSD): du -sk (kilobytes), Linux (GNU): du -sb (bytes)
|
|
60
|
-
const args = isMac ? ["-sk", dirPath] : ["-sb", dirPath];
|
|
61
|
-
const multiplier = isMac ? 1024 : 1;
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
const proc = Bun.spawn(["du", ...args], {
|
|
65
|
-
stdout: "pipe",
|
|
66
|
-
stderr: "ignore",
|
|
67
|
-
});
|
|
68
|
-
const output = await new Response(proc.stdout).text();
|
|
69
|
-
await proc.exited;
|
|
70
|
-
|
|
71
|
-
const value = parseInt(output.split("\t")[0] ?? "", 10);
|
|
72
|
-
return isNaN(value) ? 0 : value * multiplier;
|
|
73
|
-
} catch {
|
|
74
|
-
return 0;
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const getFileInfo = async (path: string, relativeTo?: string, computeDirSize?: boolean): Promise<FileInfo> => {
|
|
79
|
-
const file = Bun.file(path);
|
|
80
|
-
const s = await stat(path);
|
|
81
|
-
const name = basename(path);
|
|
82
|
-
const isDir = s.isDirectory();
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
name,
|
|
86
|
-
path: relativeTo ? relative(relativeTo, path) : path,
|
|
87
|
-
type: isDir ? "directory" : "file",
|
|
88
|
-
size: isDir ? (computeDirSize ? await getDirSize(path) : 0) : s.size,
|
|
89
|
-
mtime: s.mtime.toISOString(),
|
|
90
|
-
isHidden: name.startsWith("."),
|
|
91
|
-
mimeType: isDir ? undefined : file.type,
|
|
92
|
-
};
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
// GET /info
|
|
96
|
-
app.get(
|
|
97
|
-
"/info",
|
|
98
|
-
describeRoute({
|
|
99
|
-
tags: ["Files"],
|
|
100
|
-
summary: "Get file or directory info",
|
|
101
|
-
...requiresAuth,
|
|
102
|
-
responses: {
|
|
103
|
-
200: jsonResponse(DirInfoSchema, "File or directory info"),
|
|
104
|
-
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
105
|
-
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
106
|
-
404: jsonResponse(ErrorSchema, "Not found"),
|
|
107
|
-
},
|
|
108
|
-
}),
|
|
109
|
-
v("query", InfoQuerySchema),
|
|
110
|
-
async (c) => {
|
|
111
|
-
const { path, showHidden, computeSizes } = c.req.valid("query");
|
|
112
|
-
|
|
113
|
-
const result = await validatePath(path, { allowBasePath: true });
|
|
114
|
-
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
115
|
-
|
|
116
|
-
let s;
|
|
117
|
-
try {
|
|
118
|
-
s = await stat(result.realPath);
|
|
119
|
-
} catch {
|
|
120
|
-
return c.json({ error: "not found" }, 404);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (!s.isDirectory()) {
|
|
124
|
-
return c.json(await getFileInfo(result.realPath));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const entries = await readdir(result.realPath, { withFileTypes: true });
|
|
128
|
-
|
|
129
|
-
// Parallel file info retrieval (computeSizes only when requested)
|
|
130
|
-
const items = (
|
|
131
|
-
await Promise.all(
|
|
132
|
-
entries
|
|
133
|
-
.filter((e) => showHidden || !e.name.startsWith("."))
|
|
134
|
-
.map((e) => getFileInfo(join(result.realPath, e.name), result.realPath, computeSizes).catch(() => null)),
|
|
135
|
-
)
|
|
136
|
-
).filter((item): item is FileInfo => item !== null);
|
|
137
|
-
|
|
138
|
-
const info = await getFileInfo(result.realPath);
|
|
139
|
-
const totalSize = computeSizes ? items.reduce((sum, item) => sum + item.size, 0) : 0;
|
|
140
|
-
return c.json({ ...info, size: totalSize, items, total: items.length });
|
|
141
|
-
},
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
// GET /content
|
|
145
|
-
app.get(
|
|
146
|
-
"/content",
|
|
147
|
-
describeRoute({
|
|
148
|
-
tags: ["Files"],
|
|
149
|
-
summary: "Download file or directory",
|
|
150
|
-
description:
|
|
151
|
-
"Downloads a file directly or a directory as a TAR archive. Size limit applies to both. Use ?inline=true to display in browser instead of downloading.",
|
|
152
|
-
...requiresAuth,
|
|
153
|
-
responses: {
|
|
154
|
-
200: binaryResponse("application/octet-stream", "File content or TAR archive"),
|
|
155
|
-
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
156
|
-
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
157
|
-
404: jsonResponse(ErrorSchema, "Not found"),
|
|
158
|
-
413: jsonResponse(ErrorSchema, "Content too large"),
|
|
159
|
-
},
|
|
160
|
-
}),
|
|
161
|
-
v("query", ContentQuerySchema),
|
|
162
|
-
async (c) => {
|
|
163
|
-
const { path, inline } = c.req.valid("query");
|
|
164
|
-
|
|
165
|
-
const result = await validatePath(path);
|
|
166
|
-
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
167
|
-
|
|
168
|
-
let s;
|
|
169
|
-
try {
|
|
170
|
-
s = await stat(result.realPath);
|
|
171
|
-
} catch {
|
|
172
|
-
return c.json({ error: "not found" }, 404);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (s.isDirectory()) {
|
|
176
|
-
const size = await getDirSize(result.realPath);
|
|
177
|
-
if (size > config.maxDownloadBytes) {
|
|
178
|
-
return c.json(
|
|
179
|
-
{ error: `directory exceeds max download size (${Math.round(config.maxDownloadBytes / 1024 / 1024)}MB)` },
|
|
180
|
-
413,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const dirName = basename(result.realPath);
|
|
185
|
-
|
|
186
|
-
// Create TAR archive using Bun's native API
|
|
187
|
-
const files: Record<string, Blob> = {};
|
|
188
|
-
|
|
189
|
-
// Add directory contents recursively
|
|
190
|
-
const addDirectoryToArchive = async (dirPath: string, basePath: string) => {
|
|
191
|
-
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
192
|
-
for (const entry of entries) {
|
|
193
|
-
const fullPath = join(dirPath, entry.name);
|
|
194
|
-
const archivePath = relative(basePath, fullPath);
|
|
195
|
-
|
|
196
|
-
if (entry.isDirectory()) {
|
|
197
|
-
await addDirectoryToArchive(fullPath, basePath);
|
|
198
|
-
} else if (entry.isFile()) {
|
|
199
|
-
// Read file content as Blob - Bun.file() is lazy and doesn't work reliably with Archive
|
|
200
|
-
const fileContent = await Bun.file(fullPath).arrayBuffer();
|
|
201
|
-
files[archivePath] = new Blob([fileContent]);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
await addDirectoryToArchive(result.realPath, result.realPath);
|
|
207
|
-
|
|
208
|
-
// Generate the tar archive
|
|
209
|
-
const archive = new Bun.Archive(files);
|
|
210
|
-
const archiveBlob = await archive.blob();
|
|
211
|
-
|
|
212
|
-
return new Response(archiveBlob, {
|
|
213
|
-
headers: {
|
|
214
|
-
"Content-Type": "application/x-tar",
|
|
215
|
-
"Content-Disposition": `attachment; filename="${dirName}.tar"`,
|
|
216
|
-
"X-File-Name": `${dirName}.tar`,
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const file = Bun.file(result.realPath);
|
|
222
|
-
if (!(await file.exists())) return c.json({ error: "not found" }, 404);
|
|
223
|
-
|
|
224
|
-
if (file.size > config.maxDownloadBytes) {
|
|
225
|
-
return c.json(
|
|
226
|
-
{ error: `file exceeds max download size (${Math.round(config.maxDownloadBytes / 1024 / 1024)}MB)` },
|
|
227
|
-
413,
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const filename = basename(result.realPath);
|
|
232
|
-
const disposition = inline ? "inline" : "attachment";
|
|
233
|
-
|
|
234
|
-
return new Response(file.stream(), {
|
|
235
|
-
headers: {
|
|
236
|
-
"Content-Type": file.type,
|
|
237
|
-
"Content-Length": String(file.size),
|
|
238
|
-
"Content-Disposition": `${disposition}; filename="${filename}"`,
|
|
239
|
-
"X-File-Name": filename,
|
|
240
|
-
},
|
|
241
|
-
});
|
|
242
|
-
},
|
|
243
|
-
);
|
|
244
|
-
|
|
245
|
-
// PUT /content
|
|
246
|
-
app.put(
|
|
247
|
-
"/content",
|
|
248
|
-
describeRoute({
|
|
249
|
-
tags: ["Files"],
|
|
250
|
-
summary: "Upload file",
|
|
251
|
-
description:
|
|
252
|
-
"Upload a file with optional ownership. Use headers X-File-Path, X-File-Name, and optionally X-Owner-UID, X-Owner-GID, X-File-Mode.",
|
|
253
|
-
...requiresAuth,
|
|
254
|
-
responses: {
|
|
255
|
-
201: jsonResponse(FileInfoSchema, "File created"),
|
|
256
|
-
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
257
|
-
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
258
|
-
413: jsonResponse(ErrorSchema, "File too large"),
|
|
259
|
-
},
|
|
260
|
-
}),
|
|
261
|
-
v("header", UploadFileHeadersSchema),
|
|
262
|
-
async (c) => {
|
|
263
|
-
const headers = c.req.valid("header");
|
|
264
|
-
const dirPath = headers["x-file-path"];
|
|
265
|
-
const rawFilename = headers["x-file-name"];
|
|
266
|
-
|
|
267
|
-
// Sanitize filename to prevent path traversal
|
|
268
|
-
const filename = sanitizeFilename(rawFilename);
|
|
269
|
-
if (!filename || filename !== rawFilename) {
|
|
270
|
-
return c.json({ error: "invalid filename" }, 400);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const fullPath = join(dirPath, filename);
|
|
274
|
-
|
|
275
|
-
// Build ownership from validated headers
|
|
276
|
-
const ownership: import("../lib/ownership").Ownership | null =
|
|
277
|
-
headers["x-owner-uid"] != null && headers["x-owner-gid"] != null && headers["x-file-mode"] != null
|
|
278
|
-
? {
|
|
279
|
-
uid: headers["x-owner-uid"],
|
|
280
|
-
gid: headers["x-owner-gid"],
|
|
281
|
-
mode: parseInt(headers["x-file-mode"], 8),
|
|
282
|
-
dirMode: headers["x-dir-mode"] ? parseInt(headers["x-dir-mode"], 8) : undefined,
|
|
283
|
-
}
|
|
284
|
-
: null;
|
|
285
|
-
|
|
286
|
-
// Validate path and create parent directories with ownership
|
|
287
|
-
const result = await validatePath(fullPath, { createParents: true, ownership });
|
|
288
|
-
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
289
|
-
|
|
290
|
-
const body = c.req.raw.body;
|
|
291
|
-
if (!body) return c.json({ error: "missing body" }, 400);
|
|
292
|
-
|
|
293
|
-
// Stream to file instead of buffering in memory
|
|
294
|
-
let written = 0;
|
|
295
|
-
const file = Bun.file(result.realPath);
|
|
296
|
-
const writer = file.writer();
|
|
297
|
-
|
|
298
|
-
try {
|
|
299
|
-
for await (const chunk of body) {
|
|
300
|
-
written += chunk.length;
|
|
301
|
-
if (written > config.maxUploadBytes) {
|
|
302
|
-
writer.end();
|
|
303
|
-
await rm(result.realPath).catch(() => {});
|
|
304
|
-
return c.json({ error: "file exceeds max upload size" }, 413);
|
|
305
|
-
}
|
|
306
|
-
writer.write(chunk);
|
|
307
|
-
}
|
|
308
|
-
await writer.end();
|
|
309
|
-
} catch (e) {
|
|
310
|
-
writer.end();
|
|
311
|
-
await rm(result.realPath).catch(() => {});
|
|
312
|
-
throw e;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const ownershipError = await applyOwnership(result.realPath, ownership);
|
|
316
|
-
if (ownershipError) {
|
|
317
|
-
await rm(result.realPath).catch(() => {});
|
|
318
|
-
return c.json({ error: ownershipError }, 500);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return c.json(await getFileInfo(result.realPath), 201);
|
|
322
|
-
},
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
// POST /mkdir
|
|
326
|
-
app.post(
|
|
327
|
-
"/mkdir",
|
|
328
|
-
describeRoute({
|
|
329
|
-
tags: ["Files"],
|
|
330
|
-
summary: "Create directory",
|
|
331
|
-
...requiresAuth,
|
|
332
|
-
responses: {
|
|
333
|
-
201: jsonResponse(FileInfoSchema, "Directory created"),
|
|
334
|
-
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
335
|
-
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
336
|
-
},
|
|
337
|
-
}),
|
|
338
|
-
v("json", MkdirBodySchema),
|
|
339
|
-
async (c) => {
|
|
340
|
-
const body = c.req.valid("json");
|
|
341
|
-
|
|
342
|
-
const result = await validatePath(body.path);
|
|
343
|
-
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
344
|
-
|
|
345
|
-
const ownership = parseOwnershipBody(body);
|
|
346
|
-
|
|
347
|
-
await mkdir(result.realPath, { recursive: true });
|
|
348
|
-
|
|
349
|
-
const ownershipError = await applyOwnership(result.realPath, ownership);
|
|
350
|
-
if (ownershipError) {
|
|
351
|
-
await rm(result.realPath, { recursive: true }).catch(() => {});
|
|
352
|
-
return c.json({ error: ownershipError }, 500);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return c.json(await getFileInfo(result.realPath), 201);
|
|
356
|
-
},
|
|
357
|
-
);
|
|
358
|
-
|
|
359
|
-
// DELETE /delete
|
|
360
|
-
app.delete(
|
|
361
|
-
"/delete",
|
|
362
|
-
describeRoute({
|
|
363
|
-
tags: ["Files"],
|
|
364
|
-
summary: "Delete file or directory",
|
|
365
|
-
...requiresAuth,
|
|
366
|
-
responses: {
|
|
367
|
-
204: { description: "Deleted" },
|
|
368
|
-
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
369
|
-
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
370
|
-
404: jsonResponse(ErrorSchema, "Not found"),
|
|
371
|
-
},
|
|
372
|
-
}),
|
|
373
|
-
v("query", PathQuerySchema),
|
|
374
|
-
async (c) => {
|
|
375
|
-
const { path } = c.req.valid("query");
|
|
376
|
-
|
|
377
|
-
const result = await validatePath(path);
|
|
378
|
-
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
379
|
-
|
|
380
|
-
let s;
|
|
381
|
-
try {
|
|
382
|
-
s = await stat(result.realPath);
|
|
383
|
-
} catch {
|
|
384
|
-
return c.json({ error: "not found" }, 404);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
await rm(result.realPath, { recursive: s.isDirectory() });
|
|
388
|
-
return c.body(null, 204);
|
|
389
|
-
},
|
|
390
|
-
);
|
|
391
|
-
|
|
392
|
-
// POST /transfer
|
|
393
|
-
app.post(
|
|
394
|
-
"/transfer",
|
|
395
|
-
describeRoute({
|
|
396
|
-
tags: ["Files"],
|
|
397
|
-
summary: "Move or copy file/directory",
|
|
398
|
-
description:
|
|
399
|
-
"Transfer files between locations. Mode 'move' requires same base path. " +
|
|
400
|
-
"Mode 'copy' allows cross-base transfer when ownership (ownerUid, ownerGid, fileMode) is provided.",
|
|
401
|
-
...requiresAuth,
|
|
402
|
-
responses: {
|
|
403
|
-
200: jsonResponse(FileInfoSchema, "Transferred"),
|
|
404
|
-
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
405
|
-
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
406
|
-
404: jsonResponse(ErrorSchema, "Not found"),
|
|
407
|
-
},
|
|
408
|
-
}),
|
|
409
|
-
v("json", TransferBodySchema),
|
|
410
|
-
async (c) => {
|
|
411
|
-
const { from, to, mode, ensureUniqueName, ownerUid, ownerGid, fileMode, dirMode } = c.req.valid("json");
|
|
412
|
-
|
|
413
|
-
// Build ownership if provided
|
|
414
|
-
const ownership =
|
|
415
|
-
ownerUid != null && ownerGid != null && fileMode != null
|
|
416
|
-
? {
|
|
417
|
-
uid: ownerUid,
|
|
418
|
-
gid: ownerGid,
|
|
419
|
-
mode: parseInt(fileMode, 8),
|
|
420
|
-
dirMode: dirMode ? parseInt(dirMode, 8) : undefined,
|
|
421
|
-
}
|
|
422
|
-
: null;
|
|
423
|
-
|
|
424
|
-
// Move always requires same base
|
|
425
|
-
if (mode === "move") {
|
|
426
|
-
const result = await validateSameBase(from, to);
|
|
427
|
-
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
428
|
-
|
|
429
|
-
try {
|
|
430
|
-
await stat(result.realPath);
|
|
431
|
-
} catch {
|
|
432
|
-
return c.json({ error: "source not found" }, 404);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const targetPath = ensureUniqueName ? await getUniquePath(result.realTo) : result.realTo;
|
|
436
|
-
|
|
437
|
-
await mkdir(join(targetPath, ".."), { recursive: true });
|
|
438
|
-
await rename(result.realPath, targetPath);
|
|
439
|
-
|
|
440
|
-
// Apply ownership if provided (for move within same base)
|
|
441
|
-
if (ownership) {
|
|
442
|
-
const ownershipError = await applyOwnershipRecursive(targetPath, ownership);
|
|
443
|
-
if (ownershipError) {
|
|
444
|
-
return c.json({ error: ownershipError }, 500);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
return c.json(await getFileInfo(targetPath));
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Copy: check if same base or cross-base with ownership
|
|
452
|
-
const sameBaseResult = await validateSameBase(from, to);
|
|
453
|
-
|
|
454
|
-
if (sameBaseResult.ok) {
|
|
455
|
-
// Same base - no ownership required
|
|
456
|
-
try {
|
|
457
|
-
await stat(sameBaseResult.realPath);
|
|
458
|
-
} catch {
|
|
459
|
-
return c.json({ error: "source not found" }, 404);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const targetPath = ensureUniqueName ? await getUniquePath(sameBaseResult.realTo) : sameBaseResult.realTo;
|
|
463
|
-
|
|
464
|
-
await mkdir(join(targetPath, ".."), { recursive: true });
|
|
465
|
-
await cp(sameBaseResult.realPath, targetPath, { recursive: true });
|
|
466
|
-
|
|
467
|
-
// Apply ownership if provided
|
|
468
|
-
if (ownership) {
|
|
469
|
-
const ownershipError = await applyOwnershipRecursive(targetPath, ownership);
|
|
470
|
-
if (ownershipError) {
|
|
471
|
-
await rm(targetPath, { recursive: true }).catch(() => {});
|
|
472
|
-
return c.json({ error: ownershipError }, 500);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
return c.json(await getFileInfo(targetPath));
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// Cross-base copy - ownership is required
|
|
480
|
-
if (!ownership) {
|
|
481
|
-
return c.json({ error: "cross-base copy requires ownership (ownerUid, ownerGid, fileMode)" }, 400);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Validate source and destination separately
|
|
485
|
-
const fromResult = await validatePath(from);
|
|
486
|
-
if (!fromResult.ok) return c.json({ error: fromResult.error }, fromResult.status);
|
|
487
|
-
|
|
488
|
-
const toResult = await validatePath(to, { createParents: true, ownership });
|
|
489
|
-
if (!toResult.ok) return c.json({ error: toResult.error }, toResult.status);
|
|
490
|
-
|
|
491
|
-
try {
|
|
492
|
-
await stat(fromResult.realPath);
|
|
493
|
-
} catch {
|
|
494
|
-
return c.json({ error: "source not found" }, 404);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const targetPath = ensureUniqueName ? await getUniquePath(toResult.realPath) : toResult.realPath;
|
|
498
|
-
|
|
499
|
-
await mkdir(join(targetPath, ".."), { recursive: true });
|
|
500
|
-
await cp(fromResult.realPath, targetPath, { recursive: true });
|
|
501
|
-
|
|
502
|
-
// Apply ownership recursively to copied content
|
|
503
|
-
const ownershipError = await applyOwnershipRecursive(targetPath, ownership);
|
|
504
|
-
if (ownershipError) {
|
|
505
|
-
await rm(targetPath, { recursive: true }).catch(() => {});
|
|
506
|
-
return c.json({ error: ownershipError }, 500);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return c.json(await getFileInfo(targetPath));
|
|
510
|
-
},
|
|
511
|
-
);
|
|
512
|
-
|
|
513
|
-
export default app;
|
package/src/handlers/search.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
2
|
-
import { describeRoute } from "hono-openapi";
|
|
3
|
-
import { Glob } from "bun";
|
|
4
|
-
import { stat } from "node:fs/promises";
|
|
5
|
-
import { join, basename, relative } from "node:path";
|
|
6
|
-
import { validatePath } from "../lib/path";
|
|
7
|
-
import { jsonResponse, requiresAuth } from "../lib/openapi";
|
|
8
|
-
import { v } from "../lib/validator";
|
|
9
|
-
import {
|
|
10
|
-
SearchResponseSchema,
|
|
11
|
-
ErrorSchema,
|
|
12
|
-
SearchQuerySchema,
|
|
13
|
-
countRecursiveWildcards,
|
|
14
|
-
type FileInfo,
|
|
15
|
-
type SearchResult,
|
|
16
|
-
} from "../schemas";
|
|
17
|
-
import { config } from "../config";
|
|
18
|
-
|
|
19
|
-
const app = new Hono();
|
|
20
|
-
|
|
21
|
-
const getFileInfo = async (fullPath: string, basePath: string): Promise<FileInfo | null> => {
|
|
22
|
-
try {
|
|
23
|
-
const s = await stat(fullPath);
|
|
24
|
-
const name = basename(fullPath);
|
|
25
|
-
const file = Bun.file(fullPath);
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
name,
|
|
29
|
-
path: relative(basePath, fullPath),
|
|
30
|
-
type: s.isDirectory() ? "directory" : "file",
|
|
31
|
-
size: s.isDirectory() ? 0 : s.size,
|
|
32
|
-
mtime: s.mtime.toISOString(),
|
|
33
|
-
isHidden: name.startsWith("."),
|
|
34
|
-
mimeType: s.isDirectory() ? undefined : file.type,
|
|
35
|
-
};
|
|
36
|
-
} catch {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const searchInPath = async (
|
|
42
|
-
basePath: string,
|
|
43
|
-
pattern: string,
|
|
44
|
-
showHidden: boolean,
|
|
45
|
-
limit: number,
|
|
46
|
-
includeFiles: boolean,
|
|
47
|
-
includeDirectories: boolean,
|
|
48
|
-
): Promise<SearchResult> => {
|
|
49
|
-
const glob = new Glob(pattern);
|
|
50
|
-
const files: FileInfo[] = [];
|
|
51
|
-
let hasMore = false;
|
|
52
|
-
|
|
53
|
-
// If we need directories, we must set onlyFiles: false
|
|
54
|
-
const onlyFiles = includeFiles && !includeDirectories;
|
|
55
|
-
|
|
56
|
-
for await (const match of glob.scan({ cwd: basePath, dot: showHidden, onlyFiles })) {
|
|
57
|
-
if (!showHidden && basename(match).startsWith(".")) continue;
|
|
58
|
-
|
|
59
|
-
if (files.length >= limit) {
|
|
60
|
-
hasMore = true;
|
|
61
|
-
break;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const info = await getFileInfo(join(basePath, match), basePath);
|
|
65
|
-
if (!info) continue;
|
|
66
|
-
|
|
67
|
-
// Filter based on type
|
|
68
|
-
if (info.type === "file" && !includeFiles) continue;
|
|
69
|
-
if (info.type === "directory" && !includeDirectories) continue;
|
|
70
|
-
|
|
71
|
-
files.push(info);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return { basePath, files, total: files.length, hasMore };
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
app.get(
|
|
78
|
-
"/search",
|
|
79
|
-
describeRoute({
|
|
80
|
-
tags: ["Search"],
|
|
81
|
-
summary: "Search files with glob pattern",
|
|
82
|
-
description: "Search multiple paths in parallel using glob patterns",
|
|
83
|
-
...requiresAuth,
|
|
84
|
-
responses: {
|
|
85
|
-
200: jsonResponse(SearchResponseSchema, "Search results"),
|
|
86
|
-
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
87
|
-
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
88
|
-
404: jsonResponse(ErrorSchema, "Not found"),
|
|
89
|
-
},
|
|
90
|
-
}),
|
|
91
|
-
v("query", SearchQuerySchema),
|
|
92
|
-
async (c) => {
|
|
93
|
-
const { paths: pathsParam, pattern, showHidden, limit: limitParam, files, directories } = c.req.valid("query");
|
|
94
|
-
|
|
95
|
-
// At least one of files or directories must be true
|
|
96
|
-
if (!files && !directories) {
|
|
97
|
-
return c.json({ error: "at least one of 'files' or 'directories' must be true" }, 400);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Validate recursive wildcard count
|
|
101
|
-
const wildcardCount = countRecursiveWildcards(pattern);
|
|
102
|
-
if (wildcardCount > config.searchMaxRecursiveWildcards) {
|
|
103
|
-
return c.json(
|
|
104
|
-
{ error: `too many recursive wildcards: ${wildcardCount} (max ${config.searchMaxRecursiveWildcards})` },
|
|
105
|
-
400,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const limit = Math.min(limitParam ?? config.searchMaxResults, config.searchMaxResults);
|
|
110
|
-
const paths = pathsParam
|
|
111
|
-
.split(",")
|
|
112
|
-
.map((p) => p.trim())
|
|
113
|
-
.filter(Boolean);
|
|
114
|
-
const validPaths: string[] = [];
|
|
115
|
-
|
|
116
|
-
for (const p of paths) {
|
|
117
|
-
const result = await validatePath(p, { allowBasePath: true });
|
|
118
|
-
if (!result.ok) return c.json({ error: result.error }, result.status);
|
|
119
|
-
|
|
120
|
-
const s = await stat(result.realPath).catch(() => null);
|
|
121
|
-
if (!s) return c.json({ error: `path not found: ${p}` }, 404);
|
|
122
|
-
if (!s.isDirectory()) return c.json({ error: `not a directory: ${p}` }, 400);
|
|
123
|
-
|
|
124
|
-
validPaths.push(result.realPath);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const results = await Promise.all(
|
|
128
|
-
validPaths.map((p) => searchInPath(p, pattern, showHidden, limit, files, directories)),
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const totalFiles = results.reduce((sum, r) => sum + r.total, 0);
|
|
132
|
-
|
|
133
|
-
return c.json({ results, totalFiles });
|
|
134
|
-
},
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
export default app;
|