@valentinkolb/filegate 0.0.1 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,429 +1,414 @@
1
1
  # Filegate
2
2
 
3
- A secure, high-performance file proxy server built with Bun + Hono + Zod.
3
+ A secure file proxy server for building custom file management systems. Bring your own backend and frontend - Filegate handles the file operations.
4
4
 
5
- Features:
6
- - Streaming uploads/downloads
7
- - Directory download as ZIP
5
+ ```
6
+ Browser/App Your Backend Filegate Filesystem
7
+ | | | |
8
+ | upload request | | |
9
+ |------------------->| | |
10
+ | | proxy to filegate | |
11
+ | |-------------------->| |
12
+ | | | write file |
13
+ | | |------------------->|
14
+ | | | |
15
+ | |<--------------------|<-------------------|
16
+ |<-------------------| | |
17
+ ```
18
+
19
+ Filegate is designed to work behind your backend, not as a public-facing service. Your backend handles authentication and authorization, then proxies requests to Filegate. This gives you full control over access logic while Filegate handles the complexity of streaming, chunked uploads, permissions, and security.
20
+
21
+ ## Features
22
+
23
+ - Streaming uploads and downloads (no memory buffering)
8
24
  - Resumable chunked uploads with SHA-256 verification
25
+ - Directory downloads as TAR archives
26
+ - Unix file permissions (uid, gid, mode)
9
27
  - Glob-based file search
10
- - Type-safe client API with config objects
11
- - Browser-compatible utils for chunked uploads
12
- - OpenAPI documentation with Scalar UI
13
- - LLM-friendly markdown docs at `/llms.txt`
28
+ - Type-safe client with full TypeScript support
29
+ - OpenAPI documentation
14
30
 
15
31
  ## Quick Start
16
32
 
33
+ ### 1. Start Filegate with Docker
34
+
17
35
  ```bash
18
- export FILE_PROXY_TOKEN=your-secret-token
19
- export ALLOWED_BASE_PATHS=/export/homes,/export/groups
36
+ docker run -d \
37
+ --name filegate \
38
+ -p 4000:4000 \
39
+ -e FILE_PROXY_TOKEN=your-secret-token \
40
+ -e ALLOWED_BASE_PATHS=/data \
41
+ -v /path/to/your/files:/data \
42
+ ghcr.io/valentinkolb/filegate:latest
43
+ ```
20
44
 
21
- bun run src/index.ts
45
+ ### 2. Install the Client
46
+
47
+ ```bash
48
+ npm install @valentinkolb/filegate
22
49
  ```
23
50
 
24
- ## Documentation
51
+ ### 3. Configure Environment
25
52
 
26
- - **Scalar UI:** http://localhost:4000/docs
27
- - **OpenAPI JSON:** http://localhost:4000/openapi.json
28
- - **LLM Markdown:** http://localhost:4000/llms.txt
53
+ ```bash
54
+ export FILEGATE_URL=http://localhost:4000
55
+ export FILEGATE_TOKEN=your-secret-token
56
+ ```
29
57
 
30
- ## Configuration
58
+ ### 4. Upload a File
31
59
 
32
- | Variable | Required | Default | Description |
33
- |----------|----------|---------|-------------|
34
- | `FILE_PROXY_TOKEN` | Yes | - | Bearer token for authentication |
35
- | `ALLOWED_BASE_PATHS` | Yes | - | Comma-separated allowed paths |
36
- | `REDIS_URL` | No | localhost:6379 | Redis connection URL (for chunked uploads) |
37
- | `PORT` | No | 4000 | Server port |
60
+ ```typescript
61
+ import { filegate } from "@valentinkolb/filegate/client";
62
+
63
+ const result = await filegate.upload.single({
64
+ path: "/data/uploads",
65
+ filename: "document.pdf",
66
+ data: fileBuffer,
67
+ });
38
68
 
39
- **Size Limits:**
69
+ if (result.ok) {
70
+ console.log("Uploaded:", result.data.path);
71
+ }
72
+ ```
40
73
 
41
- | Variable | Default | Description |
42
- |----------|---------|-------------|
43
- | `MAX_UPLOAD_MB` | 500 | Maximum file size for uploads (simple + chunked) |
44
- | `MAX_DOWNLOAD_MB` | 5000 | Maximum file/directory size for downloads |
45
- | `MAX_CHUNK_SIZE_MB` | 50 | Maximum chunk size (server rejects larger chunks) |
74
+ ### 5. Download a File
46
75
 
