@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.
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/package.json +56 -0
- package/src/client.ts +354 -0
- package/src/config.ts +37 -0
- package/src/handlers/files.ts +423 -0
- package/src/handlers/search.ts +119 -0
- package/src/handlers/upload.ts +332 -0
- package/src/index.ts +83 -0
- package/src/lib/openapi.ts +47 -0
- package/src/lib/ownership.ts +64 -0
- package/src/lib/path.ts +103 -0
- package/src/lib/response.ts +10 -0
- package/src/lib/validator.ts +21 -0
- package/src/schemas.ts +170 -0
- package/src/utils.ts +282 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describeRoute } from "hono-openapi";
|
|
3
|
+
import { redis } from "bun";
|
|
4
|
+
import { mkdir, readdir, rm, stat, rename } from "node:fs/promises";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { validatePath } from "../lib/path";
|
|
7
|
+
import { applyOwnership, type Ownership } from "../lib/ownership";
|
|
8
|
+
import { jsonResponse, requiresAuth } from "../lib/openapi";
|
|
9
|
+
import { v } from "../lib/validator";
|
|
10
|
+
import {
|
|
11
|
+
UploadStartBodySchema,
|
|
12
|
+
UploadStartResponseSchema,
|
|
13
|
+
UploadChunkResponseSchema,
|
|
14
|
+
ErrorSchema,
|
|
15
|
+
UploadChunkHeadersSchema,
|
|
16
|
+
} from "../schemas";
|
|
17
|
+
import { config } from "../config";
|
|
18
|
+
|
|
19
|
+
const app = new Hono();
|
|
20
|
+
|
|
21
|
+
// Deterministic upload ID from path + filename + checksum
|
|
22
|
+
const computeUploadId = (path: string, filename: string, checksum: string): string => {
|
|
23
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
24
|
+
hasher.update(`${path}:${filename}:${checksum}`);
|
|
25
|
+
return hasher.digest("hex").slice(0, 16);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Single Redis key per upload
|
|
29
|
+
const metaKey = (id: string) => `filegate:upload:${id}`;
|
|
30
|
+
|
|
31
|
+
// Chunk storage paths
|
|
32
|
+
const chunksDir = (id: string) => join(config.uploadTempDir, id);
|
|
33
|
+
const chunkPath = (id: string, idx: number) => join(chunksDir(id), String(idx));
|
|
34
|
+
|
|
35
|
+
type UploadMeta = {
|
|
36
|
+
uploadId: string;
|
|
37
|
+
path: string;
|
|
38
|
+
filename: string;
|
|
39
|
+
size: number;
|
|
40
|
+
checksum: string;
|
|
41
|
+
chunkSize: number;
|
|
42
|
+
totalChunks: number;
|
|
43
|
+
ownership: Ownership | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const saveMeta = async (meta: UploadMeta): Promise<void> => {
|
|
47
|
+
await redis.set(metaKey(meta.uploadId), JSON.stringify(meta), "EX", config.uploadExpirySecs);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const loadMeta = async (id: string): Promise<UploadMeta | null> => {
|
|
51
|
+
const data = await redis.get(metaKey(id));
|
|
52
|
+
return data ? (JSON.parse(data) as UploadMeta) : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const refreshExpiry = async (id: string): Promise<void> => {
|
|
56
|
+
await redis.expire(metaKey(id), config.uploadExpirySecs);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Get uploaded chunks from filesystem instead of Redis
|
|
60
|
+
const getUploadedChunks = async (id: string): Promise<number[]> => {
|
|
61
|
+
try {
|
|
62
|
+
const files = await readdir(chunksDir(id));
|
|
63
|
+
return files
|
|
64
|
+
.map((f) => parseInt(f, 10))
|
|
65
|
+
.filter((n) => !isNaN(n))
|
|
66
|
+
.sort((a, b) => a - b);
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const cleanupUpload = async (id: string): Promise<void> => {
|
|
73
|
+
await Promise.all([redis.del(metaKey(id)), rm(chunksDir(id), { recursive: true }).catch(() => {})]);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const assembleFile = async (meta: UploadMeta): Promise<string | null> => {
|
|
77
|
+
const chunks = await getUploadedChunks(meta.uploadId);
|
|
78
|
+
if (chunks.length !== meta.totalChunks) return "missing chunks";
|
|
79
|
+
|
|
80
|
+
const fullPath = join(meta.path, meta.filename);
|
|
81
|
+
const pathResult = await validatePath(fullPath);
|
|
82
|
+
if (!pathResult.ok) return pathResult.error;
|
|
83
|
+
|
|
84
|
+
await mkdir(dirname(pathResult.realPath), { recursive: true });
|
|
85
|
+
|
|
86
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
87
|
+
const file = Bun.file(pathResult.realPath);
|
|
88
|
+
const writer = file.writer();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
for (let i = 0; i < meta.totalChunks; i++) {
|
|
92
|
+
// Stream each chunk instead of loading entirely into memory
|
|
93
|
+
const chunkFile = Bun.file(chunkPath(meta.uploadId, i));
|
|
94
|
+
for await (const data of chunkFile.stream()) {
|
|
95
|
+
hasher.update(data);
|
|
96
|
+
writer.write(data);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await writer.end();
|
|
100
|
+
} catch (e) {
|
|
101
|
+
writer.end();
|
|
102
|
+
await rm(pathResult.realPath).catch(() => {});
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const finalChecksum = `sha256:${hasher.digest("hex")}`;
|
|
107
|
+
if (finalChecksum !== meta.checksum) {
|
|
108
|
+
await rm(pathResult.realPath).catch(() => {});
|
|
109
|
+
return `checksum mismatch: expected ${meta.checksum}, got ${finalChecksum}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const ownershipError = await applyOwnership(pathResult.realPath, meta.ownership);
|
|
113
|
+
if (ownershipError) {
|
|
114
|
+
await rm(pathResult.realPath).catch(() => {});
|
|
115
|
+
return ownershipError;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await cleanupUpload(meta.uploadId);
|
|
119
|
+
return null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// POST /upload/start
|
|
123
|
+
app.post(
|
|
124
|
+
"/start",
|
|
125
|
+
describeRoute({
|
|
126
|
+
tags: ["Upload"],
|
|
127
|
+
summary: "Start or resume chunked upload",
|
|
128
|
+
description: "Initialize a new upload or get status of existing upload with same checksum",
|
|
129
|
+
...requiresAuth,
|
|
130
|
+
responses: {
|
|
131
|
+
200: jsonResponse(UploadStartResponseSchema, "Upload initialized or resumed"),
|
|
132
|
+
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
133
|
+
403: jsonResponse(ErrorSchema, "Forbidden"),
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
v("json", UploadStartBodySchema),
|
|
137
|
+
async (c) => {
|
|
138
|
+
const body = c.req.valid("json");
|
|
139
|
+
|
|
140
|
+
// Validate size limits
|
|
141
|
+
if (body.size > config.maxUploadBytes) {
|
|
142
|
+
return c.json({ error: `file size exceeds maximum (${config.maxUploadBytes / 1024 / 1024}MB)` }, 413);
|
|
143
|
+
}
|
|
144
|
+
if (body.chunkSize > config.maxChunkBytes) {
|
|
145
|
+
return c.json({ error: `chunk size exceeds maximum (${config.maxChunkBytes / 1024 / 1024}MB)` }, 400);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const fullPath = join(body.path, body.filename);
|
|
149
|
+
const pathResult = await validatePath(fullPath);
|
|
150
|
+
if (!pathResult.ok) return c.json({ error: pathResult.error }, pathResult.status);
|
|
151
|
+
|
|
152
|
+
// Deterministic upload ID - same file/path/checksum = same ID (enables resume)
|
|
153
|
+
const uploadId = computeUploadId(body.path, body.filename, body.checksum);
|
|
154
|
+
|
|
155
|
+
// Check for existing upload (resume)
|
|
156
|
+
const existingMeta = await loadMeta(uploadId);
|
|
157
|
+
if (existingMeta) {
|
|
158
|
+
// Refresh TTL on resume
|
|
159
|
+
await refreshExpiry(uploadId);
|
|
160
|
+
// Get chunks from filesystem
|
|
161
|
+
const uploadedChunks = await getUploadedChunks(uploadId);
|
|
162
|
+
return c.json({
|
|
163
|
+
uploadId,
|
|
164
|
+
totalChunks: existingMeta.totalChunks,
|
|
165
|
+
chunkSize: existingMeta.chunkSize,
|
|
166
|
+
uploadedChunks,
|
|
167
|
+
completed: false as const,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// New upload
|
|
172
|
+
const chunkSize = body.chunkSize;
|
|
173
|
+
const totalChunks = Math.ceil(body.size / chunkSize);
|
|
174
|
+
|
|
175
|
+
const ownership: Ownership | null =
|
|
176
|
+
body.ownerUid != null && body.ownerGid != null && body.mode
|
|
177
|
+
? { uid: body.ownerUid, gid: body.ownerGid, mode: parseInt(body.mode, 8) }
|
|
178
|
+
: null;
|
|
179
|
+
|
|
180
|
+
const meta: UploadMeta = {
|
|
181
|
+
uploadId,
|
|
182
|
+
path: body.path,
|
|
183
|
+
filename: body.filename,
|
|
184
|
+
size: body.size,
|
|
185
|
+
checksum: body.checksum,
|
|
186
|
+
chunkSize,
|
|
187
|
+
totalChunks,
|
|
188
|
+
ownership,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
await mkdir(chunksDir(uploadId), { recursive: true });
|
|
192
|
+
await saveMeta(meta);
|
|
193
|
+
|
|
194
|
+
return c.json({
|
|
195
|
+
uploadId,
|
|
196
|
+
totalChunks,
|
|
197
|
+
chunkSize,
|
|
198
|
+
uploadedChunks: [],
|
|
199
|
+
completed: false as const,
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// POST /upload/chunk
|
|
205
|
+
app.post(
|
|
206
|
+
"/chunk",
|
|
207
|
+
describeRoute({
|
|
208
|
+
tags: ["Upload"],
|
|
209
|
+
summary: "Upload a chunk",
|
|
210
|
+
description: "Upload a single chunk. Auto-completes when all chunks received.",
|
|
211
|
+
...requiresAuth,
|
|
212
|
+
responses: {
|
|
213
|
+
200: jsonResponse(UploadChunkResponseSchema, "Chunk received"),
|
|
214
|
+
400: jsonResponse(ErrorSchema, "Bad request"),
|
|
215
|
+
404: jsonResponse(ErrorSchema, "Upload not found"),
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
v("header", UploadChunkHeadersSchema),
|
|
219
|
+
async (c) => {
|
|
220
|
+
const headers = c.req.valid("header");
|
|
221
|
+
const uploadId = headers["x-upload-id"];
|
|
222
|
+
const chunkIndex = headers["x-chunk-index"];
|
|
223
|
+
const chunkChecksum = headers["x-chunk-checksum"];
|
|
224
|
+
|
|
225
|
+
const meta = await loadMeta(uploadId);
|
|
226
|
+
if (!meta) return c.json({ error: "upload not found" }, 404);
|
|
227
|
+
|
|
228
|
+
if (chunkIndex >= meta.totalChunks) {
|
|
229
|
+
return c.json({ error: `chunk index ${chunkIndex} exceeds total ${meta.totalChunks}` }, 400);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const body = c.req.raw.body;
|
|
233
|
+
if (!body) return c.json({ error: "missing body" }, 400);
|
|
234
|
+
|
|
235
|
+
// Stream chunks to a temporary file to avoid memory accumulation
|
|
236
|
+
const tempChunkPath = chunkPath(uploadId, chunkIndex) + ".tmp";
|
|
237
|
+
let chunkSize = 0;
|
|
238
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
239
|
+
|
|
240
|
+
await mkdir(chunksDir(uploadId), { recursive: true });
|
|
241
|
+
const tempFile = Bun.file(tempChunkPath);
|
|
242
|
+
const writer = tempFile.writer();
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
for await (const chunk of body) {
|
|
246
|
+
chunkSize += chunk.length;
|
|
247
|
+
if (chunkSize > config.maxChunkBytes) {
|
|
248
|
+
writer.end();
|
|
249
|
+
await rm(tempChunkPath).catch(() => {});
|
|
250
|
+
return c.json({ error: `chunk size exceeds maximum (${config.maxChunkBytes / 1024 / 1024}MB)` }, 413);
|
|
251
|
+
}
|
|
252
|
+
hasher.update(chunk);
|
|
253
|
+
writer.write(chunk);
|
|
254
|
+
}
|
|
255
|
+
await writer.end();
|
|
256
|
+
} catch (e) {
|
|
257
|
+
writer.end();
|
|
258
|
+
await rm(tempChunkPath).catch(() => {});
|
|
259
|
+
throw e;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Verify checksum if provided
|
|
263
|
+
if (chunkChecksum) {
|
|
264
|
+
const computed = `sha256:${hasher.digest("hex")}`;
|
|
265
|
+
if (computed !== chunkChecksum) {
|
|
266
|
+
await rm(tempChunkPath).catch(() => {});
|
|
267
|
+
return c.json({ error: `chunk checksum mismatch: expected ${chunkChecksum}, got ${computed}` }, 400);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Move temp file to final location (atomic rename, no memory copy)
|
|
272
|
+
const finalChunkPath = chunkPath(uploadId, chunkIndex);
|
|
273
|
+
await rename(tempChunkPath, finalChunkPath);
|
|
274
|
+
|
|
275
|
+
// Get uploaded chunks from filesystem
|
|
276
|
+
const uploadedChunks = await getUploadedChunks(uploadId);
|
|
277
|
+
|
|
278
|
+
if (uploadedChunks.length === meta.totalChunks) {
|
|
279
|
+
const assembleError = await assembleFile(meta);
|
|
280
|
+
if (assembleError) return c.json({ error: assembleError }, 500);
|
|
281
|
+
|
|
282
|
+
const fullPath = join(meta.path, meta.filename);
|
|
283
|
+
const file = Bun.file(fullPath);
|
|
284
|
+
const s = await stat(fullPath);
|
|
285
|
+
|
|
286
|
+
return c.json({
|
|
287
|
+
completed: true as const,
|
|
288
|
+
file: {
|
|
289
|
+
name: meta.filename,
|
|
290
|
+
path: fullPath,
|
|
291
|
+
type: "file" as const,
|
|
292
|
+
size: s.size,
|
|
293
|
+
mtime: s.mtime.toISOString(),
|
|
294
|
+
isHidden: meta.filename.startsWith("."),
|
|
295
|
+
checksum: meta.checksum,
|
|
296
|
+
mimeType: file.type,
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return c.json({
|
|
302
|
+
chunkIndex,
|
|
303
|
+
uploadedChunks,
|
|
304
|
+
completed: false as const,
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// Cleanup orphaned chunk directories (Redis keys expired but files remain)
|
|
310
|
+
export const cleanupOrphanedChunks = async () => {
|
|
311
|
+
try {
|
|
312
|
+
const dirs = await readdir(config.uploadTempDir);
|
|
313
|
+
let cleaned = 0;
|
|
314
|
+
|
|
315
|
+
for (const dir of dirs) {
|
|
316
|
+
// Check if upload still exists in Redis
|
|
317
|
+
const exists = await redis.exists(metaKey(dir));
|
|
318
|
+
if (!exists) {
|
|
319
|
+
await rm(chunksDir(dir), { recursive: true }).catch(() => {});
|
|
320
|
+
cleaned++;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (cleaned > 0) {
|
|
325
|
+
console.log(`[Filegate] Cleaned up ${cleaned} orphaned chunk director${cleaned === 1 ? "y" : "ies"}`);
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
// Directory doesn't exist yet
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export default app;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { Scalar } from "@scalar/hono-api-reference";
|
|
3
|
+
import { generateSpecs } from "hono-openapi";
|
|
4
|
+
import { createMarkdownFromOpenApi } from "@scalar/openapi-to-markdown";
|
|
5
|
+
import { bearerAuth } from "hono/bearer-auth";
|
|
6
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
7
|
+
import { config } from "./config";
|
|
8
|
+
import { openApiMeta } from "./lib/openapi";
|
|
9
|
+
import filesRoutes from "./handlers/files";
|
|
10
|
+
import searchRoutes from "./handlers/search";
|
|
11
|
+
import uploadRoutes, { cleanupOrphanedChunks } from "./handlers/upload";
|
|
12
|
+
|
|
13
|
+
// Dev mode warning
|
|
14
|
+
if (config.isDev) {
|
|
15
|
+
console.log("╔════════════════════════════════════════════════════════════════╗");
|
|
16
|
+
console.log("║ WARNING: DEV MODE - UID/GID OVERRIDES ENABLED ║");
|
|
17
|
+
console.log("║ DO NOT USE IN PRODUCTION! ║");
|
|
18
|
+
console.log(`║ DEV_UID_OVERRIDE: ${String(config.devUid ?? "not set").padEnd(43)}║`);
|
|
19
|
+
console.log(`║ DEV_GID_OVERRIDE: ${String(config.devGid ?? "not set").padEnd(43)}║`);
|
|
20
|
+
console.log("╚════════════════════════════════════════════════════════════════╝");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log(`[Filegate] ALLOWED_BASE_PATHS: ${config.allowedPaths.join(", ")}`);
|
|
24
|
+
console.log(`[Filegate] MAX_UPLOAD_MB: ${config.maxUploadBytes / 1024 / 1024}`);
|
|
25
|
+
console.log(`[Filegate] REDIS_URL: ${process.env.REDIS_URL ?? "redis://localhost:6379 (default)"}`);
|
|
26
|
+
console.log(`[Filegate] PORT: ${config.port}`);
|
|
27
|
+
|
|
28
|
+
// Periodic disk cleanup for orphaned chunks (every 6h by default)
|
|
29
|
+
setInterval(cleanupOrphanedChunks, config.diskCleanupIntervalMs);
|
|
30
|
+
setTimeout(cleanupOrphanedChunks, 10_000); // Run 10s after startup
|
|
31
|
+
|
|
32
|
+
// Main app
|
|
33
|
+
const app = new Hono();
|
|
34
|
+
|
|
35
|
+
// Security headers
|
|
36
|
+
app.use(
|
|
37
|
+
"*",
|
|
38
|
+
secureHeaders({
|
|
39
|
+
xFrameOptions: "DENY",
|
|
40
|
+
xContentTypeOptions: "nosniff",
|
|
41
|
+
referrerPolicy: "strict-origin-when-cross-origin",
|
|
42
|
+
crossOriginOpenerPolicy: "same-origin",
|
|
43
|
+
crossOriginResourcePolicy: "same-origin",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Health check (public)
|
|
48
|
+
app.get("/health", (c) => c.text("OK"));
|
|
49
|
+
|
|
50
|
+
// Protected routes
|
|
51
|
+
const api = new Hono()
|
|
52
|
+
.use("/*", bearerAuth({ token: config.token }))
|
|
53
|
+
.route("/", filesRoutes)
|
|
54
|
+
.route("/", searchRoutes)
|
|
55
|
+
.route("/upload", uploadRoutes);
|
|
56
|
+
|
|
57
|
+
app.route("/files", api);
|
|
58
|
+
|
|
59
|
+
// Generate OpenAPI spec
|
|
60
|
+
const spec = await generateSpecs(app, openApiMeta);
|
|
61
|
+
const llmsTxt = await createMarkdownFromOpenApi(JSON.stringify(spec));
|
|
62
|
+
|
|
63
|
+
// Documentation endpoints (public)
|
|
64
|
+
app.get("/openapi.json", (c) => c.json(spec));
|
|
65
|
+
app.get("/docs", Scalar({ theme: "saturn", url: "/openapi.json" }));
|
|
66
|
+
app.get("/llms.txt", (c) => c.text(llmsTxt));
|
|
67
|
+
|
|
68
|
+
// 404 fallback
|
|
69
|
+
app.notFound((c) => c.json({ error: "not found" }, 404));
|
|
70
|
+
|
|
71
|
+
// Error handler
|
|
72
|
+
app.onError((err, c) => {
|
|
73
|
+
console.error("[Filegate] Error:", err);
|
|
74
|
+
return c.json({ error: "internal error" }, 500);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export default {
|
|
78
|
+
port: config.port,
|
|
79
|
+
fetch: app.fetch,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
console.log(`[Filegate] Listening on http://localhost:${config.port}`);
|
|
83
|
+
console.log(`[Filegate] Docs: http://localhost:${config.port}/docs`);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { resolver, type GenerateSpecOptions } from "hono-openapi";
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
|
+
import { config } from "../config";
|
|
4
|
+
|
|
5
|
+
/** JSON response helper for OpenAPI docs */
|
|
6
|
+
export const jsonResponse = <T extends ZodType>(schema: T, description: string) => ({
|
|
7
|
+
description,
|
|
8
|
+
content: { "application/json": { schema: resolver(schema) } },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/** Binary/stream response helper */
|
|
12
|
+
export const binaryResponse = (mimeType: string, description: string) => ({
|
|
13
|
+
description,
|
|
14
|
+
content: { [mimeType]: { schema: { type: "string" as const, format: "binary" } } },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** OpenAPI spec metadata */
|
|
18
|
+
export const openApiMeta: Partial<GenerateSpecOptions> = {
|
|
19
|
+
documentation: {
|
|
20
|
+
info: {
|
|
21
|
+
title: "File Proxy API",
|
|
22
|
+
version: "1.0.0",
|
|
23
|
+
description: "Secure file proxy with streaming uploads/downloads and resumable chunked uploads",
|
|
24
|
+
},
|
|
25
|
+
servers: [{ url: `http://localhost:${config.port}`, description: "File Proxy Server" }],
|
|
26
|
+
tags: [
|
|
27
|
+
{ name: "Health", description: "Health check endpoint" },
|
|
28
|
+
{ name: "Files", description: "File operations (info, download, upload, mkdir, move, copy, delete)" },
|
|
29
|
+
{ name: "Search", description: "File search with glob patterns" },
|
|
30
|
+
{ name: "Upload", description: "Resumable chunked uploads" },
|
|
31
|
+
],
|
|
32
|
+
components: {
|
|
33
|
+
securitySchemes: {
|
|
34
|
+
bearerAuth: {
|
|
35
|
+
type: "http",
|
|
36
|
+
scheme: "bearer",
|
|
37
|
+
description: "Bearer token authentication",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Security requirement for authenticated routes */
|
|
45
|
+
export const requiresAuth = {
|
|
46
|
+
security: [{ bearerAuth: [] as string[] }],
|
|
47
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { chown, chmod } from "node:fs/promises";
|
|
2
|
+
import { config } from "../config";
|
|
3
|
+
|
|
4
|
+
export type Ownership = {
|
|
5
|
+
uid: number;
|
|
6
|
+
gid: number;
|
|
7
|
+
mode: number; // octal, e.g. 0o600
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const parseOwnershipHeaders = (req: Request): Ownership | null => {
|
|
11
|
+
const uid = req.headers.get("X-Owner-UID");
|
|
12
|
+
const gid = req.headers.get("X-Owner-GID");
|
|
13
|
+
const mode = req.headers.get("X-File-Mode");
|
|
14
|
+
|
|
15
|
+
if (!uid || !gid || !mode) return null;
|
|
16
|
+
|
|
17
|
+
const parsedMode = parseInt(mode, 8);
|
|
18
|
+
if (isNaN(parsedMode)) return null;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
uid: parseInt(uid, 10),
|
|
22
|
+
gid: parseInt(gid, 10),
|
|
23
|
+
mode: parsedMode,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const parseOwnershipBody = (body: {
|
|
28
|
+
ownerUid?: number;
|
|
29
|
+
ownerGid?: number;
|
|
30
|
+
mode?: string;
|
|
31
|
+
}): Ownership | null => {
|
|
32
|
+
if (body.ownerUid == null || body.ownerGid == null || !body.mode) return null;
|
|
33
|
+
|
|
34
|
+
const mode = parseInt(body.mode, 8);
|
|
35
|
+
if (isNaN(mode)) return null;
|
|
36
|
+
|
|
37
|
+
return { uid: body.ownerUid, gid: body.ownerGid, mode };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const applyOwnership = async (
|
|
41
|
+
path: string,
|
|
42
|
+
ownership: Ownership | null
|
|
43
|
+
): Promise<string | null> => {
|
|
44
|
+
if (!ownership) return null;
|
|
45
|
+
|
|
46
|
+
const uid = config.devUid ?? ownership.uid;
|
|
47
|
+
const gid = config.devGid ?? ownership.gid;
|
|
48
|
+
|
|
49
|
+
if (config.isDev) {
|
|
50
|
+
console.log(
|
|
51
|
+
`[DEV] chown ${path}: ${ownership.uid}->${uid}, ${ownership.gid}->${gid}, mode=${ownership.mode.toString(8)}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await chown(path, uid, gid);
|
|
57
|
+
await chmod(path, ownership.mode);
|
|
58
|
+
return null;
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
if (e.code === "EPERM") return "permission denied (not root?)";
|
|
61
|
+
if (e.code === "EINVAL") return `invalid uid=${uid} or gid=${gid}`;
|
|
62
|
+
return `ownership failed: ${e.message}`;
|
|
63
|
+
}
|
|
64
|
+
};
|
package/src/lib/path.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { join, normalize, dirname, basename } from "node:path";
|
|
3
|
+
import { config } from "../config";
|
|
4
|
+
|
|
5
|
+
export type PathResult =
|
|
6
|
+
| { ok: true; realPath: string; basePath: string }
|
|
7
|
+
| { ok: false; error: string; status: 400 | 403 | 404 };
|
|
8
|
+
|
|
9
|
+
// Cache for resolved base paths (they don't change at runtime)
|
|
10
|
+
const realBaseCache = new Map<string, string>();
|
|
11
|
+
|
|
12
|
+
const getRealBase = async (basePath: string): Promise<string | null> => {
|
|
13
|
+
const cached = realBaseCache.get(basePath);
|
|
14
|
+
if (cached) return cached;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const realBase = await realpath(basePath);
|
|
18
|
+
realBaseCache.set(basePath, realBase);
|
|
19
|
+
return realBase;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validates path is within allowed base paths, resolves symlinks.
|
|
27
|
+
* @param allowBasePath - if true, allows operating on the base path itself
|
|
28
|
+
*/
|
|
29
|
+
export const validatePath = async (path: string, allowBasePath = false): Promise<PathResult> => {
|
|
30
|
+
const cleaned = normalize(path);
|
|
31
|
+
|
|
32
|
+
// Find matching base
|
|
33
|
+
let basePath: string | null = null;
|
|
34
|
+
for (const base of config.allowedPaths) {
|
|
35
|
+
const cleanBase = normalize(base);
|
|
36
|
+
if (cleaned === cleanBase) {
|
|
37
|
+
if (!allowBasePath) {
|
|
38
|
+
return { ok: false, error: "cannot operate on base path", status: 403 };
|
|
39
|
+
}
|
|
40
|
+
basePath = cleanBase;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
if (cleaned.startsWith(cleanBase + "/")) {
|
|
44
|
+
basePath = cleanBase;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!basePath) {
|
|
50
|
+
return { ok: false, error: "path not allowed", status: 403 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Resolve symlinks
|
|
54
|
+
let realPath: string;
|
|
55
|
+
try {
|
|
56
|
+
realPath = await realpath(cleaned);
|
|
57
|
+
} catch (e: any) {
|
|
58
|
+
if (e.code === "ENOENT") {
|
|
59
|
+
// Path doesn't exist yet - validate parent
|
|
60
|
+
try {
|
|
61
|
+
const parentReal = await realpath(dirname(cleaned));
|
|
62
|
+
realPath = join(parentReal, basename(cleaned));
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, error: "parent path not found", status: 404 };
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
return { ok: false, error: "path resolution failed", status: 400 };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Verify still within base after symlink resolution (cached)
|
|
72
|
+
const realBase = await getRealBase(basePath);
|
|
73
|
+
if (!realBase) {
|
|
74
|
+
return { ok: false, error: "base path invalid", status: 500 as any };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (realPath !== realBase && !realPath.startsWith(realBase + "/")) {
|
|
78
|
+
return { ok: false, error: "symlink escape not allowed", status: 403 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { ok: true, realPath, basePath: realBase };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type SameBaseResult =
|
|
85
|
+
| { ok: true; realPath: string; basePath: string; realTo: string }
|
|
86
|
+
| { ok: false; error: string; status: 400 | 403 | 404 };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validates both paths are in the same base (for move/copy).
|
|
90
|
+
*/
|
|
91
|
+
export const validateSameBase = async (from: string, to: string): Promise<SameBaseResult> => {
|
|
92
|
+
const fromResult = await validatePath(from);
|
|
93
|
+
if (!fromResult.ok) return fromResult;
|
|
94
|
+
|
|
95
|
+
const toResult = await validatePath(to);
|
|
96
|
+
if (!toResult.ok) return toResult;
|
|
97
|
+
|
|
98
|
+
if (fromResult.basePath !== toResult.basePath) {
|
|
99
|
+
return { ok: false, error: "cross-basepath not allowed", status: 400 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ...fromResult, realTo: toResult.realPath };
|
|
103
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const json = <T>(data: T, status = 200) =>
|
|
2
|
+
Response.json(data, { status });
|
|
3
|
+
|
|
4
|
+
export const error = (msg: string, status = 400) =>
|
|
5
|
+
Response.json({ error: msg }, { status });
|
|
6
|
+
|
|
7
|
+
export const notFound = (msg = "not found") => error(msg, 404);
|
|
8
|
+
export const forbidden = (msg = "forbidden") => error(msg, 403);
|
|
9
|
+
export const badRequest = (msg: string) => error(msg, 400);
|
|
10
|
+
export const serverError = (msg = "internal error") => error(msg, 500);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { validator } from "hono-openapi";
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
|
+
import type { ValidationTargets, Context } from "hono";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zod validator middleware with OpenAPI support.
|
|
7
|
+
* Returns 400 with error details on validation failure.
|
|
8
|
+
*/
|
|
9
|
+
export const v = <Target extends keyof ValidationTargets, T extends ZodType>(target: Target, schema: T) =>
|
|
10
|
+
validator(target, schema, (result, c: Context) => {
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
const issues = result.error as readonly { message: string; path?: readonly unknown[] }[];
|
|
13
|
+
const msg = issues
|
|
14
|
+
?.map((issue) => {
|
|
15
|
+
const path = issue.path?.map((p) => String(p)).join(".");
|
|
16
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
17
|
+
})
|
|
18
|
+
.join(", ");
|
|
19
|
+
return c.json({ error: msg || "validation failed" }, 400);
|
|
20
|
+
}
|
|
21
|
+
});
|