@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6
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 +183 -4
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +6 -387
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -6
- package/dist/ops/index.js +330 -30
- package/dist/ops/index.js.map +1 -1
- package/dist/server/index.js +24 -1
- package/dist/server/index.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
package/docs/file-upload.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# File Upload
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SPFN provides `FileSchema()` and `FileArraySchema()` for type-safe file uploads within [route definitions](../src/route/README.md). Files are received as standard `File` objects through `formData` input.
|
|
4
|
+
|
|
5
|
+
> **They are functions — always call them.** `file: FileSchema()` is correct;
|
|
6
|
+
> `file: FileSchema` passes the function reference and produces an invalid schema. The
|
|
7
|
+
> same goes for `FileArraySchema()` and `OptionalFileSchema()`.
|
|
8
|
+
>
|
|
9
|
+
> `body` and `formData` are also mutually exclusive at runtime: the request's
|
|
10
|
+
> `Content-Type` decides which one is parsed. A multipart request never populates `body`.
|
|
4
11
|
|
|
5
12
|
## Basic Usage
|
|
6
13
|
|
|
@@ -15,8 +22,8 @@ export const uploadAvatar = route.post('/users/:id/avatar')
|
|
|
15
22
|
params: Type.Object({ id: Type.String() }),
|
|
16
23
|
formData: Type.Object({
|
|
17
24
|
file: FileSchema(),
|
|
18
|
-
description: Type.Optional(Type.String())
|
|
19
|
-
})
|
|
25
|
+
description: Type.Optional(Type.String()),
|
|
26
|
+
}),
|
|
20
27
|
})
|
|
21
28
|
.handler(async (c) =>
|
|
22
29
|
{
|
|
@@ -30,7 +37,6 @@ export const uploadAvatar = route.post('/users/:id/avatar')
|
|
|
30
37
|
|
|
31
38
|
// Read file content
|
|
32
39
|
const buffer = await file.arrayBuffer();
|
|
33
|
-
const text = await file.text(); // for text files
|
|
34
40
|
|
|
35
41
|
return c.created({ filename: file.name, size: file.size });
|
|
36
42
|
});
|
|
@@ -45,8 +51,8 @@ export const uploadDocuments = route.post('/documents')
|
|
|
45
51
|
.input({
|
|
46
52
|
formData: Type.Object({
|
|
47
53
|
files: FileArraySchema(),
|
|
48
|
-
category: Type.String()
|
|
49
|
-
})
|
|
54
|
+
category: Type.String(),
|
|
55
|
+
}),
|
|
50
56
|
})
|
|
51
57
|
.handler(async (c) =>
|
|
52
58
|
{
|
|
@@ -57,7 +63,6 @@ export const uploadDocuments = route.post('/documents')
|
|
|
57
63
|
files.map(async (file) =>
|
|
58
64
|
{
|
|
59
65
|
const buffer = await file.arrayBuffer();
|
|
60
|
-
// Process each file...
|
|
61
66
|
return { name: file.name, size: file.size };
|
|
62
67
|
})
|
|
63
68
|
);
|
|
@@ -66,45 +71,44 @@ export const uploadDocuments = route.post('/documents')
|
|
|
66
71
|
});
|
|
67
72
|
```
|
|
68
73
|
|
|
69
|
-
### Mixed Fields
|
|
74
|
+
### Mixed Fields (File + Text)
|
|
70
75
|
|
|
71
76
|
```typescript
|
|
77
|
+
import { route, FileSchema } from '@spfn/core/route';
|
|
78
|
+
|
|
72
79
|
export const createPost = route.post('/posts')
|
|
73
80
|
.input({
|
|
74
81
|
formData: Type.Object({
|
|
75
82
|
title: Type.String(),
|
|
76
83
|
content: Type.String(),
|
|
77
|
-
image:
|
|
78
|
-
tags: Type.Optional(Type.String()) // JSON string
|
|
79
|
-
})
|
|
84
|
+
image: FileSchema(),
|
|
85
|
+
tags: Type.Optional(Type.String()), // JSON string
|
|
86
|
+
}),
|
|
80
87
|
})
|
|
81
88
|
.handler(async (c) =>
|
|
82
89
|
{
|
|
83
90
|
const { formData } = await c.data();
|
|
84
|
-
const image = formData.image as File
|
|
91
|
+
const image = formData.image as File;
|
|
85
92
|
|
|
86
93
|
const post = await postRepo.create({
|
|
87
94
|
title: formData.title,
|
|
88
95
|
content: formData.content,
|
|
89
96
|
tags: formData.tags ? JSON.parse(formData.tags) : [],
|
|
90
|
-
imageUrl:
|
|
97
|
+
imageUrl: await saveFile(image),
|
|
91
98
|
});
|
|
92
99
|
|
|
93
100
|
return c.created(post);
|
|
94
101
|
});
|
|
95
102
|
```
|
|
96
103
|
|
|
97
|
-
---
|
|
98
|
-
|
|
99
104
|
## Validation
|
|
100
105
|
|
|
101
106
|
### Declarative Validation (Recommended)
|
|
102
107
|
|
|
103
|
-
|
|
108
|
+
Pass validation options directly to the schema for automatic enforcement:
|
|
104
109
|
|
|
105
110
|
```typescript
|
|
106
111
|
import { route, FileSchema, FileArraySchema } from '@spfn/core/route';
|
|
107
|
-
import { Type } from '@sinclair/typebox';
|
|
108
112
|
|
|
109
113
|
// Single file with size and type constraints
|
|
110
114
|
export const uploadAvatar = route.post('/avatars')
|
|
@@ -112,9 +116,9 @@ export const uploadAvatar = route.post('/avatars')
|
|
|
112
116
|
formData: Type.Object({
|
|
113
117
|
avatar: FileSchema({
|
|
114
118
|
maxSize: 5 * 1024 * 1024, // 5MB
|
|
115
|
-
allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
|
|
116
|
-
})
|
|
117
|
-
})
|
|
119
|
+
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
118
122
|
})
|
|
119
123
|
.handler(async (c) =>
|
|
120
124
|
{
|
|
@@ -132,9 +136,9 @@ export const uploadDocuments = route.post('/documents')
|
|
|
132
136
|
maxFiles: 5,
|
|
133
137
|
minFiles: 1,
|
|
134
138
|
maxSize: 10 * 1024 * 1024, // 10MB per file
|
|
135
|
-
allowedTypes: ['application/pdf', 'application/msword']
|
|
136
|
-
})
|
|
137
|
-
})
|
|
139
|
+
allowedTypes: ['application/pdf', 'application/msword'],
|
|
140
|
+
}),
|
|
141
|
+
}),
|
|
138
142
|
})
|
|
139
143
|
.handler(async (c) =>
|
|
140
144
|
{
|
|
@@ -144,20 +148,11 @@ export const uploadDocuments = route.post('/documents')
|
|
|
144
148
|
});
|
|
145
149
|
```
|
|
146
150
|
|
|
147
|
-
|
|
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:
|
|
151
|
+
Validation errors are thrown automatically with a 400 status:
|
|
158
152
|
|
|
159
153
|
```json
|
|
160
154
|
{
|
|
155
|
+
"__type": "ValidationError",
|
|
161
156
|
"message": "Invalid form data",
|
|
162
157
|
"fields": [
|
|
163
158
|
{
|
|
@@ -165,13 +160,36 @@ Validation errors are thrown automatically with 400 status code and structured e
|
|
|
165
160
|
"message": "File size 15.0MB exceeds maximum 5.0MB",
|
|
166
161
|
"value": 15728640
|
|
167
162
|
}
|
|
168
|
-
]
|
|
163
|
+
],
|
|
164
|
+
"error": {
|
|
165
|
+
"code": "ValidationError",
|
|
166
|
+
"message": "Invalid form data",
|
|
167
|
+
"requestId": "req_1754380000000_9f2c1ab4e7d0"
|
|
168
|
+
}
|
|
169
169
|
}
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
Every error body carries `__type` (what the web client restores an error class from) and
|
|
173
|
+
an `error` envelope with `code` / `message` / `requestId` (what a client in another
|
|
174
|
+
language classifies on). For a file-array field the `path` includes the index —
|
|
175
|
+
`/files/2` for the third file, `/files` for a count violation (`maxFiles` / `minFiles`).
|
|
176
|
+
|
|
177
|
+
> **A missing file field is not a validation error.** Validation walks the fields the
|
|
178
|
+
> request actually sent, so a `FileSchema()` field the client omitted produces no error —
|
|
179
|
+
> the handler just receives `undefined`. `formData.avatar as File` is a cast, not a
|
|
180
|
+
> guarantee. Check for the file yourself before using it.
|
|
173
181
|
|
|
174
|
-
###
|
|
182
|
+
### Validation Options
|
|
183
|
+
|
|
184
|
+
| Option | Type | Applies To | Description |
|
|
185
|
+
|--------|------|------------|-------------|
|
|
186
|
+
| `maxSize` | number | Both | Maximum file size in bytes |
|
|
187
|
+
| `minSize` | number | Both | Minimum file size in bytes |
|
|
188
|
+
| `allowedTypes` | string[] | Both | Allowed MIME types |
|
|
189
|
+
| `maxFiles` | number | FileArraySchema | Maximum file count |
|
|
190
|
+
| `minFiles` | number | FileArraySchema | Minimum file count |
|
|
191
|
+
|
|
192
|
+
### Manual Validation
|
|
175
193
|
|
|
176
194
|
For custom validation logic, validate in the handler:
|
|
177
195
|
|
|
@@ -183,15 +201,14 @@ const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif
|
|
|
183
201
|
export const uploadImage = route.post('/images')
|
|
184
202
|
.input({
|
|
185
203
|
formData: Type.Object({
|
|
186
|
-
image: FileSchema()
|
|
187
|
-
})
|
|
204
|
+
image: FileSchema(),
|
|
205
|
+
}),
|
|
188
206
|
})
|
|
189
207
|
.handler(async (c) =>
|
|
190
208
|
{
|
|
191
209
|
const { formData } = await c.data();
|
|
192
210
|
const file = formData.image as File;
|
|
193
211
|
|
|
194
|
-
// Custom validation logic
|
|
195
212
|
if (!ALLOWED_IMAGE_TYPES.includes(file.type))
|
|
196
213
|
{
|
|
197
214
|
throw new ValidationError({
|
|
@@ -199,8 +216,8 @@ export const uploadImage = route.post('/images')
|
|
|
199
216
|
fields: [{
|
|
200
217
|
path: '/image',
|
|
201
218
|
message: `Allowed types: ${ALLOWED_IMAGE_TYPES.join(', ')}`,
|
|
202
|
-
value: file.type
|
|
203
|
-
}]
|
|
219
|
+
value: file.type,
|
|
220
|
+
}],
|
|
204
221
|
});
|
|
205
222
|
}
|
|
206
223
|
|
|
@@ -208,116 +225,75 @@ export const uploadImage = route.post('/images')
|
|
|
208
225
|
});
|
|
209
226
|
```
|
|
210
227
|
|
|
211
|
-
|
|
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
|
-
}
|
|
228
|
+
## Storage Patterns
|
|
238
229
|
|
|
239
|
-
|
|
240
|
-
});
|
|
241
|
-
```
|
|
230
|
+
### `@spfn/storage` (recommended)
|
|
242
231
|
|
|
243
|
-
|
|
232
|
+
SPFN ships provider-agnostic object storage — S3-compatible services (S3, R2, MinIO,
|
|
233
|
+
Wasabi), Google Cloud Storage, and the local filesystem behind one interface. Prefer it
|
|
234
|
+
over calling a provider SDK directly: every object operation validates its key before it
|
|
235
|
+
reaches the provider (rejecting `..` segments, leading `/`, backslashes, control
|
|
236
|
+
characters and URLs), so a key built from user input cannot escape its prefix.
|
|
244
237
|
|
|
245
238
|
```typescript
|
|
246
|
-
|
|
247
|
-
import { ValidationError } from '@spfn/core/errors';
|
|
239
|
+
import { getStorageService, randomKey } from '@spfn/storage/server';
|
|
248
240
|
|
|
249
|
-
|
|
241
|
+
async function saveUpload(file: File): Promise<string>
|
|
250
242
|
{
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
243
|
+
const storage = await getStorageService();
|
|
244
|
+
const key = randomKey('public/uploads', file.name.split('.').pop() || 'bin');
|
|
245
|
+
|
|
246
|
+
await storage.upload(key, Buffer.from(await file.arrayBuffer()), file.type);
|
|
247
|
+
|
|
248
|
+
return storage.getPublicUrl(key);
|
|
254
249
|
}
|
|
250
|
+
```
|
|
255
251
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
options: FileValidationOptions = {}
|
|
260
|
-
): void
|
|
261
|
-
{
|
|
262
|
-
const { maxSize, allowedTypes, required = true } = options;
|
|
252
|
+
The provider comes from `STORAGE_PROVIDER` (`local` / `s3` / `gcs`), defaulting to `local`
|
|
253
|
+
in development and `s3` in production. Private objects are read back with
|
|
254
|
+
`storage.getDownloadUrl(key)` (a presigned GET) or `storage.getStream(key)`.
|
|
263
255
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
throw new ValidationError({
|
|
269
|
-
message: 'File required',
|
|
270
|
-
fields: [{ path: `/${fieldName}`, message: 'File is required', value: null }]
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
256
|
+
A `public/` key prefix is meaningful **on GCS only**, where it routes the object to the
|
|
257
|
+
public bucket instead of the private one. On S3-compatible providers and local there is a
|
|
258
|
+
single bucket, and `getPublicUrl()` just prepends the configured public base URL to any
|
|
259
|
+
key — the prefix is a convention you must back with your own bucket policy.
|
|
275
260
|
|
|
276
|
-
|
|
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
|
-
}
|
|
261
|
+
### Presigned upload (large files)
|
|
287
262
|
|
|
288
|
-
|
|
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
|
-
}
|
|
263
|
+
For large files, don't route the bytes through your API process at all — sign an upload,
|
|
264
|
+
let the browser `PUT` straight to the returned `uploadUrl`, then confirm it:
|
|
300
265
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
266
|
+
```typescript
|
|
267
|
+
const { uploadUrl, requiredHeaders } = await storage.getUploadUrl({
|
|
268
|
+
key,
|
|
269
|
+
contentType: 'image/webp',
|
|
270
|
+
contentLength: exactSize, // signed on both S3 and GCS
|
|
271
|
+
temp: true, // unconfirmed until finalized
|
|
272
|
+
});
|
|
308
273
|
|
|
309
|
-
|
|
310
|
-
maxSize: 5 * 1024 * 1024,
|
|
311
|
-
allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
|
|
312
|
-
});
|
|
274
|
+
// browser PUTs to uploadUrl, sending every requiredHeaders entry verbatim
|
|
313
275
|
|
|
314
|
-
|
|
315
|
-
});
|
|
276
|
+
await storage.finalizeObject(key);
|
|
316
277
|
```
|
|
317
278
|
|
|
318
|
-
|
|
279
|
+
Three constraints decide whether this is safe:
|
|
319
280
|
|
|
320
|
-
|
|
281
|
+
| Constraint | Behaviour |
|
|
282
|
+
|---|---|
|
|
283
|
+
| `contentLength` (exact size) | Signed on **both** S3-compatible and GCS. A mismatched size fails. |
|
|
284
|
+
| `maxBytes` (upper bound) | Enforced on **GCS only**. A presigned PUT cannot sign a size range, so S3, R2, MinIO and Wasabi **ignore it silently**. |
|
|
285
|
+
| Local filesystem provider | Presigned upload is **not supported** — `getUploadUrl()` throws. Use the direct `upload()` path in local dev. |
|
|
286
|
+
|
|
287
|
+
A client can declare one size and send another, so a server-side check of a
|
|
288
|
+
client-declared size binds nothing. If you only know an upper bound and must enforce it on
|
|
289
|
+
S3, verify the size after upload.
|
|
290
|
+
|
|
291
|
+
`temp: true` marks the upload unconfirmed so abandoned uploads don't accumulate, and
|
|
292
|
+
`finalizeObject(key)` confirms it (idempotent; it rejects if neither the temp nor the final
|
|
293
|
+
object exists). The package does **not** delete orphans itself — it tags them
|
|
294
|
+
(`lifecycle=temp` on S3) or stages them under `tmp/<key>` (GCS), and you configure the
|
|
295
|
+
bucket lifecycle rule that expires them. On GCS a temp object is not readable at its final
|
|
296
|
+
key until finalized; on S3 it is.
|
|
321
297
|
|
|
322
298
|
### Local File System
|
|
323
299
|
|
|
@@ -342,18 +318,6 @@ async function saveToLocal(file: File, subdir: string = ''): Promise<string>
|
|
|
342
318
|
|
|
343
319
|
return filepath;
|
|
344
320
|
}
|
|
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
321
|
```
|
|
358
322
|
|
|
359
323
|
### AWS S3
|
|
@@ -375,47 +339,26 @@ async function uploadToS3(file: File, prefix: string = ''): Promise<string>
|
|
|
375
339
|
Key: key,
|
|
376
340
|
Body: Buffer.from(await file.arrayBuffer()),
|
|
377
341
|
ContentType: file.type,
|
|
378
|
-
Metadata: {
|
|
379
|
-
originalName: file.name
|
|
380
|
-
}
|
|
342
|
+
Metadata: { originalName: file.name },
|
|
381
343
|
}));
|
|
382
344
|
|
|
383
345
|
return `https://${BUCKET}.s3.amazonaws.com/${key}`;
|
|
384
346
|
}
|
|
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
347
|
```
|
|
406
348
|
|
|
407
349
|
### Cloudflare R2
|
|
408
350
|
|
|
409
351
|
```typescript
|
|
410
352
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
353
|
+
import { randomUUID } from 'crypto';
|
|
411
354
|
|
|
412
355
|
const r2 = new S3Client({
|
|
413
356
|
region: 'auto',
|
|
414
357
|
endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
415
358
|
credentials: {
|
|
416
359
|
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
|
417
|
-
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY
|
|
418
|
-
}
|
|
360
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
|
361
|
+
},
|
|
419
362
|
});
|
|
420
363
|
|
|
421
364
|
async function uploadToR2(file: File, prefix: string = ''): Promise<string>
|
|
@@ -426,15 +369,13 @@ async function uploadToR2(file: File, prefix: string = ''): Promise<string>
|
|
|
426
369
|
Bucket: process.env.R2_BUCKET,
|
|
427
370
|
Key: key,
|
|
428
371
|
Body: Buffer.from(await file.arrayBuffer()),
|
|
429
|
-
ContentType: file.type
|
|
372
|
+
ContentType: file.type,
|
|
430
373
|
}));
|
|
431
374
|
|
|
432
375
|
return `${process.env.R2_PUBLIC_URL}/${key}`;
|
|
433
376
|
}
|
|
434
377
|
```
|
|
435
378
|
|
|
436
|
-
---
|
|
437
|
-
|
|
438
379
|
## Streaming (Large Files)
|
|
439
380
|
|
|
440
381
|
For large files, use streaming to avoid memory issues:
|
|
@@ -443,6 +384,7 @@ For large files, use streaming to avoid memory issues:
|
|
|
443
384
|
import { Readable } from 'stream';
|
|
444
385
|
import { createWriteStream } from 'fs';
|
|
445
386
|
import { pipeline } from 'stream/promises';
|
|
387
|
+
import { randomUUID } from 'crypto';
|
|
446
388
|
|
|
447
389
|
export const uploadLargeFile = route.post('/large-files')
|
|
448
390
|
.handler(async (c) =>
|
|
@@ -456,11 +398,9 @@ export const uploadLargeFile = route.post('/large-files')
|
|
|
456
398
|
throw new ValidationError({ message: 'File required' });
|
|
457
399
|
}
|
|
458
400
|
|
|
459
|
-
// Stream to disk
|
|
460
401
|
const outputPath = `./uploads/${randomUUID()}.bin`;
|
|
461
402
|
const writeStream = createWriteStream(outputPath);
|
|
462
403
|
|
|
463
|
-
// Convert File to Node.js Readable stream
|
|
464
404
|
const reader = file.stream().getReader();
|
|
465
405
|
const nodeStream = new Readable({
|
|
466
406
|
async read()
|
|
@@ -474,7 +414,7 @@ export const uploadLargeFile = route.post('/large-files')
|
|
|
474
414
|
{
|
|
475
415
|
this.push(Buffer.from(value));
|
|
476
416
|
}
|
|
477
|
-
}
|
|
417
|
+
},
|
|
478
418
|
});
|
|
479
419
|
|
|
480
420
|
await pipeline(nodeStream, writeStream);
|
|
@@ -483,17 +423,70 @@ export const uploadLargeFile = route.post('/large-files')
|
|
|
483
423
|
});
|
|
484
424
|
```
|
|
485
425
|
|
|
486
|
-
|
|
426
|
+
## Client Usage
|
|
427
|
+
|
|
428
|
+
### SPFN API Client (Recommended)
|
|
429
|
+
|
|
430
|
+
The generated API client handles `FormData` construction automatically:
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
import { api } from '@/lib/api';
|
|
434
|
+
|
|
435
|
+
// Single file upload
|
|
436
|
+
const result = await api.uploadAvatar.call({
|
|
437
|
+
params: { id: '123' },
|
|
438
|
+
formData: {
|
|
439
|
+
file: fileInput.files[0],
|
|
440
|
+
description: 'Profile photo',
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Multiple files
|
|
445
|
+
const docs = await api.uploadDocuments.call({
|
|
446
|
+
formData: {
|
|
447
|
+
files: Array.from(fileInput.files),
|
|
448
|
+
category: 'reports',
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Fetch API
|
|
454
|
+
|
|
455
|
+
For direct backend calls (bypassing RPC proxy):
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
const formData = new FormData();
|
|
459
|
+
formData.append('file', fileInput.files[0]);
|
|
460
|
+
formData.append('description', 'My file');
|
|
461
|
+
|
|
462
|
+
const response = await fetch('/api/upload', {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
body: formData,
|
|
465
|
+
// Don't set Content-Type - browser sets it with boundary
|
|
466
|
+
});
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### curl
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
# Single file
|
|
473
|
+
curl -X POST http://localhost:3000/upload \
|
|
474
|
+
-F "file=@./document.pdf" \
|
|
475
|
+
-F "description=Important document"
|
|
476
|
+
|
|
477
|
+
# Multiple files
|
|
478
|
+
curl -X POST http://localhost:3000/upload-multiple \
|
|
479
|
+
-F "files=@./file1.txt" \
|
|
480
|
+
-F "files=@./file2.txt"
|
|
481
|
+
```
|
|
487
482
|
|
|
488
483
|
## Security Best Practices
|
|
489
484
|
|
|
490
485
|
### 1. Always Validate MIME Types
|
|
491
486
|
|
|
492
487
|
```typescript
|
|
493
|
-
// Don't trust
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
// Also consider using magic bytes for true type detection
|
|
488
|
+
// Don't trust file extensions - check MIME type
|
|
489
|
+
// Consider using magic bytes for true type detection
|
|
497
490
|
import { fileTypeFromBuffer } from 'file-type';
|
|
498
491
|
|
|
499
492
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
@@ -509,9 +502,6 @@ if (!detected || !ALLOWED_TYPES.includes(detected.mime))
|
|
|
509
502
|
|
|
510
503
|
```typescript
|
|
511
504
|
// Never use user-provided filenames directly
|
|
512
|
-
const userFilename = file.name; // potentially malicious
|
|
513
|
-
|
|
514
|
-
// Generate safe filename
|
|
515
505
|
const safeFilename = `${randomUUID()}.${getExtension(file.type)}`;
|
|
516
506
|
|
|
517
507
|
function getExtension(mimeType: string): string
|
|
@@ -520,39 +510,13 @@ function getExtension(mimeType: string): string
|
|
|
520
510
|
'image/jpeg': 'jpg',
|
|
521
511
|
'image/png': 'png',
|
|
522
512
|
'image/webp': 'webp',
|
|
523
|
-
'application/pdf': 'pdf'
|
|
513
|
+
'application/pdf': 'pdf',
|
|
524
514
|
};
|
|
525
515
|
return map[mimeType] || 'bin';
|
|
526
516
|
}
|
|
527
517
|
```
|
|
528
518
|
|
|
529
|
-
### 3.
|
|
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
|
|
519
|
+
### 3. Store Outside Web Root
|
|
556
520
|
|
|
557
521
|
```typescript
|
|
558
522
|
// Files should not be directly accessible via URL
|
|
@@ -568,127 +532,24 @@ export const getFile = route.get('/files/:id')
|
|
|
568
532
|
|
|
569
533
|
if (!file || !canAccess(c.raw.get('user'), file))
|
|
570
534
|
{
|
|
571
|
-
throw new NotFoundError();
|
|
535
|
+
throw new NotFoundError({ resource: 'File' });
|
|
572
536
|
}
|
|
573
537
|
|
|
574
|
-
// Stream file from secure location
|
|
575
538
|
const buffer = await readFile(file.path);
|
|
576
539
|
return new Response(buffer, {
|
|
577
540
|
headers: {
|
|
578
541
|
'Content-Type': file.mimeType,
|
|
579
|
-
'Content-Disposition': `attachment; filename="${file.originalName}"
|
|
580
|
-
}
|
|
542
|
+
'Content-Disposition': `attachment; filename="${file.originalName}"`,
|
|
543
|
+
},
|
|
581
544
|
});
|
|
582
545
|
});
|
|
583
546
|
```
|
|
584
547
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
---
|
|
548
|
+
A handler that returns a raw `Response` has it passed through as-is, but the typed client
|
|
549
|
+
then infers the response as `Response` rather than a concrete shape. That trade-off is
|
|
550
|
+
fine for a file download and wrong for a JSON endpoint.
|
|
616
551
|
|
|
617
|
-
##
|
|
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
|
|
552
|
+
## Schema Reference
|
|
692
553
|
|
|
693
554
|
| Schema | Description |
|
|
694
555
|
|--------|-------------|
|
|
@@ -699,19 +560,20 @@ curl -X POST http://localhost:3000/upload-multiple \
|
|
|
699
560
|
| `OptionalFileSchema()` | Optional single File |
|
|
700
561
|
| `OptionalFileSchema(options)` | Optional File with validation |
|
|
701
562
|
|
|
702
|
-
|
|
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) |
|
|
563
|
+
## File Properties
|
|
709
564
|
|
|
710
|
-
|
|
|
711
|
-
|
|
565
|
+
| Property | Type | Description |
|
|
566
|
+
|----------|------|-------------|
|
|
712
567
|
| `file.name` | string | Original filename |
|
|
713
568
|
| `file.size` | number | Size in bytes |
|
|
714
569
|
| `file.type` | string | MIME type |
|
|
715
570
|
| `file.arrayBuffer()` | Promise\<ArrayBuffer\> | File content as buffer |
|
|
716
571
|
| `file.text()` | Promise\<string\> | File content as text |
|
|
717
572
|
| `file.stream()` | ReadableStream | File as stream |
|
|
573
|
+
|
|
574
|
+
## Related
|
|
575
|
+
|
|
576
|
+
- `@spfn/storage` - object storage (S3 / GCS / local), presigned uploads, key validation
|
|
577
|
+
- [Route Definition](../src/route/README.md) - `formData` input type
|
|
578
|
+
- [Next.js Integration](../src/nextjs/README.md) - Upload files through RPC proxy
|
|
579
|
+
- [Error Handling](../src/errors/README.md) - `ValidationError` for file errors
|