47
- **Search:**
76
+ ```typescript
77
+ import { filegate } from "@valentinkolb/filegate/client";
48
78
 
49
- | Variable | Default | Description |
50
- |----------|---------|-------------|
51
- | `SEARCH_MAX_RESULTS` | 100 | Max files returned by search |
52
- | `SEARCH_MAX_RECURSIVE_WILDCARDS` | 10 | Max `**` wildcards allowed in glob patterns (prevents DoS) |
79
+ const result = await filegate.download({ path: "/data/uploads/document.pdf" });
53
80
 
54
- **Other:**
81
+ if (result.ok) {
82
+ const blob = await result.data.blob();
83
+ }
84
+ ```
55
85
 
56
- | Variable | Default | Description |
57
- |----------|---------|-------------|
58
- | `UPLOAD_EXPIRY_HOURS` | 24 | Chunked upload expiry (resets on each chunk) |
59
- | `DISK_CLEANUP_INTERVAL_HOURS` | 6 | Interval to clean orphaned chunk files |
60
- | `DEV_UID_OVERRIDE` | - | Override file ownership UID (dev mode only) |
61
- | `DEV_GID_OVERRIDE` | - | Override file ownership GID (dev mode only) |
86
+ ## Architecture
62
87
 
63
- ## API Endpoints
88
+ Filegate follows a proxy architecture where your backend mediates all file operations:
64
89
 
65
- All `/files/*` endpoints require `Authorization: Bearer <token>`.
90
+ ```
91
+ +------------------+ +------------------+ +------------------+
92
+ | | | | | |
93
+ | Your Client |<----->| Your Backend |<----->| Filegate |
94
+ | (Browser/App) | | (Auth, Logic) | | (File Ops) |
95
+ | | | | | |
96
+ +------------------+ +------------------+ +------------------+
97
+ |
98
+ v
99
+ +------------------+
100
+ | |
101
+ | Filesystem |
102
+ | |
103
+ +------------------+
104
+ ```
66
105
 
67
- | Method | Path | Description |
68
- |--------|------|-------------|
69
- | GET | `/health` | Health check (public) |
70
- | GET | `/docs` | Scalar API docs (public) |
71
- | GET | `/openapi.json` | OpenAPI spec (public) |
72
- | GET | `/llms.txt` | LLM-friendly docs (public) |
73
- | GET | `/files/info` | File/directory info |
74
- | GET | `/files/content` | Download file or directory (ZIP) |
75
- | PUT | `/files/content` | Upload file |
76
- | POST | `/files/mkdir` | Create directory |
77
- | DELETE | `/files/delete` | Delete file/directory |
78
- | POST | `/files/move` | Move (same basepath) |
79
- | POST | `/files/copy` | Copy (same basepath) |
80
- | GET | `/files/search` | Glob search |
81
- | POST | `/files/upload/start` | Start/resume chunked upload |
82
- | POST | `/files/upload/chunk` | Upload chunk |
106
+ **Your Client** handles the UI and user interactions. It communicates with your backend.
107
+
108
+ **Your Backend** handles authentication, authorization, and business logic. It decides who can access what and proxies requests to Filegate.
109
+
110
+ **Filegate** handles the actual file operations: reading, writing, streaming, chunked uploads, permissions. It only accepts requests with a valid token.
111
+
112
+ This separation means you have full control over access patterns while Filegate handles the complexity of file operations.
83
113
 
84
- ## Examples
114
+ ## Core Concepts
115
+
116
+ ### Base Paths
117
+
118
+ Filegate restricts all file operations to explicitly allowed directories called "base paths". This is a security boundary - files outside these paths cannot be accessed.
85
119
 
86
- ### Get Directory Info
87
120
  ```bash
88
- curl -H "Authorization: Bearer $TOKEN" \
89
- "http://localhost:4000/files/info?path=/export/homes/alice&showHidden=false"
121
+ ALLOWED_BASE_PATHS=/data/uploads,/data/exports
90
122
  ```
91
123
 
