@better-s3/server 3.1049.0 → 3.1051.0
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 +10 -194
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @better-s3/server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Presigned S3 route handlers and lifecycle hooks for upload, download, delete, and multipart.
|
|
4
|
+
|
|
5
|
+
Full documentation: [better-s3-docs.vercel.app](https://better-s3-docs.vercel.app/docs/server)
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -8,10 +10,9 @@ Server-side presigned URL handlers for S3 — upload, download, delete, and mult
|
|
|
8
10
|
pnpm add @better-s3/server @aws-sdk/client-s3
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
##
|
|
13
|
+
## Minimal setup
|
|
12
14
|
|
|
13
15
|
```ts
|
|
14
|
-
// app/api/s3/[...s3]/route.ts (Next.js App Router)
|
|
15
16
|
import { createRouteHandler } from "@better-s3/server/next";
|
|
16
17
|
import { s3Config } from "@/lib/better-s3";
|
|
17
18
|
|
|
@@ -20,204 +21,19 @@ export { handler as GET, handler as POST, handler as DELETE };
|
|
|
20
21
|
```
|
|
21
22
|
|
|
22
23
|
```ts
|
|
23
|
-
// lib/better-s3.ts
|
|
24
24
|
import type { S3HandlerConfig } from "@better-s3/server";
|
|
25
25
|
import { s3, defaultBucket } from "@/lib/s3";
|
|
26
26
|
|
|
27
27
|
export const s3Config = {
|
|
28
|
-
s3
|
|
29
|
-
defaultBucket
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
upload: {
|
|
36
|
-
enabled: true,
|
|
37
|
-
onUploadConfirmed: async ({ key, contentLength }) => {
|
|
38
|
-
// await db.file.create({ data: { key, contentLength } });
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
|
|
42
|
-
download: { enabled: true },
|
|
43
|
-
|
|
44
|
-
delete: {
|
|
45
|
-
enabled: true,
|
|
46
|
-
onDeleted: async ({ key }) => {
|
|
47
|
-
// await db.file.delete({ where: { key } });
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
// multipart: { enabled: true },
|
|
28
|
+
s3,
|
|
29
|
+
defaultBucket,
|
|
30
|
+
upload: { enabled: true },
|
|
31
|
+
download: { enabled: false },
|
|
32
|
+
delete: { enabled: false },
|
|
33
|
+
multipart: { enabled: false },
|
|
52
34
|
} satisfies S3HandlerConfig;
|
|
53
35
|
```
|
|
54
36
|
|
|
55
|
-
Mount the catch-all route at `app/api/s3/[...s3]/route.ts` (default base path `/api/s3`). For a custom path, pass the same value to `createRouteHandler(config, basePath)` and `createS3Api(basePath)` from `@better-s3/core`.
|
|
56
|
-
|
|
57
|
-
`resolveObjectAcl` is optional and defaults to `false`.
|
|
58
|
-
When disabled, confirmation responses omit `acl` (unknown state).
|
|
59
|
-
|
|
60
|
-
Other frameworks via `createRouter`:
|
|
61
|
-
|
|
62
|
-
```ts
|
|
63
|
-
import { createRouter } from "@better-s3/server";
|
|
64
|
-
|
|
65
|
-
const router = createRouter(s3Config);
|
|
66
|
-
app.all("/api/s3/*", (c) => router(c.req.raw)); // Hono example
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
## Enabling endpoints
|
|
70
|
-
|
|
71
|
-
All endpoints are **disabled by default** — set `enabled: true` inside each section to opt in. Disabled endpoints respond with `404`.
|
|
72
|
-
|
|
73
|
-
```ts
|
|
74
|
-
upload: { enabled: true } // POST /presign/upload + POST /presign/upload/confirm
|
|
75
|
-
download: { enabled: true } // GET /presign/download
|
|
76
|
-
delete: { enabled: true } // DELETE /delete
|
|
77
|
-
multipart: { enabled: true } // POST /presign/multipart/{init,part,complete,abort}
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
| Upload mode | Enforcement |
|
|
81
|
-
| ----------- | --------------------------------------------------------------------------------------------------------------- |
|
|
82
|
-
| Simple | S3 enforces exact size via `content-length-range` in the signed POST policy — tamperproof |
|
|
83
|
-
| Multipart | `HeadObject` after complete verifies the final object size; no per-part enforcement at the S3 level (see below) |
|
|
84
|
-
|
|
85
|
-
> Simple upload is inherently more secure — enforcement happens at the S3 storage layer. Multipart presigned `UploadPart` URLs cannot enforce per-part size; see [Multipart cost attacks](#multipart-cost-attacks).
|
|
86
|
-
|
|
87
|
-
## Hooks
|
|
88
|
-
|
|
89
|
-
Run server-side logic at key points. Every hook receives the `Request` object. **Throw to reject** — returned as `{ message }` with status 403, or any status you set on the thrown error.
|
|
90
|
-
|
|
91
|
-
```
|
|
92
|
-
Simple: upload.presignGuard → upload.onPresigned → [S3] → upload.confirmGuard → upload.onUploadConfirmed
|
|
93
|
-
Multipart: multipart.initGuard → multipart.onInit
|
|
94
|
-
multipart.partGuard → [S3]
|
|
95
|
-
multipart.completeGuard → HeadObject → multipart.onComplete
|
|
96
|
-
multipart.abortGuard → multipart.onAbort
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
### Authentication
|
|
100
|
-
|
|
101
|
-
```ts
|
|
102
|
-
guard: async ({ request }) => {
|
|
103
|
-
const session = await getSession(request);
|
|
104
|
-
if (!session) throw Object.assign(new Error("Unauthorized"), { status: 401 });
|
|
105
|
-
},
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
### Quota check
|
|
109
|
-
|
|
110
|
-
`fileSize` in `upload.presignGuard` and `multipart.initGuard` is **declared by the client** — use it for pre-checks. `contentLength` in `onUploadConfirmed` / `multipart.onComplete` is verified by S3.
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
upload: {
|
|
114
|
-
enabled: true,
|
|
115
|
-
guard: async ({ request, fileSize }) => {
|
|
116
|
-
const { userId } = await getSession(request);
|
|
117
|
-
const used = await db.storage.getUsed(userId);
|
|
118
|
-
if (fileSize && used + fileSize > QUOTA)
|
|
119
|
-
throw Object.assign(new Error("Quota exceeded"), { status: 403 });
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
## Multipart cost attacks
|
|
125
|
-
|
|
126
|
-
S3 presigned `UploadPart` URLs cannot enforce per-part size. A malicious client can upload oversized parts — the post-complete `HeadObject` check will catch and delete the final object, but temporary part storage still accumulates cost. Without completion, orphaned parts stay on AWS S3 indefinitely (R2 cleans up after 7 days).
|
|
127
|
-
|
|
128
|
-
**Mitigation: track uploads in a database + cron cleanup**
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
multipart: {
|
|
132
|
-
enabled: true,
|
|
133
|
-
// Guard runs on init, part, complete, and abort.
|
|
134
|
-
// fileSize is only present during init — use it to limit open sessions.
|
|
135
|
-
guard: async ({ request, fileSize }) => {
|
|
136
|
-
if (!fileSize) return;
|
|
137
|
-
const { userId } = await getSession(request);
|
|
138
|
-
const pending = await db.upload.count({ where: { userId, status: "pending" } });
|
|
139
|
-
if (pending >= 3)
|
|
140
|
-
throw Object.assign(new Error("Too many pending uploads"), { status: 429 });
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
onInit: async ({ request, key, uploadId, contentType, fileSize }) => {
|
|
144
|
-
const { userId } = await getSession(request);
|
|
145
|
-
await db.upload.create({
|
|
146
|
-
data: { key, userId, uploadId, contentType, declaredSize: fileSize, status: "pending" },
|
|
147
|
-
});
|
|
148
|
-
},
|
|
149
|
-
|
|
150
|
-
onComplete: async ({ key, contentLength, contentType, eTag }) => {
|
|
151
|
-
await db.upload.update({
|
|
152
|
-
where: { key },
|
|
153
|
-
data: { status: "complete", verifiedSize: contentLength, contentType, eTag },
|
|
154
|
-
});
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
onAbort: async ({ key }) => {
|
|
158
|
-
await db.upload.update({ where: { key }, data: { status: "aborted" } });
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
}
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
**Cron job** — abort stale uploads and release S3 part storage:
|
|
165
|
-
|
|
166
|
-
```ts
|
|
167
|
-
// Runs every hour
|
|
168
|
-
const stale = await db.upload.findMany({
|
|
169
|
-
where: {
|
|
170
|
-
status: "pending",
|
|
171
|
-
createdAt: { lt: new Date(Date.now() - 3_600_000) },
|
|
172
|
-
},
|
|
173
|
-
});
|
|
174
|
-
for (const upload of stale) {
|
|
175
|
-
await s3
|
|
176
|
-
.send(
|
|
177
|
-
new AbortMultipartUploadCommand({
|
|
178
|
-
Bucket: defaultBucket,
|
|
179
|
-
Key: upload.key,
|
|
180
|
-
UploadId: upload.uploadId,
|
|
181
|
-
}),
|
|
182
|
-
)
|
|
183
|
-
.catch(() => {});
|
|
184
|
-
await db.upload.update({
|
|
185
|
-
where: { key: upload.key },
|
|
186
|
-
data: { status: "expired" },
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
**S3 lifecycle rule** — safety net for anything the cron misses (e.g. crash before `onInit`):
|
|
192
|
-
|
|
193
|
-
```json
|
|
194
|
-
{
|
|
195
|
-
"Rules": [
|
|
196
|
-
{
|
|
197
|
-
"ID": "abort-incomplete-multipart",
|
|
198
|
-
"Status": "Enabled",
|
|
199
|
-
"Filter": { "Prefix": "" },
|
|
200
|
-
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
|
|
201
|
-
}
|
|
202
|
-
]
|
|
203
|
-
}
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
> Also rate-limit `/presign/multipart/part` per user/IP at the application layer.
|
|
207
|
-
|
|
208
|
-
## API Routes
|
|
209
|
-
|
|
210
|
-
| Method | Path | Description |
|
|
211
|
-
| -------- | ----------------------------- | -------------------------------- |
|
|
212
|
-
| `POST` | `/presign/upload` | Presigned POST for direct upload |
|
|
213
|
-
| `POST` | `/presign/upload/confirm` | Confirm upload via HeadObject |
|
|
214
|
-
| `GET` | `/presign/download` | Presigned download URL |
|
|
215
|
-
| `DELETE` | `/delete` | Delete object |
|
|
216
|
-
| `POST` | `/presign/multipart/init` | Init multipart upload |
|
|
217
|
-
| `POST` | `/presign/multipart/part` | Sign a part URL |
|
|
218
|
-
| `POST` | `/presign/multipart/complete` | Complete + verify |
|
|
219
|
-
| `POST` | `/presign/multipart/abort` | Abort multipart |
|
|
220
|
-
|
|
221
37
|
## License
|
|
222
38
|
|
|
223
39
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-s3/server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1051.0",
|
|
4
4
|
"description": "Framework-agnostic S3 server handlers — presigned uploads, downloads, deletes, and multipart operations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"s3",
|
|
@@ -42,15 +42,15 @@
|
|
|
42
42
|
"dist"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@aws-sdk/s3-presigned-post": "^3.
|
|
46
|
-
"@aws-sdk/s3-request-presigner": "^3.
|
|
47
|
-
"@better-s3/core": "3.
|
|
45
|
+
"@aws-sdk/s3-presigned-post": "^3.1068.0",
|
|
46
|
+
"@aws-sdk/s3-request-presigner": "^3.1068.0",
|
|
47
|
+
"@better-s3/core": "3.1051.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@aws-sdk/client-s3": ">=3"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@aws-sdk/client-s3": "^3.
|
|
53
|
+
"@aws-sdk/client-s3": "^3.1068.0",
|
|
54
54
|
"tsc-alias": "^1.8.17",
|
|
55
55
|
"tsup": "^8.5.1",
|
|
56
56
|
"typescript": "6.0.3"
|