@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 +295 -310
- package/package.json +2 -2
- package/src/handlers/upload.ts +29 -20
- package/src/index.ts +0 -1
package/README.md
CHANGED
|
@@ -1,429 +1,414 @@
|
|
|
1
1
|
# Filegate
|
|
2
2
|
|
|
3
|
-
A secure
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
11
|
-
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
45
|
+
### 2. Install the Client
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install @valentinkolb/filegate
|
|
22
49
|
```
|
|
23
50
|
|
|
24
|
-
|
|
51
|
+
### 3. Configure Environment
|
|
25
52
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
-
|
|
53
|
+
```bash
|
|
54
|
+
export FILEGATE_URL=http://localhost:4000
|
|
55
|
+
export FILEGATE_TOKEN=your-secret-token
|
|
56
|
+
```
|
|
29
57
|
|
|
30
|
-
|
|
58
|
+
### 4. Upload a File
|
|
31
59
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
69
|
+
if (result.ok) {
|
|
70
|
+
console.log("Uploaded:", result.data.path);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
40
73
|
|
|
41
|
-
|
|
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
|
-
|
|
76
|
+
```typescript
|
|
77
|
+
import { filegate } from "@valentinkolb/filegate/client";
|
|
48
78
|
|
|
49
|
-
|
|
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
|
-
|
|
81
|
+
if (result.ok) {
|
|
82
|
+
const blob = await result.data.blob();
|
|
83
|
+
}
|
|
84
|
+
```
|
|
55
85
|
|
|
56
|
-
|
|
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
|
-
|
|
88
|
+
Filegate follows a proxy architecture where your backend mediates all file operations:
|
|
64
89
|
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
231
|
+
The client provides a type-safe interface for all Filegate operations.
|
|
142
232
|
|
|
143
233
|
### Installation
|
|
144
234
|
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
import { chunks, formatBytes } from "@valentinkolb/filegate/utils";
|
|
235
|
+
```bash
|
|
236
|
+
npm install @valentinkolb/filegate
|
|
148
237
|
```
|
|
149
238
|
|
|
150
|
-
### Default Instance
|
|
239
|
+
### Default Instance
|
|
151
240
|
|
|
152
|
-
Set `FILEGATE_URL` and `FILEGATE_TOKEN
|
|
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
|
-
|
|
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
|
-
###
|
|
262
|
+
### Methods
|
|
172
263
|
|
|
173
264
|
```typescript
|
|
174
|
-
// Get file
|
|
175
|
-
|
|
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
|
|
186
|
-
|
|
268
|
+
// Download file (returns streaming Response)
|
|
269
|
+
await client.download({ path: "/data/file.txt" });
|
|
187
270
|
|
|
188
|
-
//
|
|
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: "/
|
|
191
|
-
filename: "
|
|
192
|
-
data:
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
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: "/
|
|
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
|
|
221
|
-
await client.move({ from: "/
|
|
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
|
-
//
|
|
225
|
-
await client.
|
|
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
|
-
|
|
229
|
-
paths: ["/
|
|
301
|
+
await client.glob({
|
|
302
|
+
paths: ["/data/uploads"],
|
|
230
303
|
pattern: "**/*.pdf",
|
|
231
|
-
|
|
232
|
-
limit: 100,
|
|
304
|
+
limit: 50,
|
|
233
305
|
});
|
|
234
306
|
```
|
|
235
307
|
|
|
236
|
-
|
|
308
|
+
### Response Format
|
|
237
309
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
### Basic Usage
|
|
310
|
+
All methods return a discriminated union:
|
|
241
311
|
|
|
242
312
|
```typescript
|
|
243
|
-
|
|
313
|
+
type Response<T> =
|
|
314
|
+
| { ok: true; data: T }
|
|
315
|
+
| { ok: false; error: string; status: number };
|
|
244
316
|
|
|
245
|
-
|
|
246
|
-
const upload = await chunks.prepare({ file, chunkSize: 5 * 1024 * 1024 });
|
|
317
|
+
const result = await client.info({ path: "/data/file.txt" });
|
|
247
318
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
326
|
+
## Browser Utilities
|
|
260
327
|
|
|
261
|
-
|
|
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
|
-
|
|
282
|
-
const chunk = upload.get({ index: 0 });
|
|
331
|
+
import { chunks, formatBytes } from "@valentinkolb/filegate/utils";
|
|
283
332
|
|
|
284
|
-
//
|
|
285
|
-
const
|
|
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
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
343
|
+
// Subscribe to progress updates
|
|
344
|
+
upload.subscribe((state) => {
|
|
345
|
+
console.log(`${state.percent}% - ${state.status}`);
|
|
346
|
+
});
|
|
294
347
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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: {
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
```typescript
|
|
323
|
-
import { chunks } from "@valentinkolb/filegate/utils";
|
|
366
|
+
## API Endpoints
|
|
324
367
|
|
|
325
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
387
|
+
## Security
|
|
376
388
|
|
|
377
|
-
|
|
389
|
+
Filegate implements multiple security layers:
|
|
378
390
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
##
|
|
398
|
+
## Development
|
|
402
399
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
404
|
+
# Run server
|
|
405
|
+
FILE_PROXY_TOKEN=dev ALLOWED_BASE_PATHS=/tmp bun run src/index.ts
|
|
412
406
|
|
|
413
|
-
|
|
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
|
-
##
|
|
412
|
+
## License
|
|
425
413
|
|
|
426
|
-
|
|
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.
|
|
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/
|
|
10
|
+
"url": "https://github.com/ValentinKolb/filegate.git"
|
|
11
11
|
},
|
|
12
12
|
"keywords": [
|
|
13
13
|
"file-server",
|
package/src/handlers/upload.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { describeRoute } from "hono-openapi";
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
if
|
|
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}
|
|
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)
|