92
- ### Upload File
93
- ```bash
94
- curl -X PUT \
95
- -H "Authorization: Bearer $TOKEN" \
96
- -H "X-File-Path: /export/homes/alice" \
97
- -H "X-File-Name: report.pdf" \
98
- -H "X-Owner-UID: 1000" \
99
- -H "X-Owner-GID: 1000" \
100
- -H "X-File-Mode: 600" \
101
- --data-binary @report.pdf \
102
- "http://localhost:4000/files/content"
124
+ With this configuration:
125
+ - `/data/uploads/file.txt` - allowed
126
+ - `/data/exports/report.pdf` - allowed
127
+ - `/home/user/file.txt` - forbidden
128
+ - `/data/../etc/passwd` - forbidden (path traversal blocked)
129
+
130
+ Symlinks that point outside base paths are also blocked.
131
+
132
+ ### File Ownership
133
+
134
+ Filegate runs as root to set file ownership. When uploading files, you can specify Unix permissions:
135
+
136
+ ```typescript
137
+ await client.upload.single({
138
+ path: "/data/uploads",
139
+ filename: "file.txt",
140
+ data: buffer,
141
+ uid: 1000, // Owner user ID
142
+ gid: 1000, // Owner group ID
143
+ mode: "644", // Unix permissions (rw-r--r--)
144
+ });
103
145
  ```
104
146
 
105
- ### Search Files
106
- ```bash
107
- curl -H "Authorization: Bearer $TOKEN" \
108
- "http://localhost:4000/files/search?paths=/export/homes/alice,/export/groups/team&pattern=**/*.pdf"
147
+ This is essential when Filegate manages files for multiple users on a shared filesystem.
148
+
149
+ ### Chunked Uploads
150
+
151
+ For large files, use chunked uploads. They support:
152
+ - Resume after connection failure
153
+ - Progress tracking
154
+ - Per-chunk checksum verification
155
+ - Automatic assembly when complete
156
+
157
+ ```typescript
158
+ // Start or resume upload
159
+ const start = await client.upload.chunked.start({
160
+ path: "/data/uploads",
161
+ filename: "large-file.zip",
162
+ size: file.size,
163
+ checksum: "sha256:abc123...", // Checksum of entire file
164
+ chunkSize: 5 * 1024 * 1024, // 5MB chunks
165
+ });
166
+
167
+ // Upload each chunk
168
+ for (let i = 0; i < start.data.totalChunks; i++) {
169
+ if (start.data.uploadedChunks.includes(i)) continue; // Skip already uploaded
170
+
171
+ await client.upload.chunked.send({
172
+ uploadId: start.data.uploadId,
173
+ index: i,
174
+ data: chunkData,
175
+ });
176
+ }
109
177
  ```
110
178
 
111
- ### Chunked Upload
179
+ Uploads automatically expire after 24 hours of inactivity.
180
+
181
+ ## Configuration
182
+
183
+ All configuration is done through environment variables.
184
+
185
+ | Variable | Required | Default | Description |
186
+ |----------|----------|---------|-------------|
187
+ | `FILE_PROXY_TOKEN` | Yes | - | Bearer token for API authentication |
188
+ | `ALLOWED_BASE_PATHS` | Yes | - | Comma-separated list of allowed directories |
189
+ | `PORT` | No | 4000 | Server port |
190
+ | `MAX_UPLOAD_MB` | No | 500 | Maximum upload size in MB |
191
+ | `MAX_DOWNLOAD_MB` | No | 5000 | Maximum download size in MB |
192
+ | `MAX_CHUNK_SIZE_MB` | No | 50 | Maximum chunk size in MB |
193
+ | `SEARCH_MAX_RESULTS` | No | 100 | Maximum search results returned |
194
+ | `SEARCH_MAX_RECURSIVE_WILDCARDS` | No | 10 | Maximum `**` wildcards in glob patterns |
195
+ | `UPLOAD_EXPIRY_HOURS` | No | 24 | Hours until incomplete uploads expire |
196
+ | `UPLOAD_TEMP_DIR` | No | /tmp/filegate-uploads | Directory for temporary chunk storage |
197
+ | `DISK_CLEANUP_INTERVAL_HOURS` | No | 6 | Interval for cleaning orphaned chunks |
198
+
199
+ ### Development Mode
200
+
201
+ For local development without root permissions, you can override file ownership:
112
202
 
