@spfn/core 0.2.0-beta.5 → 0.2.0-beta.8
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 +260 -1175
- package/dist/{boss-BO8ty33K.d.ts → boss-DI1r4kTS.d.ts} +24 -7
- package/dist/codegen/index.d.ts +47 -2
- package/dist/codegen/index.js +143 -5
- package/dist/codegen/index.js.map +1 -1
- package/dist/db/index.d.ts +13 -0
- package/dist/db/index.js +40 -6
- package/dist/db/index.js.map +1 -1
- package/dist/job/index.d.ts +23 -8
- package/dist/job/index.js +43 -3
- package/dist/job/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +35 -3
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +61 -14
- package/dist/nextjs/server.js +98 -32
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +136 -2
- package/dist/route/index.js +209 -11
- package/dist/route/index.js.map +1 -1
- package/dist/server/index.d.ts +72 -1
- package/dist/server/index.js +41 -0
- package/dist/server/index.js.map +1 -1
- package/dist/{types-D_N_U-Py.d.ts → types-BOPTApC2.d.ts} +15 -0
- package/docs/cache.md +133 -0
- package/docs/codegen.md +74 -0
- package/docs/database.md +346 -0
- package/docs/entity.md +539 -0
- package/docs/env.md +477 -0
- package/docs/errors.md +319 -0
- package/docs/event.md +116 -0
- package/docs/file-upload.md +717 -0
- package/docs/job.md +131 -0
- package/docs/logger.md +108 -0
- package/docs/middleware.md +337 -0
- package/docs/nextjs.md +241 -0
- package/docs/repository.md +496 -0
- package/docs/route.md +497 -0
- package/docs/server.md +307 -0
- package/package.json +1 -1
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
# File Upload
|
|
2
|
+
|
|
3
|
+
Complete guide for handling file uploads in SPFN applications.
|
|
4
|
+
|
|
5
|
+
## Basic Usage
|
|
6
|
+
|
|
7
|
+
### Single File Upload
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { route, FileSchema } from '@spfn/core/route';
|
|
11
|
+
import { Type } from '@sinclair/typebox';
|
|
12
|
+
|
|
13
|
+
export const uploadAvatar = route.post('/users/:id/avatar')
|
|
14
|
+
.input({
|
|
15
|
+
params: Type.Object({ id: Type.String() }),
|
|
16
|
+
formData: Type.Object({
|
|
17
|
+
file: FileSchema(),
|
|
18
|
+
description: Type.Optional(Type.String())
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
.handler(async (c) =>
|
|
22
|
+
{
|
|
23
|
+
const { params, formData } = await c.data();
|
|
24
|
+
const file = formData.file as File;
|
|
25
|
+
|
|
26
|
+
// File properties
|
|
27
|
+
console.log(file.name); // original filename
|
|
28
|
+
console.log(file.size); // size in bytes
|
|
29
|
+
console.log(file.type); // MIME type
|
|
30
|
+
|
|
31
|
+
// Read file content
|
|
32
|
+
const buffer = await file.arrayBuffer();
|
|
33
|
+
const text = await file.text(); // for text files
|
|
34
|
+
|
|
35
|
+
return c.created({ filename: file.name, size: file.size });
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Multiple Files
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { route, FileArraySchema } from '@spfn/core/route';
|
|
43
|
+
|
|
44
|
+
export const uploadDocuments = route.post('/documents')
|
|
45
|
+
.input({
|
|
46
|
+
formData: Type.Object({
|
|
47
|
+
files: FileArraySchema(),
|
|
48
|
+
category: Type.String()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
.handler(async (c) =>
|
|
52
|
+
{
|
|
53
|
+
const { formData } = await c.data();
|
|
54
|
+
const files = formData.files as File[];
|
|
55
|
+
|
|
56
|
+
const results = await Promise.all(
|
|
57
|
+
files.map(async (file) =>
|
|
58
|
+
{
|
|
59
|
+
const buffer = await file.arrayBuffer();
|
|
60
|
+
// Process each file...
|
|
61
|
+
return { name: file.name, size: file.size };
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return { uploaded: results.length, files: results };
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Mixed Fields
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
export const createPost = route.post('/posts')
|
|
73
|
+
.input({
|
|
74
|
+
formData: Type.Object({
|
|
75
|
+
title: Type.String(),
|
|
76
|
+
content: Type.String(),
|
|
77
|
+
image: OptionalFileSchema(),
|
|
78
|
+
tags: Type.Optional(Type.String()) // JSON string
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
.handler(async (c) =>
|
|
82
|
+
{
|
|
83
|
+
const { formData } = await c.data();
|
|
84
|
+
const image = formData.image as File | undefined;
|
|
85
|
+
|
|
86
|
+
const post = await postRepo.create({
|
|
87
|
+
title: formData.title,
|
|
88
|
+
content: formData.content,
|
|
89
|
+
tags: formData.tags ? JSON.parse(formData.tags) : [],
|
|
90
|
+
imageUrl: image ? await saveFile(image) : null
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return c.created(post);
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Validation
|
|
100
|
+
|
|
101
|
+
### Declarative Validation (Recommended)
|
|
102
|
+
|
|
103
|
+
Use schema options for automatic validation:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { route, FileSchema, FileArraySchema } from '@spfn/core/route';
|
|
107
|
+
import { Type } from '@sinclair/typebox';
|
|
108
|
+
|
|
109
|
+
// Single file with size and type constraints
|
|
110
|
+
export const uploadAvatar = route.post('/avatars')
|
|
111
|
+
.input({
|
|
112
|
+
formData: Type.Object({
|
|
113
|
+
avatar: FileSchema({
|
|
114
|
+
maxSize: 5 * 1024 * 1024, // 5MB
|
|
115
|
+
allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
.handler(async (c) =>
|
|
120
|
+
{
|
|
121
|
+
const { formData } = await c.data();
|
|
122
|
+
const file = formData.avatar as File;
|
|
123
|
+
// File is already validated - safe to use
|
|
124
|
+
return { name: file.name, size: file.size };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Multiple files with count and size limits
|
|
128
|
+
export const uploadDocuments = route.post('/documents')
|
|
129
|
+
.input({
|
|
130
|
+
formData: Type.Object({
|
|
131
|
+
files: FileArraySchema({
|
|
132
|
+
maxFiles: 5,
|
|
133
|
+
minFiles: 1,
|
|
134
|
+
maxSize: 10 * 1024 * 1024, // 10MB per file
|
|
135
|
+
allowedTypes: ['application/pdf', 'application/msword']
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
.handler(async (c) =>
|
|
140
|
+
{
|
|
141
|
+
const { formData } = await c.data();
|
|
142
|
+
const files = formData.files as File[];
|
|
143
|
+
return { count: files.length };
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Available Options:**
|
|
148
|
+
|
|
149
|
+
| Option | Type | Description |
|
|
150
|
+
|--------|------|-------------|
|
|
151
|
+
| `maxSize` | number | Maximum file size in bytes |
|
|
152
|
+
| `minSize` | number | Minimum file size in bytes |
|
|
153
|
+
| `allowedTypes` | string[] | Allowed MIME types |
|
|
154
|
+
| `maxFiles` | number | Maximum file count (FileArraySchema only) |
|
|
155
|
+
| `minFiles` | number | Minimum file count (FileArraySchema only) |
|
|
156
|
+
|
|
157
|
+
Validation errors are thrown automatically with 400 status code and structured error response:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"message": "Invalid form data",
|
|
162
|
+
"fields": [
|
|
163
|
+
{
|
|
164
|
+
"path": "/avatar",
|
|
165
|
+
"message": "File size 15.0MB exceeds maximum 5.0MB",
|
|
166
|
+
"value": 15728640
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
### Manual Validation (Advanced)
|
|
175
|
+
|
|
176
|
+
For custom validation logic, validate in the handler:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import { ValidationError } from '@spfn/core/errors';
|
|
180
|
+
|
|
181
|
+
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
182
|
+
|
|
183
|
+
export const uploadImage = route.post('/images')
|
|
184
|
+
.input({
|
|
185
|
+
formData: Type.Object({
|
|
186
|
+
image: FileSchema()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
.handler(async (c) =>
|
|
190
|
+
{
|
|
191
|
+
const { formData } = await c.data();
|
|
192
|
+
const file = formData.image as File;
|
|
193
|
+
|
|
194
|
+
// Custom validation logic
|
|
195
|
+
if (!ALLOWED_IMAGE_TYPES.includes(file.type))
|
|
196
|
+
{
|
|
197
|
+
throw new ValidationError({
|
|
198
|
+
message: 'Invalid file type',
|
|
199
|
+
fields: [{
|
|
200
|
+
path: '/image',
|
|
201
|
+
message: `Allowed types: ${ALLOWED_IMAGE_TYPES.join(', ')}`,
|
|
202
|
+
value: file.type
|
|
203
|
+
}]
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Process valid image...
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### File Size Validation
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
215
|
+
|
|
216
|
+
export const uploadFile = route.post('/files')
|
|
217
|
+
.input({
|
|
218
|
+
formData: Type.Object({
|
|
219
|
+
file: FileSchema()
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
.handler(async (c) =>
|
|
223
|
+
{
|
|
224
|
+
const { formData } = await c.data();
|
|
225
|
+
const file = formData.file as File;
|
|
226
|
+
|
|
227
|
+
if (file.size > MAX_FILE_SIZE)
|
|
228
|
+
{
|
|
229
|
+
throw new ValidationError({
|
|
230
|
+
message: 'File too large',
|
|
231
|
+
fields: [{
|
|
232
|
+
path: '/file',
|
|
233
|
+
message: `Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB`,
|
|
234
|
+
value: file.size
|
|
235
|
+
}]
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Process file...
|
|
240
|
+
});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Reusable Validation Helper
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// lib/file-validation.ts
|
|
247
|
+
import { ValidationError } from '@spfn/core/errors';
|
|
248
|
+
|
|
249
|
+
interface FileValidationOptions
|
|
250
|
+
{
|
|
251
|
+
maxSize?: number;
|
|
252
|
+
allowedTypes?: string[];
|
|
253
|
+
required?: boolean;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function validateFile(
|
|
257
|
+
file: File | undefined,
|
|
258
|
+
fieldName: string,
|
|
259
|
+
options: FileValidationOptions = {}
|
|
260
|
+
): void
|
|
261
|
+
{
|
|
262
|
+
const { maxSize, allowedTypes, required = true } = options;
|
|
263
|
+
|
|
264
|
+
if (!file)
|
|
265
|
+
{
|
|
266
|
+
if (required)
|
|
267
|
+
{
|
|
268
|
+
throw new ValidationError({
|
|
269
|
+
message: 'File required',
|
|
270
|
+
fields: [{ path: `/${fieldName}`, message: 'File is required', value: null }]
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (maxSize && file.size > maxSize)
|
|
277
|
+
{
|
|
278
|
+
throw new ValidationError({
|
|
279
|
+
message: 'File too large',
|
|
280
|
+
fields: [{
|
|
281
|
+
path: `/${fieldName}`,
|
|
282
|
+
message: `Maximum size: ${(maxSize / 1024 / 1024).toFixed(1)}MB`,
|
|
283
|
+
value: file.size
|
|
284
|
+
}]
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (allowedTypes && !allowedTypes.includes(file.type))
|
|
289
|
+
{
|
|
290
|
+
throw new ValidationError({
|
|
291
|
+
message: 'Invalid file type',
|
|
292
|
+
fields: [{
|
|
293
|
+
path: `/${fieldName}`,
|
|
294
|
+
message: `Allowed types: ${allowedTypes.join(', ')}`,
|
|
295
|
+
value: file.type
|
|
296
|
+
}]
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Usage
|
|
302
|
+
export const uploadImage = route.post('/images')
|
|
303
|
+
.input({ formData: Type.Object({ image: FileSchema() }) })
|
|
304
|
+
.handler(async (c) =>
|
|
305
|
+
{
|
|
306
|
+
const { formData } = await c.data();
|
|
307
|
+
const file = formData.image as File;
|
|
308
|
+
|
|
309
|
+
validateFile(file, 'image', {
|
|
310
|
+
maxSize: 5 * 1024 * 1024,
|
|
311
|
+
allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// File is valid...
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Storage Patterns
|
|
321
|
+
|
|
322
|
+
### Local File System
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
326
|
+
import { join } from 'path';
|
|
327
|
+
import { randomUUID } from 'crypto';
|
|
328
|
+
|
|
329
|
+
const UPLOAD_DIR = './uploads';
|
|
330
|
+
|
|
331
|
+
async function saveToLocal(file: File, subdir: string = ''): Promise<string>
|
|
332
|
+
{
|
|
333
|
+
const dir = join(UPLOAD_DIR, subdir);
|
|
334
|
+
await mkdir(dir, { recursive: true });
|
|
335
|
+
|
|
336
|
+
const ext = file.name.split('.').pop() || '';
|
|
337
|
+
const filename = `${randomUUID()}.${ext}`;
|
|
338
|
+
const filepath = join(dir, filename);
|
|
339
|
+
|
|
340
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
341
|
+
await writeFile(filepath, buffer);
|
|
342
|
+
|
|
343
|
+
return filepath;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export const uploadFile = route.post('/files')
|
|
347
|
+
.input({ formData: Type.Object({ file: FileSchema() }) })
|
|
348
|
+
.handler(async (c) =>
|
|
349
|
+
{
|
|
350
|
+
const { formData } = await c.data();
|
|
351
|
+
const file = formData.file as File;
|
|
352
|
+
|
|
353
|
+
const path = await saveToLocal(file, 'documents');
|
|
354
|
+
|
|
355
|
+
return c.created({ path, originalName: file.name });
|
|
356
|
+
});
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### AWS S3
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
363
|
+
import { randomUUID } from 'crypto';
|
|
364
|
+
|
|
365
|
+
const s3 = new S3Client({ region: process.env.AWS_REGION });
|
|
366
|
+
const BUCKET = process.env.S3_BUCKET!;
|
|
367
|
+
|
|
368
|
+
async function uploadToS3(file: File, prefix: string = ''): Promise<string>
|
|
369
|
+
{
|
|
370
|
+
const ext = file.name.split('.').pop() || '';
|
|
371
|
+
const key = `${prefix}${randomUUID()}.${ext}`;
|
|
372
|
+
|
|
373
|
+
await s3.send(new PutObjectCommand({
|
|
374
|
+
Bucket: BUCKET,
|
|
375
|
+
Key: key,
|
|
376
|
+
Body: Buffer.from(await file.arrayBuffer()),
|
|
377
|
+
ContentType: file.type,
|
|
378
|
+
Metadata: {
|
|
379
|
+
originalName: file.name
|
|
380
|
+
}
|
|
381
|
+
}));
|
|
382
|
+
|
|
383
|
+
return `https://${BUCKET}.s3.amazonaws.com/${key}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export const uploadAvatar = route.post('/avatars')
|
|
387
|
+
.input({
|
|
388
|
+
formData: Type.Object({
|
|
389
|
+
image: FileSchema({
|
|
390
|
+
maxSize: 2 * 1024 * 1024,
|
|
391
|
+
allowedTypes: ['image/jpeg', 'image/png']
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
.handler(async (c) =>
|
|
396
|
+
{
|
|
397
|
+
const { formData } = await c.data();
|
|
398
|
+
const file = formData.image as File;
|
|
399
|
+
// File already validated via schema
|
|
400
|
+
|
|
401
|
+
const url = await uploadToS3(file, 'avatars/');
|
|
402
|
+
|
|
403
|
+
return c.created({ url });
|
|
404
|
+
});
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Cloudflare R2
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
411
|
+
|
|
412
|
+
const r2 = new S3Client({
|
|
413
|
+
region: 'auto',
|
|
414
|
+
endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
415
|
+
credentials: {
|
|
416
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
|
417
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
async function uploadToR2(file: File, prefix: string = ''): Promise<string>
|
|
422
|
+
{
|
|
423
|
+
const key = `${prefix}${randomUUID()}.${file.name.split('.').pop()}`;
|
|
424
|
+
|
|
425
|
+
await r2.send(new PutObjectCommand({
|
|
426
|
+
Bucket: process.env.R2_BUCKET,
|
|
427
|
+
Key: key,
|
|
428
|
+
Body: Buffer.from(await file.arrayBuffer()),
|
|
429
|
+
ContentType: file.type
|
|
430
|
+
}));
|
|
431
|
+
|
|
432
|
+
return `${process.env.R2_PUBLIC_URL}/${key}`;
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Streaming (Large Files)
|
|
439
|
+
|
|
440
|
+
For large files, use streaming to avoid memory issues:
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
import { Readable } from 'stream';
|
|
444
|
+
import { createWriteStream } from 'fs';
|
|
445
|
+
import { pipeline } from 'stream/promises';
|
|
446
|
+
|
|
447
|
+
export const uploadLargeFile = route.post('/large-files')
|
|
448
|
+
.handler(async (c) =>
|
|
449
|
+
{
|
|
450
|
+
// Access raw request for streaming
|
|
451
|
+
const formData = await c.raw.req.formData();
|
|
452
|
+
const file = formData.get('file') as File;
|
|
453
|
+
|
|
454
|
+
if (!file)
|
|
455
|
+
{
|
|
456
|
+
throw new ValidationError({ message: 'File required' });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Stream to disk
|
|
460
|
+
const outputPath = `./uploads/${randomUUID()}.bin`;
|
|
461
|
+
const writeStream = createWriteStream(outputPath);
|
|
462
|
+
|
|
463
|
+
// Convert File to Node.js Readable stream
|
|
464
|
+
const reader = file.stream().getReader();
|
|
465
|
+
const nodeStream = new Readable({
|
|
466
|
+
async read()
|
|
467
|
+
{
|
|
468
|
+
const { done, value } = await reader.read();
|
|
469
|
+
if (done)
|
|
470
|
+
{
|
|
471
|
+
this.push(null);
|
|
472
|
+
}
|
|
473
|
+
else
|
|
474
|
+
{
|
|
475
|
+
this.push(Buffer.from(value));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
await pipeline(nodeStream, writeStream);
|
|
481
|
+
|
|
482
|
+
return c.created({ path: outputPath, size: file.size });
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
---
|
|
487
|
+
|
|
488
|
+
## Security Best Practices
|
|
489
|
+
|
|
490
|
+
### 1. Always Validate MIME Types
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
// Don't trust the file extension - check MIME type
|
|
494
|
+
const file = formData.file as File;
|
|
495
|
+
|
|
496
|
+
// Also consider using magic bytes for true type detection
|
|
497
|
+
import { fileTypeFromBuffer } from 'file-type';
|
|
498
|
+
|
|
499
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
500
|
+
const detected = await fileTypeFromBuffer(buffer);
|
|
501
|
+
|
|
502
|
+
if (!detected || !ALLOWED_TYPES.includes(detected.mime))
|
|
503
|
+
{
|
|
504
|
+
throw new ValidationError({ message: 'Invalid file type' });
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### 2. Generate New Filenames
|
|
509
|
+
|
|
510
|
+
```typescript
|
|
511
|
+
// Never use user-provided filenames directly
|
|
512
|
+
const userFilename = file.name; // potentially malicious
|
|
513
|
+
|
|
514
|
+
// Generate safe filename
|
|
515
|
+
const safeFilename = `${randomUUID()}.${getExtension(file.type)}`;
|
|
516
|
+
|
|
517
|
+
function getExtension(mimeType: string): string
|
|
518
|
+
{
|
|
519
|
+
const map: Record<string, string> = {
|
|
520
|
+
'image/jpeg': 'jpg',
|
|
521
|
+
'image/png': 'png',
|
|
522
|
+
'image/webp': 'webp',
|
|
523
|
+
'application/pdf': 'pdf'
|
|
524
|
+
};
|
|
525
|
+
return map[mimeType] || 'bin';
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### 3. Limit File Size at Server Level
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
// server.config.ts
|
|
533
|
+
export default defineServerConfig()
|
|
534
|
+
.lifecycle({
|
|
535
|
+
beforeRoutes: async (app) =>
|
|
536
|
+
{
|
|
537
|
+
// Global body size limit (Hono middleware)
|
|
538
|
+
app.use('*', async (c, next) =>
|
|
539
|
+
{
|
|
540
|
+
const contentLength = parseInt(c.req.header('content-length') || '0');
|
|
541
|
+
const MAX_BODY_SIZE = 50 * 1024 * 1024; // 50MB
|
|
542
|
+
|
|
543
|
+
if (contentLength > MAX_BODY_SIZE)
|
|
544
|
+
{
|
|
545
|
+
return c.json({ error: 'Request too large' }, 413);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
await next();
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
})
|
|
552
|
+
.build();
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### 4. Store Outside Web Root
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
// Files should not be directly accessible via URL
|
|
559
|
+
const UPLOAD_DIR = '/var/data/uploads'; // Outside public/
|
|
560
|
+
|
|
561
|
+
// Serve files through authenticated route
|
|
562
|
+
export const getFile = route.get('/files/:id')
|
|
563
|
+
.use([authMiddleware])
|
|
564
|
+
.handler(async (c) =>
|
|
565
|
+
{
|
|
566
|
+
const { params } = await c.data();
|
|
567
|
+
const file = await fileRepo.findById(params.id);
|
|
568
|
+
|
|
569
|
+
if (!file || !canAccess(c.raw.get('user'), file))
|
|
570
|
+
{
|
|
571
|
+
throw new NotFoundError();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Stream file from secure location
|
|
575
|
+
const buffer = await readFile(file.path);
|
|
576
|
+
return new Response(buffer, {
|
|
577
|
+
headers: {
|
|
578
|
+
'Content-Type': file.mimeType,
|
|
579
|
+
'Content-Disposition': `attachment; filename="${file.originalName}"`
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
### 5. Scan for Malware (Production)
|
|
586
|
+
|
|
587
|
+
```typescript
|
|
588
|
+
import { ClamScan } from 'clamscan';
|
|
589
|
+
|
|
590
|
+
const clam = new ClamScan({ clamdscan: { host: 'localhost', port: 3310 } });
|
|
591
|
+
|
|
592
|
+
async function scanFile(buffer: Buffer): Promise<boolean>
|
|
593
|
+
{
|
|
594
|
+
const { isInfected } = await clam.scanBuffer(buffer);
|
|
595
|
+
return !isInfected;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export const uploadFile = route.post('/files')
|
|
599
|
+
.handler(async (c) =>
|
|
600
|
+
{
|
|
601
|
+
const { formData } = await c.data();
|
|
602
|
+
const file = formData.file as File;
|
|
603
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
604
|
+
|
|
605
|
+
const isSafe = await scanFile(buffer);
|
|
606
|
+
if (!isSafe)
|
|
607
|
+
{
|
|
608
|
+
throw new ValidationError({ message: 'File rejected by security scan' });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Proceed with safe file...
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## Client Usage
|
|
618
|
+
|
|
619
|
+
### SPFN API Client (Recommended)
|
|
620
|
+
|
|
621
|
+
Type-safe file upload with full type inference:
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
import { createApi } from '@spfn/core/nextjs';
|
|
625
|
+
import type { AppRouter } from '@/server/router';
|
|
626
|
+
|
|
627
|
+
const api = createApi<AppRouter>();
|
|
628
|
+
|
|
629
|
+
// Single file upload
|
|
630
|
+
const result = await api.uploadAvatar.call({
|
|
631
|
+
params: { id: '123' },
|
|
632
|
+
formData: {
|
|
633
|
+
file: fileInput.files[0], // File object - type-safe!
|
|
634
|
+
description: 'Profile photo' // string field
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Multiple files
|
|
639
|
+
const docs = await api.uploadDocuments.call({
|
|
640
|
+
formData: {
|
|
641
|
+
files: Array.from(fileInput.files), // File[]
|
|
642
|
+
category: 'reports'
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// With additional options
|
|
647
|
+
const result = await api.uploadFile
|
|
648
|
+
.headers({ 'X-Custom': 'value' })
|
|
649
|
+
.call({
|
|
650
|
+
formData: { file: myFile }
|
|
651
|
+
});
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
**How it works:**
|
|
655
|
+
1. Client builds `FormData` with files and metadata
|
|
656
|
+
2. RPC Proxy parses multipart and forwards to backend
|
|
657
|
+
3. Backend route receives typed `formData` via `c.data()`
|
|
658
|
+
|
|
659
|
+
### Fetch API
|
|
660
|
+
|
|
661
|
+
For direct backend calls (bypassing RPC proxy):
|
|
662
|
+
|
|
663
|
+
```typescript
|
|
664
|
+
const formData = new FormData();
|
|
665
|
+
formData.append('file', fileInput.files[0]);
|
|
666
|
+
formData.append('description', 'My file');
|
|
667
|
+
|
|
668
|
+
const response = await fetch('/api/upload', {
|
|
669
|
+
method: 'POST',
|
|
670
|
+
body: formData
|
|
671
|
+
// Note: Don't set Content-Type header - browser sets it with boundary
|
|
672
|
+
});
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
### curl
|
|
676
|
+
|
|
677
|
+
```bash
|
|
678
|
+
# Single file
|
|
679
|
+
curl -X POST http://localhost:3000/upload \
|
|
680
|
+
-F "file=@./document.pdf" \
|
|
681
|
+
-F "description=Important document"
|
|
682
|
+
|
|
683
|
+
# Multiple files
|
|
684
|
+
curl -X POST http://localhost:3000/upload-multiple \
|
|
685
|
+
-F "files=@./file1.txt" \
|
|
686
|
+
-F "files=@./file2.txt"
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
---
|
|
690
|
+
|
|
691
|
+
## Summary
|
|
692
|
+
|
|
693
|
+
| Schema | Description |
|
|
694
|
+
|--------|-------------|
|
|
695
|
+
| `FileSchema()` | Single File object |
|
|
696
|
+
| `FileSchema(options)` | Single File with validation |
|
|
697
|
+
| `FileArraySchema()` | Array of File objects |
|
|
698
|
+
| `FileArraySchema(options)` | Array of Files with validation |
|
|
699
|
+
| `OptionalFileSchema()` | Optional single File |
|
|
700
|
+
| `OptionalFileSchema(options)` | Optional File with validation |
|
|
701
|
+
|
|
702
|
+
| Validation Option | Type | Description |
|
|
703
|
+
|-------------------|------|-------------|
|
|
704
|
+
| `maxSize` | number | Maximum file size in bytes |
|
|
705
|
+
| `minSize` | number | Minimum file size in bytes |
|
|
706
|
+
| `allowedTypes` | string[] | Allowed MIME types |
|
|
707
|
+
| `maxFiles` | number | Maximum file count (FileArraySchema only) |
|
|
708
|
+
| `minFiles` | number | Minimum file count (FileArraySchema only) |
|
|
709
|
+
|
|
710
|
+
| File Property | Type | Description |
|
|
711
|
+
|---------------|------|-------------|
|
|
712
|
+
| `file.name` | string | Original filename |
|
|
713
|
+
| `file.size` | number | Size in bytes |
|
|
714
|
+
| `file.type` | string | MIME type |
|
|
715
|
+
| `file.arrayBuffer()` | Promise\<ArrayBuffer\> | File content as buffer |
|
|
716
|
+
| `file.text()` | Promise\<string\> | File content as text |
|
|
717
|
+
| `file.stream()` | ReadableStream | File as stream |
|