@valentinkolb/filegate 0.0.1

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.
@@ -0,0 +1,423 @@
1
+ import { Hono } from "hono";
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";
5
+ import sanitizeFilename from "sanitize-filename";
6
+ import { validatePath, validateSameBase } from "../lib/path";
7
+ import { parseOwnershipBody, applyOwnership } 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
+ MkdirBodySchema,
17
+ MoveBodySchema,
18
+ CopyBodySchema,
19
+ UploadFileHeadersSchema,
20
+ type FileInfo,
21
+ } from "../schemas";
22
+ import { config } from "../config";
23
+
24
+ const app = new Hono();
25
+
26
+ // Cross-platform directory size using `du` command
27
+ const getDirSize = async (dirPath: string): Promise<number> => {
28
+ const isMac = process.platform === "darwin";
29
+
30
+ // macOS (BSD): du -sk (kilobytes), Linux (GNU): du -sb (bytes)
31
+ const args = isMac ? ["-sk", dirPath] : ["-sb", dirPath];
32
+ const multiplier = isMac ? 1024 : 1;
33
+
34
+ try {
35
+ const proc = Bun.spawn(["du", ...args], {
36
+ stdout: "pipe",
37
+ stderr: "ignore",
38
+ });
39
+ const output = await new Response(proc.stdout).text();
40
+ await proc.exited;
41
+
42
+ const value = parseInt(output.split("\t")[0] ?? "", 10);
43
+ return isNaN(value) ? 0 : value * multiplier;
44
+ } catch {
45
+ return 0;
46
+ }
47
+ };
48
+
49
+ const getFileInfo = async (path: string, relativeTo?: string): Promise<FileInfo> => {
50
+ const file = Bun.file(path);
51
+ const s = await stat(path);
52
+ const name = basename(path);
53
+
54
+ return {
55
+ name,
56
+ path: relativeTo ? relative(relativeTo, path) : path,
57
+ type: s.isDirectory() ? "directory" : "file",
58
+ size: s.isDirectory() ? 0 : s.size,
59
+ mtime: s.mtime.toISOString(),
60
+ isHidden: name.startsWith("."),
61
+ mimeType: s.isDirectory() ? undefined : file.type,
62
+ };
63
+ };
64
+
65
+ // GET /info
66
+ app.get(
67
+ "/info",
68
+ describeRoute({
69
+ tags: ["Files"],
70
+ summary: "Get file or directory info",
71
+ ...requiresAuth,
72
+ responses: {
73
+ 200: jsonResponse(DirInfoSchema, "File or directory info"),
74
+ 400: jsonResponse(ErrorSchema, "Bad request"),
75
+ 403: jsonResponse(ErrorSchema, "Forbidden"),
76
+ 404: jsonResponse(ErrorSchema, "Not found"),
77
+ },
78
+ }),
79
+ v("query", InfoQuerySchema),
80
+ async (c) => {
81
+ const { path, showHidden } = c.req.valid("query");
82
+
83
+ const result = await validatePath(path, true);
84
+ if (!result.ok) return c.json({ error: result.error }, result.status);
85
+
86
+ let s;
87
+ try {
88
+ s = await stat(result.realPath);
89
+ } catch {
90
+ return c.json({ error: "not found" }, 404);
91
+ }
92
+
93
+ if (!s.isDirectory()) {
94
+ return c.json(await getFileInfo(result.realPath));
95
+ }
96
+
97
+ const entries = await readdir(result.realPath, { withFileTypes: true });
98
+
99
+ // Parallel file info retrieval
100
+ const items = (
101
+ await Promise.all(
102
+ entries
103
+ .filter((e) => showHidden || !e.name.startsWith("."))
104
+ .map((e) => getFileInfo(join(result.realPath, e.name), result.realPath).catch(() => null)),
105
+ )
106
+ ).filter((item): item is FileInfo => item !== null);
107
+
108
+ const info = await getFileInfo(result.realPath);
109
+ return c.json({ ...info, items, total: items.length });
110
+ },
111
+ );
112
+
113
+ // GET /content
114
+ app.get(
115
+ "/content",
116
+ describeRoute({
117
+ tags: ["Files"],
118
+ summary: "Download file or directory",
119
+ description: "Downloads a file directly or a directory as a TAR archive. Size limit applies to both.",
120
+ ...requiresAuth,
121
+ responses: {
122
+ 200: binaryResponse("application/octet-stream", "File content or TAR archive"),
123
+ 400: jsonResponse(ErrorSchema, "Bad request"),
124
+ 403: jsonResponse(ErrorSchema, "Forbidden"),
125
+ 404: jsonResponse(ErrorSchema, "Not found"),
126
+ 413: jsonResponse(ErrorSchema, "Content too large"),
127
+ },
128
+ }),
129
+ v("query", PathQuerySchema),
130
+ async (c) => {
131
+ const { path } = c.req.valid("query");
132
+
133
+ const result = await validatePath(path);
134
+ if (!result.ok) return c.json({ error: result.error }, result.status);
135
+
136
+ let s;
137
+ try {
138
+ s = await stat(result.realPath);
139
+ } catch {
140
+ return c.json({ error: "not found" }, 404);
141
+ }
142
+
143
+ if (s.isDirectory()) {
144
+ const size = await getDirSize(result.realPath);
145
+ if (size > config.maxDownloadBytes) {
146
+ return c.json(
147
+ { error: `directory exceeds max download size (${Math.round(config.maxDownloadBytes / 1024 / 1024)}MB)` },
148
+ 413,
149
+ );
150
+ }
151
+
152
+ const dirName = basename(result.realPath);
153
+
154
+ // Create TAR archive using Bun's native API
155
+ const files: Record<string, Blob> = {};
156
+
157
+ // Add directory contents recursively
158
+ const addDirectoryToArchive = async (dirPath: string, basePath: string) => {
159
+ const entries = await readdir(dirPath, { withFileTypes: true });
160
+ for (const entry of entries) {
161
+ const fullPath = join(dirPath, entry.name);
162
+ const archivePath = relative(basePath, fullPath);
163
+
164
+ if (entry.isDirectory()) {
165
+ await addDirectoryToArchive(fullPath, basePath);
166
+ } else if (entry.isFile()) {
167
+ files[archivePath] = Bun.file(fullPath);
168
+ }
169
+ }
170
+ };
171
+
172
+ await addDirectoryToArchive(result.realPath, result.realPath);
173
+
174
+ // Generate the tar archive
175
+ const archive = new Bun.Archive(files);
176
+ const archiveBlob = await archive.blob();
177
+
178
+ return new Response(archiveBlob, {
179
+ headers: {
180
+ "Content-Type": "application/x-tar",
181
+ "Content-Disposition": `attachment; filename="${dirName}.tar"`,
182
+ "X-File-Name": `${dirName}.tar`,
183
+ },
184
+ });
185
+ }
186
+
187
+ const file = Bun.file(result.realPath);
188
+ if (!(await file.exists())) return c.json({ error: "not found" }, 404);
189
+
190
+ if (file.size > config.maxDownloadBytes) {
191
+ return c.json(
192
+ { error: `file exceeds max download size (${Math.round(config.maxDownloadBytes / 1024 / 1024)}MB)` },
193
+ 413,
194
+ );
195
+ }
196
+
197
+ return new Response(file.stream(), {
198
+ headers: {
199
+ "Content-Type": file.type,
200
+ "Content-Length": String(file.size),
201
+ "X-File-Name": basename(result.realPath),
202
+ },
203
+ });
204
+ },
205
+ );
206
+
207
+ // PUT /content
208
+ app.put(
209
+ "/content",
210
+ describeRoute({
211
+ tags: ["Files"],
212
+ summary: "Upload file",
213
+ description:
214
+ "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.",
215
+ ...requiresAuth,
216
+ responses: {
217
+ 201: jsonResponse(FileInfoSchema, "File created"),
218
+ 400: jsonResponse(ErrorSchema, "Bad request"),
219
+ 403: jsonResponse(ErrorSchema, "Forbidden"),
220
+ 413: jsonResponse(ErrorSchema, "File too large"),
221
+ },
222
+ }),
223
+ v("header", UploadFileHeadersSchema),
224
+ async (c) => {
225
+ const headers = c.req.valid("header");
226
+ const dirPath = headers["x-file-path"];
227
+ const rawFilename = headers["x-file-name"];
228
+
229
+ // Sanitize filename to prevent path traversal
230
+ const filename = sanitizeFilename(rawFilename);
231
+ if (!filename || filename !== rawFilename) {
232
+ return c.json({ error: "invalid filename" }, 400);
233
+ }
234
+
235
+ const fullPath = join(dirPath, filename);
236
+ const result = await validatePath(fullPath);
237
+ if (!result.ok) return c.json({ error: result.error }, result.status);
238
+
239
+ // Build ownership from validated headers
240
+ const ownership: import("../lib/ownership").Ownership | null =
241
+ headers["x-owner-uid"] != null && headers["x-owner-gid"] != null && headers["x-file-mode"] != null
242
+ ? {
243
+ uid: headers["x-owner-uid"],
244
+ gid: headers["x-owner-gid"],
245
+ mode: parseInt(headers["x-file-mode"], 8),
246
+ }
247
+ : null;
248
+
249
+ await mkdir(dirPath, { recursive: true });
250
+
251
+ const body = c.req.raw.body;
252
+ if (!body) return c.json({ error: "missing body" }, 400);
253
+
254
+ // Stream to file instead of buffering in memory
255
+ let written = 0;
256
+ const file = Bun.file(result.realPath);
257
+ const writer = file.writer();
258
+
259
+ try {
260
+ for await (const chunk of body) {
261
+ written += chunk.length;
262
+ if (written > config.maxUploadBytes) {
263
+ writer.end();
264
+ await rm(result.realPath).catch(() => {});
265
+ return c.json({ error: "file exceeds max upload size" }, 413);
266
+ }
267
+ writer.write(chunk);
268
+ }
269
+ await writer.end();
270
+ } catch (e) {
271
+ writer.end();
272
+ await rm(result.realPath).catch(() => {});
273
+ throw e;
274
+ }
275
+
276
+ const ownershipError = await applyOwnership(result.realPath, ownership);
277
+ if (ownershipError) {
278
+ await rm(result.realPath).catch(() => {});
279
+ return c.json({ error: ownershipError }, 500);
280
+ }
281
+
282
+ return c.json(await getFileInfo(result.realPath), 201);
283
+ },
284
+ );
285
+
286
+ // POST /mkdir
287
+ app.post(
288
+ "/mkdir",
289
+ describeRoute({
290
+ tags: ["Files"],
291
+ summary: "Create directory",
292
+ ...requiresAuth,
293
+ responses: {
294
+ 201: jsonResponse(FileInfoSchema, "Directory created"),
295
+ 400: jsonResponse(ErrorSchema, "Bad request"),
296
+ 403: jsonResponse(ErrorSchema, "Forbidden"),
297
+ },
298
+ }),
299
+ v("json", MkdirBodySchema),
300
+ async (c) => {
301
+ const body = c.req.valid("json");
302
+
303
+ const result = await validatePath(body.path);
304
+ if (!result.ok) return c.json({ error: result.error }, result.status);
305
+
306
+ const ownership = parseOwnershipBody(body);
307
+
308
+ await mkdir(result.realPath, { recursive: true });
309
+
310
+ const ownershipError = await applyOwnership(result.realPath, ownership);
311
+ if (ownershipError) {
312
+ await rm(result.realPath, { recursive: true }).catch(() => {});
313
+ return c.json({ error: ownershipError }, 500);
314
+ }
315
+
316
+ return c.json(await getFileInfo(result.realPath), 201);
317
+ },
318
+ );
319
+
320
+ // DELETE /delete
321
+ app.delete(
322
+ "/delete",
323
+ describeRoute({
324
+ tags: ["Files"],
325
+ summary: "Delete file or directory",
326
+ ...requiresAuth,
327
+ responses: {
328
+ 204: { description: "Deleted" },
329
+ 400: jsonResponse(ErrorSchema, "Bad request"),
330
+ 403: jsonResponse(ErrorSchema, "Forbidden"),
331
+ 404: jsonResponse(ErrorSchema, "Not found"),
332
+ },
333
+ }),
334
+ v("query", PathQuerySchema),
335
+ async (c) => {
336
+ const { path } = c.req.valid("query");
337
+
338
+ const result = await validatePath(path);
339
+ if (!result.ok) return c.json({ error: result.error }, result.status);
340
+
341
+ let s;
342
+ try {
343
+ s = await stat(result.realPath);
344
+ } catch {
345
+ return c.json({ error: "not found" }, 404);
346
+ }
347
+
348
+ await rm(result.realPath, { recursive: s.isDirectory() });
349
+ return c.body(null, 204);
350
+ },
351
+ );
352
+
353
+ // POST /move
354
+ app.post(
355
+ "/move",
356
+ describeRoute({
357
+ tags: ["Files"],
358
+ summary: "Move file or directory",
359
+ description: "Move within same base path only",
360
+ ...requiresAuth,
361
+ responses: {
362
+ 200: jsonResponse(FileInfoSchema, "Moved"),
363
+ 400: jsonResponse(ErrorSchema, "Bad request"),
364
+ 403: jsonResponse(ErrorSchema, "Forbidden"),
365
+ 404: jsonResponse(ErrorSchema, "Not found"),
366
+ },
367
+ }),
368
+ v("json", MoveBodySchema),
369
+ async (c) => {
370
+ const { from, to } = c.req.valid("json");
371
+
372
+ const result = await validateSameBase(from, to);
373
+ if (!result.ok) return c.json({ error: result.error }, result.status);
374
+
375
+ try {
376
+ await stat(result.realPath);
377
+ } catch {
378
+ return c.json({ error: "source not found" }, 404);
379
+ }
380
+
381
+ await mkdir(join(result.realTo, ".."), { recursive: true });
382
+ await rename(result.realPath, result.realTo);
383
+
384
+ return c.json(await getFileInfo(result.realTo));
385
+ },
386
+ );
387
+
388
+ // POST /copy
389
+ app.post(
390
+ "/copy",
391
+ describeRoute({
392
+ tags: ["Files"],
393
+ summary: "Copy file or directory",
394
+ description: "Copy within same base path only",
395
+ ...requiresAuth,
396
+ responses: {
397
+ 200: jsonResponse(FileInfoSchema, "Copied"),
398
+ 400: jsonResponse(ErrorSchema, "Bad request"),
399
+ 403: jsonResponse(ErrorSchema, "Forbidden"),
400
+ 404: jsonResponse(ErrorSchema, "Not found"),
401
+ },
402
+ }),
403
+ v("json", CopyBodySchema),
404
+ async (c) => {
405
+ const { from, to } = c.req.valid("json");
406
+
407
+ const result = await validateSameBase(from, to);
408
+ if (!result.ok) return c.json({ error: result.error }, result.status);
409
+
410
+ try {
411
+ await stat(result.realPath);
412
+ } catch {
413
+ return c.json({ error: "source not found" }, 404);
414
+ }
415
+
416
+ await mkdir(join(result.realTo, ".."), { recursive: true });
417
+ await cp(result.realPath, result.realTo, { recursive: true });
418
+
419
+ return c.json(await getFileInfo(result.realTo));
420
+ },
421
+ );
422
+
423
+ export default app;
@@ -0,0 +1,119 @@
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
+ ): Promise<SearchResult> => {
47
+ const glob = new Glob(pattern);
48
+ const files: FileInfo[] = [];
49
+ let hasMore = false;
50
+
51
+ for await (const match of glob.scan({ cwd: basePath, dot: showHidden })) {
52
+ if (!showHidden && basename(match).startsWith(".")) continue;
53
+
54
+ if (files.length >= limit) {
55
+ hasMore = true;
56
+ break;
57
+ }
58
+
59
+ const info = await getFileInfo(join(basePath, match), basePath);
60
+ if (info) files.push(info);
61
+ }
62
+
63
+ return { basePath, files, total: files.length, hasMore };
64
+ };
65
+
66
+ app.get(
67
+ "/search",
68
+ describeRoute({
69
+ tags: ["Search"],
70
+ summary: "Search files with glob pattern",
71
+ description: "Search multiple paths in parallel using glob patterns",
72
+ ...requiresAuth,
73
+ responses: {
74
+ 200: jsonResponse(SearchResponseSchema, "Search results"),
75
+ 400: jsonResponse(ErrorSchema, "Bad request"),
76
+ 403: jsonResponse(ErrorSchema, "Forbidden"),
77
+ 404: jsonResponse(ErrorSchema, "Not found"),
78
+ },
79
+ }),
80
+ v("query", SearchQuerySchema),
81
+ async (c) => {
82
+ const { paths: pathsParam, pattern, showHidden, limit: limitParam } = c.req.valid("query");
83
+
84
+ // Validate recursive wildcard count
85
+ const wildcardCount = countRecursiveWildcards(pattern);
86
+ if (wildcardCount > config.searchMaxRecursiveWildcards) {
87
+ return c.json(
88
+ { error: `too many recursive wildcards: ${wildcardCount} (max ${config.searchMaxRecursiveWildcards})` },
89
+ 400,
90
+ );
91
+ }
92
+
93
+ const limit = Math.min(limitParam ?? config.searchMaxResults, config.searchMaxResults);
94
+ const paths = pathsParam
95
+ .split(",")
96
+ .map((p) => p.trim())
97
+ .filter(Boolean);
98
+ const validPaths: string[] = [];
99
+
100
+ for (const p of paths) {
101
+ const result = await validatePath(p, true);
102
+ if (!result.ok) return c.json({ error: result.error }, result.status);
103
+
104
+ const s = await stat(result.realPath).catch(() => null);
105
+ if (!s) return c.json({ error: `path not found: ${p}` }, 404);
106
+ if (!s.isDirectory()) return c.json({ error: `not a directory: ${p}` }, 400);
107
+
108
+ validPaths.push(result.realPath);
109
+ }
110
+
111
+ const results = await Promise.all(validPaths.map((p) => searchInPath(p, pattern, showHidden, limit)));
112
+
113
+ const totalFiles = results.reduce((sum, r) => sum + r.total, 0);
114
+
115
+ return c.json({ results, totalFiles });
116
+ },
117
+ );
118
+
119
+ export default app;