113
203
  ```bash
114
- # 1. Start upload
115
- curl -X POST \
116
- -H "Authorization: Bearer $TOKEN" \
117
- -H "Content-Type: application/json" \
118
- -d '{
119
- "path": "/export/homes/alice",
120
- "filename": "large.zip",
121
- "size": 104857600,
122
- "checksum": "sha256:abc123...",
123
- "chunkSize": 10485760
124
- }' \
125
- "http://localhost:4000/files/upload/start"
126
-
127
- # 2. Upload chunks
128
- curl -X POST \
129
- -H "Authorization: Bearer $TOKEN" \
130
- -H "X-Upload-Id: <uploadId>" \
131
- -H "X-Chunk-Index: 0" \
132
- -H "X-Chunk-Checksum: sha256:def456..." \
133
- --data-binary @chunk0.bin \
134
- "http://localhost:4000/files/upload/chunk"
135
-
136
- # Repeat for all chunks - auto-completes on last chunk
204
+ DEV_UID_OVERRIDE=1000
205
+ DEV_GID_OVERRIDE=1000
206
+ ```
207
+
208
+ This applies the specified uid/gid instead of the requested values. Do not use in production.
209
+
210
+ ## Docker Compose Example
211
+
212
+ ```yaml
213
+ services:
214
+ filegate:
215
+ image: ghcr.io/valentinkolb/filegate:latest
216
+ ports:
217
+ - "4000:4000"
218
+ environment:
219
+ FILE_PROXY_TOKEN: ${FILE_PROXY_TOKEN}
220
+ ALLOWED_BASE_PATHS: /data
221
+ volumes:
222
+ - ./data:/data
223
+ - filegate-chunks:/tmp/filegate-uploads
224
+
225
+ volumes:
226
+ filegate-chunks:
137
227
  ```
138
228
 
139
229
  ## Client API
140
230
 
141
- Type-safe client for server-side usage. All methods use config objects for better readability.
231
+ The client provides a type-safe interface for all Filegate operations.
142
232
 
143
233
  ### Installation
144
234
 
145
- ```typescript
146
- import { Filegate } from "@valentinkolb/filegate/client";
147
- import { chunks, formatBytes } from "@valentinkolb/filegate/utils";
235
+ ```bash
236
+ npm install @valentinkolb/filegate
148
237
  ```
149
238
 
150
- ### Default Instance (via env vars)
239
+ ### Default Instance
151
240
 
152
- Set `FILEGATE_URL` and `FILEGATE_TOKEN`, then import the pre-configured instance:
241
+ Set `FILEGATE_URL` and `FILEGATE_TOKEN` environment variables, then import the pre-configured client:
153
242
 
154
243
  ```typescript
155
244
  import { filegate } from "@valentinkolb/filegate/client";
156
245
 
157
- const info = await filegate.info({ path: "/export/homes/alice" });
246
+ await filegate.info({ path: "/data/uploads" });
158
247
  ```
159
248
 
160
249
  ### Custom Instance
161
250
 
251
+ For more control or multiple Filegate servers, create instances manually:
252
+
162
253
  ```typescript
163
254
  import { Filegate } from "@valentinkolb/filegate/client";
164
255
 
165
256
  const client = new Filegate({
166
257
  url: "http://localhost:4000",
167
- token: "your-token",
258
+ token: "your-secret-token",
168
259
  });
169
260
  ```
170
261
 
171
- ### Client Methods
262
+ ### Methods
172
263
 
173
264
  ```typescript
174
- // Get file/directory info
175
- const info = await client.info({ path: "/export/homes/alice", showHidden: true });
176
-
177
- // Download file (returns Response with streaming body)
178
- const file = await client.download({ path: "/export/homes/alice/report.pdf" });
179
- if (file.ok) {
180
- const text = await file.data.text();
181
- const buffer = await file.data.arrayBuffer();
182
- // Or stream directly: file.data.body
183
- }
265
+ // Get file or directory info
266
+ await client.info({ path: "/data/file.txt", showHidden: false });
184
267
 
185
- // Download directory as ZIP
186
- const zip = await client.download({ path: "/export/homes/alice/documents" });
268
+ // Download file (returns streaming Response)
269
+ await client.download({ path: "/data/file.txt" });
187
270
 
188
- // Upload file (simple)
271
+ // Download directory as TAR archive
272
+ await client.download({ path: "/data/folder" });
273
+
274
+ // Simple upload
189
275
  await client.upload.single({
190
- path: "/export/homes/alice",
191
- filename: "report.pdf",
192
- data: fileData,
276
+ path: "/data/uploads",
277
+ filename: "file.txt",
278
+ data: buffer,
193
279
  uid: 1000,
194
280
  gid: 1000,
195
281
  mode: "644",
196
282
  });
197
283
 
198
- // Upload file (chunked) - for large files
199
- const startResult = await client.upload.chunked.start({
200
- path: "/export/homes/alice",
201
- filename: "large.zip",
202
- size: file.size,
203
- checksum: "sha256:...",
204
- chunkSize: 5 * 1024 * 1024,
205
- uid: 1000,
206
- gid: 1000,
207
- });
208
-
209
- // Send chunks
210
- await client.upload.chunked.send({
211
- uploadId: startResult.data.uploadId,
212
- index: 0,
213
- data: chunkData,
214
- checksum: "sha256:...",
215
- });
284
+ // Chunked upload
285
+ await client.upload.chunked.start({ ... });
286
+ await client.upload.chunked.send({ ... });
216
287
 
217
288
  // Create directory
218
- await client.mkdir({ path: "/export/homes/alice/new-folder", mode: "750" });
289
+ await client.mkdir({ path: "/data/new-folder", mode: "755" });
290
+
291
+ // Delete file or directory
292
+ await client.delete({ path: "/data/old-file.txt" });
219
293
 
220
- // Move/copy (within same basepath)
221
- await client.move({ from: "/export/homes/alice/old.txt", to: "/export/homes/alice/new.txt" });
222
- await client.copy({ from: "/export/homes/alice/file.txt", to: "/export/homes/alice/backup.txt" });
294
+ // Move (within same base path)
295
+ await client.move({ from: "/data/old.txt", to: "/data/new.txt" });
223
296
 
224
- // Delete
225
- await client.delete({ path: "/export/homes/alice/trash" });
297
+ // Copy (within same base path)
298
+ await client.copy({ from: "/data/file.txt", to: "/data/backup.txt" });
226
299
 
227
300
  // Search with glob patterns
228
- const results = await client.glob({
229
- paths: ["/export/homes/alice", "/export/groups/team"],
301
+ await client.glob({
302
+ paths: ["/data/uploads"],
230
303
  pattern: "**/*.pdf",
231
- showHidden: false,
232
- limit: 100,
304
+ limit: 50,
233
305
  });
234
306
  ```
235
307
 
236
- ## Browser Utils
308
+ ### Response Format
237
309
 
238
- Browser-compatible utilities for chunked uploads. Framework-agnostic with reactive state management.
239
-
240
- ### Basic Usage
310
+ All methods return a discriminated union:
241
311
 
242
312
  ```typescript
243
- import { chunks, formatBytes } from "@valentinkolb/filegate/utils";
313
+ type Response<T> =
314
+ | { ok: true; data: T }
315
+ | { ok: false; error: string; status: number };
244
316
 
245
- // Prepare upload (calculates checksum and chunk info)
246
- const upload = await chunks.prepare({ file, chunkSize: 5 * 1024 * 1024 });
317
+ const result = await client.info({ path: "/data/file.txt" });
247
318
 
248
- // Access properties
249
- upload.file // Original File/Blob
250
- upload.fileSize // Total size in bytes
251
- upload.chunkSize // Chunk size in bytes
252
- upload.totalChunks // Number of chunks
253
- upload.checksum // "sha256:..." of entire file
254
-
255
- // Format bytes for display
256
- formatBytes({ bytes: upload.fileSize }); // "52.43 MB"
319
+ if (result.ok) {
320
+ console.log(result.data.size);
321
+ } else {
322
+ console.error(result.error); // "not found", "path not allowed", etc.
323
+ }
257
324
  ```
258
325
 
259
- ### State Management
326
+ ## Browser Utilities
260
327
 
261
- ```typescript
262
- // Subscribe to state changes (framework-agnostic)
263
- const unsubscribe = upload.subscribe((state) => {
264
- console.log(`${state.percent}% - ${state.status}`);
265
- // state: { uploaded: number, total: number, percent: number, status: "pending" | "uploading" | "completed" | "error" }
266
- });
267
-
268
- // Mark chunk as completed
269
- upload.complete({ index: 0 });
270
-
271
- // Reset state
272
- upload.reset();
273
-
274
- // Unsubscribe when done
275
- unsubscribe();
276
- ```
277
-
278
- ### Chunk Access
328
+ For browser-based chunked uploads, use the utils package:
279
329
 
280
330
  ```typescript
281
- // Get specific chunk (sync, returns Blob)
282
- const chunk = upload.get({ index: 0 });
331
+ import { chunks, formatBytes } from "@valentinkolb/filegate/utils";
283
332
 
284
- // Calculate chunk checksum
285
- const hash = await upload.hash({ data: chunk });
333
+ // Prepare a file for chunked upload
334
+ const upload = await chunks.prepare({
335
+ file: fileInput.files[0],
336
+ chunkSize: 5 * 1024 * 1024,
337
+ });
286
338
 
287
- // Iterate over all chunks
288
- for await (const { index, data, total } of upload) {
289
- console.log(`Chunk ${index + 1}/${total}`);
290
- }
291
- ```
339
+ console.log(upload.checksum); // "sha256:..."
340
+ console.log(upload.totalChunks); // Number of chunks
341
+ console.log(formatBytes({ bytes: upload.fileSize })); // "52.4 MB"
292
342
 
293
- ### Upload Helpers
343
+ // Subscribe to progress updates
344
+ upload.subscribe((state) => {
345
+ console.log(`${state.percent}% - ${state.status}`);
346
+ });
294
347
 
295
- ```typescript
296
- // Send single chunk with retry
297
- await upload.send({
298
- index: 0,
348
+ // Upload all chunks with retry and concurrency
349
+ await upload.sendAll({
350
+ skip: alreadyUploadedChunks,
299
351
  retries: 3,
352
+ concurrency: 3,
300
353
  fn: async ({ index, data }) => {
301
354
  await fetch("/api/upload/chunk", {
302
355
  method: "POST",
303
- headers: { "X-Chunk-Index": String(index) },
356
+ headers: {
357
+ "X-Upload-Id": uploadId,
358
+ "X-Chunk-Index": String(index),
359
+ },
304
360
  body: data,
305
361
  });
306
362
  },
307
363
  });
308
-
309
- // Send all chunks (with skip for resume, concurrency, retries)
310
- await upload.sendAll({
311
- skip: [0, 1], // Already uploaded chunks (from resume)
312
- retries: 3,
313
- concurrency: 3, // Parallel uploads
314
- fn: async ({ index, data }) => {
315
- await fetch("/api/upload/chunk", { ... });
316
- },
317
- });
318
364
  ```
319
365
 
320
- ### Complete Example (Browser)
321
-
322
- ```typescript
323
- import { chunks } from "@valentinkolb/filegate/utils";
366
+ ## API Endpoints
324
367
 
325
- async function uploadFile(file: File, targetPath: string, onProgress?: (state) => void) {
326
- const upload = await chunks.prepare({ file, chunkSize: 5 * 1024 * 1024 });
327
-
328
- if (onProgress) upload.subscribe(onProgress);
329
-
330
- // 1. Start/Resume upload
331
- const { uploadId, uploadedChunks, completed } = await fetch("/api/upload/start", {
332
- method: "POST",
333
- headers: { "Content-Type": "application/json" },
334
- body: JSON.stringify({
335
- path: targetPath,
336
- filename: file.name,
337
- size: upload.fileSize,
338
- checksum: upload.checksum,
339
- chunkSize: upload.chunkSize,
340
- }),
341
- }).then(r => r.json());
342
-
343
- if (completed) return; // Already done
344
-
345
- // 2. Upload all chunks (skipping already uploaded)
346
- await upload.sendAll({
347
- skip: uploadedChunks,
348
- retries: 3,
349
- fn: async ({ index, data }) => {
350
- await fetch("/api/upload/chunk", {
351
- method: "POST",
352
- headers: {
353
- "X-Upload-Id": uploadId,
354
- "X-Chunk-Index": String(index),
355
- "X-Chunk-Checksum": await upload.hash({ data }),
356
- },
357
- body: data,
358
- });
359
- },
360
- });
361
- }
368
+ All `/files/*` endpoints require `Authorization: Bearer <token>`.
362
369
 
363
- // Usage with React
364
- function UploadButton() {
365
- const [progress, setProgress] = useState({ percent: 0, status: "pending" });
366
-
367
- const handleUpload = async (file: File) => {
368
- await uploadFile(file, "/documents", setProgress);
369
- };
370
-
371
- return <div>{progress.percent}% - {progress.status}</div>;
372
- }
373
- ```
370
+ | Method | Path | Description |
371
+ |--------|------|-------------|
372
+ | GET | `/health` | Health check |
373
+ | GET | `/docs` | OpenAPI documentation (Scalar UI) |
374
+ | GET | `/openapi.json` | OpenAPI specification |
375
+ | GET | `/llms.txt` | LLM-friendly markdown documentation |
376
+ | GET | `/files/info` | Get file or directory info |
377
+ | GET | `/files/content` | Download file or directory (TAR) |
378
+ | PUT | `/files/content` | Upload file |
379
+ | POST | `/files/mkdir` | Create directory |
380
+ | DELETE | `/files/delete` | Delete file or directory |
381
+ | POST | `/files/move` | Move file or directory |
382
+ | POST | `/files/copy` | Copy file or directory |
383
+ | GET | `/files/search` | Search with glob pattern |
384
+ | POST | `/files/upload/start` | Start or resume chunked upload |
385
+ | POST | `/files/upload/chunk` | Upload a chunk |
374
386
 
375
- ### Streaming Proxy (Server-Side)
387
+ ## Security
376
388
 
377
- The download response streams directly - perfect for proxying without buffering:
389
+ Filegate implements multiple security layers:
378
390
 
379
- ```typescript
380
- // In your proxy server (e.g., with Hono, Express, etc.)
381
- app.get("/api/files/download", async (c) => {
382
- const path = c.req.query("path");
383
-
384
- // Get streaming response from Filegate
385
- const response = await client.download({ path });
386
-
387
- if (!response.ok) {
388
- return c.json({ error: response.error }, response.status);
389
- }
390
-
391
- // Stream directly to client - no buffering!
392
- return new Response(response.data.body, {
393
- headers: {
394
- "Content-Type": response.data.headers.get("Content-Type") || "application/octet-stream",
395
- "Content-Disposition": response.data.headers.get("Content-Disposition") || "",
396
- },
397
- });
398
- });
399
- ```
391
+ - **Path validation**: All paths are validated against allowed base paths
392
+ - **Symlink protection**: Symlinks pointing outside base paths are blocked
393
+ - **Path traversal prevention**: Sequences like `../` are normalized and checked
394
+ - **Size limits**: Configurable limits for uploads, downloads, and chunks
395
+ - **Search limits**: Glob pattern complexity is limited to prevent DoS
396
+ - **Secure headers**: X-Frame-Options, X-Content-Type-Options, etc.
400
397
 
401
- ## Security
398
+ ## Development
402
399
 
403
- - Path validation with symlink protection
404
- - Base path escape prevention
405
- - Same-basepath enforcement for move/copy
406
- - SHA-256 checksum verification
407
- - Configurable upload/download size limits
408
- - Glob pattern limits (max length 500 chars, configurable recursive wildcard limit)
409
- - Security headers (X-Frame-Options, X-Content-Type-Options, etc.)
400
+ ```bash
401
+ # Install dependencies
402
+ bun install
410
403
 
411
- ## Testing
404
+ # Run server
405
+ FILE_PROXY_TOKEN=dev ALLOWED_BASE_PATHS=/tmp bun run src/index.ts
412
406
 
413
- ```bash
414
- # Run unit tests
407
+ # Run tests
415
408
  bun run test:unit
416
-
417
- # Run integration tests (requires Docker)
418
409
  bun run test:integration:run
419
-
420
- # Run all tests
421
- bun run test:all
422
410
  ```
423
411
 
424
- ## Tech Stack
412
+ ## License
425
413
 
426
- - **Runtime:** Bun
427
- - **Framework:** Hono
428
- - **Validation:** Zod
429
- - **Docs:** hono-openapi + Scalar
414
+ MIT
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@valentinkolb/filegate",
3
- "version": "0.0.1",
3
+ "version": "0.0.5",
4
4
  "description": "Secure, high-performance file proxy server with streaming uploads, chunked uploads, and TAR downloads",
5
5
  "module": "index.ts",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/valentinkolb/filegate.git"
10
+ "url": "https://github.com/ValentinKolb/filegate.git"
11
11
  },
12
12
  "keywords": [
13
13
  "file-server",
@@ -1,7 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { describeRoute } from "hono-openapi";
3
- import { redis } from "bun";
4
- import { mkdir, readdir, rm, stat, rename } from "node:fs/promises";
3
+ import { mkdir, readdir, rm, stat, rename, readFile, writeFile } from "node:fs/promises";
5
4
  import { join, dirname } from "node:path";
6
5
  import { validatePath } from "../lib/path";
7
6
  import { applyOwnership, type Ownership } from "../lib/ownership";
@@ -25,12 +24,10 @@ const computeUploadId = (path: string, filename: string, checksum: string): stri
25
24
  return hasher.digest("hex").slice(0, 16);
26
25
  };
27
26
 
28
- // Single Redis key per upload
29
- const metaKey = (id: string) => `filegate:upload:${id}`;
30
-
31
27
  // Chunk storage paths
32
28
  const chunksDir = (id: string) => join(config.uploadTempDir, id);
33
29
  const chunkPath = (id: string, idx: number) => join(chunksDir(id), String(idx));
30
+ const metaPath = (id: string) => join(chunksDir(id), "meta.json");
34
31
 
35
32
  type UploadMeta = {
36
33
  uploadId: string;
@@ -41,26 +38,35 @@ type UploadMeta = {
41
38
  chunkSize: number;
42
39
  totalChunks: number;
43
40
  ownership: Ownership | null;
41
+ createdAt: number; // Unix timestamp for expiry check
44
42
  };
45
43
 
46
44
  const saveMeta = async (meta: UploadMeta): Promise<void> => {
47
- await redis.set(metaKey(meta.uploadId), JSON.stringify(meta), "EX", config.uploadExpirySecs);
45
+ await mkdir(chunksDir(meta.uploadId), { recursive: true });
46
+ await writeFile(metaPath(meta.uploadId), JSON.stringify(meta));
48
47
  };
49
48
 
50
49
  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;
50
+ try {
51
+ const data = await readFile(metaPath(id), "utf-8");
52
+ return JSON.parse(data) as UploadMeta;
53
+ } catch {
54
+ return null;
55
+ }
53
56
  };
54
57
 
55
- const refreshExpiry = async (id: string): Promise<void> => {
56
- await redis.expire(metaKey(id), config.uploadExpirySecs);
58
+ const refreshExpiry = async (id: string, meta: UploadMeta): Promise<void> => {
59
+ // Update createdAt to extend expiry
60
+ meta.createdAt = Date.now();
61
+ await saveMeta(meta);
57
62
  };
58
63
 
59
- // Get uploaded chunks from filesystem instead of Redis
64
+ // Get uploaded chunks from filesystem
60
65
  const getUploadedChunks = async (id: string): Promise<number[]> => {
61
66
  try {
62
67
  const files = await readdir(chunksDir(id));
63
68
  return files
69
+ .filter((f) => f !== "meta.json")
64
70
  .map((f) => parseInt(f, 10))
65
71
  .filter((n) => !isNaN(n))
66
72
  .sort((a, b) => a - b);
@@ -70,7 +76,7 @@ const getUploadedChunks = async (id: string): Promise<number[]> => {
70
76
  };
71
77
 
72
78
  const cleanupUpload = async (id: string): Promise<void> => {
73
- await Promise.all([redis.del(metaKey(id)), rm(chunksDir(id), { recursive: true }).catch(() => {})]);
79
+ await rm(chunksDir(id), { recursive: true }).catch(() => {});
74
80
  };
75
81
 
76
82
  const assembleFile = async (meta: UploadMeta): Promise<string | null> => {
@@ -155,8 +161,8 @@ app.post(
155
161
  // Check for existing upload (resume)
156
162
  const existingMeta = await loadMeta(uploadId);
157
163
  if (existingMeta) {
158
- // Refresh TTL on resume
159
- await refreshExpiry(uploadId);
164
+ // Refresh expiry on resume
165
+ await refreshExpiry(uploadId, existingMeta);
160
166
  // Get chunks from filesystem
161
167
  const uploadedChunks = await getUploadedChunks(uploadId);
162
168
  return c.json({
@@ -186,9 +192,9 @@ app.post(
186
192
  chunkSize,
187
193
  totalChunks,
188
194
  ownership,
195
+ createdAt: Date.now(),
189
196
  };
190
197
 
191
- await mkdir(chunksDir(uploadId), { recursive: true });
192
198
  await saveMeta(meta);
193
199
 
194
200
  return c.json({
@@ -306,23 +312,26 @@ app.post(
306
312
  },
307
313
  );
308
314
 
309
- // Cleanup orphaned chunk directories (Redis keys expired but files remain)
315
+ // Cleanup expired upload directories
310
316
  export const cleanupOrphanedChunks = async () => {
311
317
  try {
312
318
  const dirs = await readdir(config.uploadTempDir);
313
319
  let cleaned = 0;
320
+ const now = Date.now();
321
+ const expiryMs = config.uploadExpirySecs * 1000;
314
322
 
315
323
  for (const dir of dirs) {
316
- // Check if upload still exists in Redis
317
- const exists = await redis.exists(metaKey(dir));
318
- if (!exists) {
324
+ const meta = await loadMeta(dir);
325
+
326
+ // Remove if no meta or expired
327
+ if (!meta || now - meta.createdAt > expiryMs) {
319
328
  await rm(chunksDir(dir), { recursive: true }).catch(() => {});
320
329
  cleaned++;
321
330
  }
322
331
  }
323
332
 
324
333
  if (cleaned > 0) {
325
- console.log(`[Filegate] Cleaned up ${cleaned} orphaned chunk director${cleaned === 1 ? "y" : "ies"}`);
334
+ console.log(`[Filegate] Cleaned up ${cleaned} expired upload${cleaned === 1 ? "" : "s"}`);
326
335
  }
327
336
  } catch {
328
337
  // Directory doesn't exist yet
package/src/index.ts CHANGED
@@ -22,7 +22,6 @@ if (config.isDev) {
22
22
 
23
23
  console.log(`[Filegate] ALLOWED_BASE_PATHS: ${config.allowedPaths.join(", ")}`);
24
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
25
  console.log(`[Filegate] PORT: ${config.port}`);
27
26
 
28
27
  // Periodic disk cleanup for orphaned chunks (every 6h by default)