@better-s3/server 3.2.0 → 3.2.1
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 +70 -124
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,119 +12,66 @@ pnpm add @better-s3/server @aws-sdk/client-s3
|
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
14
|
// app/api/s3/[...s3]/route.ts (Next.js App Router)
|
|
15
|
-
import { S3Client } from "@aws-sdk/client-s3";
|
|
16
15
|
import { createRouteHandler } from "@better-s3/server/next";
|
|
16
|
+
import { s3Config } from "@/lib/better-s3";
|
|
17
17
|
|
|
18
|
-
const handler = createRouteHandler(
|
|
19
|
-
s3: new S3Client({ region: "us-east-1" }),
|
|
20
|
-
defaultBucket: "my-bucket",
|
|
21
|
-
basePath: "/api/s3",
|
|
22
|
-
|
|
23
|
-
// Enable only the features your app needs. All are disabled by default.
|
|
24
|
-
features: {
|
|
25
|
-
upload: true,
|
|
26
|
-
download: true,
|
|
27
|
-
delete: true,
|
|
28
|
-
// multipart: true, // enable only if you explicitly need multipart support
|
|
29
|
-
},
|
|
30
|
-
});
|
|
31
|
-
|
|
18
|
+
const handler = createRouteHandler(s3Config);
|
|
32
19
|
export { handler as GET, handler as POST, handler as DELETE };
|
|
33
20
|
```
|
|
34
21
|
|
|
35
|
-
Other frameworks via `createRouter` from `@better-s3/server`:
|
|
36
|
-
|
|
37
22
|
```ts
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
23
|
+
// lib/better-s3.ts
|
|
24
|
+
import type { S3RouteHandlerConfig } from "@better-s3/server";
|
|
25
|
+
import { s3, defaultBucket, resolvePublicUrl } from "@/lib/s3";
|
|
26
|
+
|
|
27
|
+
export const s3Config = {
|
|
28
|
+
s3: s3,
|
|
29
|
+
defaultBucket: defaultBucket,
|
|
30
|
+
resolvePublicUrl: resolvePublicUrl,
|
|
41
31
|
basePath: "/api/s3",
|
|
42
|
-
features: { upload: true, download: true, delete: true, multipart: true },
|
|
43
|
-
hooks: {
|
|
44
|
-
guard: async ({ request }) => {
|
|
45
|
-
console.log("[guard]", request.method, request.url);
|
|
46
|
-
},
|
|
47
32
|
|
|
48
|
-
|
|
49
|
-
guard: async ({ key, bucket, contentType, fileSize }) => {
|
|
50
|
-
console.log("[upload.guard]", { key, bucket, contentType, fileSize });
|
|
51
|
-
},
|
|
52
|
-
onPresigned: async ({ key, url, expiresIn }) => {
|
|
53
|
-
console.log("[upload.onPresigned]", { key, url, expiresIn });
|
|
54
|
-
},
|
|
55
|
-
onUploaded: async ({ key, contentType, contentLength, eTag }) => {
|
|
56
|
-
console.log("[upload.onUploaded]", {
|
|
57
|
-
key,
|
|
58
|
-
contentType,
|
|
59
|
-
contentLength,
|
|
60
|
-
eTag,
|
|
61
|
-
});
|
|
62
|
-
},
|
|
63
|
-
},
|
|
33
|
+
// guard: async ({ request }) => { ... },
|
|
64
34
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
onPresigned: async ({ key, url, expiresIn }) => {
|
|
70
|
-
console.log("[download.onPresigned]", { key, url, expiresIn });
|
|
71
|
-
},
|
|
35
|
+
upload: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
onUploaded: async ({ key, contentLength }) => {
|
|
38
|
+
// await db.file.create({ data: { key, contentLength } });
|
|
72
39
|
},
|
|
40
|
+
},
|
|
73
41
|
|
|
74
|
-
|
|
75
|
-
guard: async ({ key, bucket }) => {
|
|
76
|
-
console.log("[delete.guard]", { key, bucket });
|
|
77
|
-
},
|
|
78
|
-
onDeleted: async ({ key, bucket }) => {
|
|
79
|
-
console.log("[delete.onDeleted]", { key, bucket });
|
|
80
|
-
},
|
|
81
|
-
},
|
|
42
|
+
download: { enabled: true },
|
|
82
43
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
onInit: async ({ key, uploadId, contentType, fileSize }) => {
|
|
88
|
-
console.log("[multipart.onInit]", {
|
|
89
|
-
key,
|
|
90
|
-
uploadId,
|
|
91
|
-
contentType,
|
|
92
|
-
fileSize,
|
|
93
|
-
});
|
|
94
|
-
},
|
|
95
|
-
onComplete: async ({ key, uploadId, contentLength, eTag }) => {
|
|
96
|
-
console.log("[multipart.onComplete]", {
|
|
97
|
-
key,
|
|
98
|
-
uploadId,
|
|
99
|
-
contentLength,
|
|
100
|
-
eTag,
|
|
101
|
-
});
|
|
102
|
-
},
|
|
103
|
-
onAbort: async ({ key, uploadId }) => {
|
|
104
|
-
console.log("[multipart.onAbort]", { key, uploadId });
|
|
105
|
-
},
|
|
44
|
+
delete: {
|
|
45
|
+
enabled: true,
|
|
46
|
+
onDeleted: async ({ key }) => {
|
|
47
|
+
// await db.file.delete({ where: { key } });
|
|
106
48
|
},
|
|
107
49
|
},
|
|
108
|
-
});
|
|
109
50
|
|
|
51
|
+
// multipart: { enabled: true },
|
|
52
|
+
} satisfies S3RouteHandlerConfig;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Other frameworks via `createRouter`:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { createRouter } from "@better-s3/server";
|
|
59
|
+
|
|
60
|
+
const router = createRouter(s3Config);
|
|
110
61
|
app.all("/api/s3/*", (c) => router(c.req.raw)); // Hono example
|
|
111
62
|
```
|
|
112
63
|
|
|
113
|
-
##
|
|
64
|
+
## Enabling endpoints
|
|
114
65
|
|
|
115
|
-
All endpoints are **disabled by default
|
|
66
|
+
All endpoints are **disabled by default** — set `enabled: true` inside each section to opt in. Disabled endpoints respond with `404`.
|
|
116
67
|
|
|
117
68
|
```ts
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
multipart: true, // POST /presign/multipart/{init,part,complete,abort}
|
|
123
|
-
}
|
|
69
|
+
upload: { enabled: true } // POST /presign/upload + POST /presign/upload/confirm
|
|
70
|
+
download: { enabled: true } // GET /presign/download
|
|
71
|
+
delete: { enabled: true } // DELETE /delete
|
|
72
|
+
multipart: { enabled: true } // POST /presign/multipart/{init,part,complete,abort}
|
|
124
73
|
```
|
|
125
74
|
|
|
126
|
-
> Disabled endpoints respond with `404 Not Found`.
|
|
127
|
-
|
|
128
75
|
| Upload mode | Enforcement |
|
|
129
76
|
| ----------- | --------------------------------------------------------------------------------------------------------------- |
|
|
130
77
|
| Simple | S3 enforces exact size via `content-length-range` in the signed POST policy — tamperproof |
|
|
@@ -132,7 +79,7 @@ features: {
|
|
|
132
79
|
|
|
133
80
|
> 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).
|
|
134
81
|
|
|
135
|
-
##
|
|
82
|
+
## Hooks
|
|
136
83
|
|
|
137
84
|
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.
|
|
138
85
|
|
|
@@ -147,12 +94,10 @@ Multipart: multipart.guard(init) → multipart.onInit
|
|
|
147
94
|
### Authentication
|
|
148
95
|
|
|
149
96
|
```ts
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
},
|
|
155
|
-
}
|
|
97
|
+
guard: async ({ request }) => {
|
|
98
|
+
const session = await getSession(request);
|
|
99
|
+
if (!session) throw Object.assign(new Error("Unauthorized"), { status: 401 });
|
|
100
|
+
},
|
|
156
101
|
```
|
|
157
102
|
|
|
158
103
|
### Quota check
|
|
@@ -160,18 +105,19 @@ hooks: {
|
|
|
160
105
|
`fileSize` in `upload.guard` and `multipart.guard` (init only) is **declared by the client** — use it for pre-checks. `contentLength` in `onUploaded` / `multipart.onComplete` is verified by S3.
|
|
161
106
|
|
|
162
107
|
```ts
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
},
|
|
108
|
+
upload: {
|
|
109
|
+
enabled: true,
|
|
110
|
+
guard: async ({ request, fileSize }) => {
|
|
111
|
+
const { userId } = await getSession(request);
|
|
112
|
+
const used = await db.storage.getUsed(userId);
|
|
113
|
+
if (fileSize && used + fileSize > QUOTA)
|
|
114
|
+
throw Object.assign(new Error("Quota exceeded"), { status: 403 });
|
|
171
115
|
},
|
|
172
|
-
}
|
|
116
|
+
},
|
|
173
117
|
```
|
|
174
118
|
|
|
119
|
+
````
|
|
120
|
+
|
|
175
121
|
## Multipart cost attacks
|
|
176
122
|
|
|
177
123
|
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).
|
|
@@ -179,24 +125,24 @@ S3 presigned `UploadPart` URLs cannot enforce per-part size. A malicious client
|
|
|
179
125
|
**Mitigation: track uploads in a database + cron cleanup**
|
|
180
126
|
|
|
181
127
|
```ts
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
128
|
+
multipart: {
|
|
129
|
+
enabled: true,
|
|
130
|
+
// Guard runs on init, part, complete, and abort.
|
|
131
|
+
// fileSize is only present during init — use it to limit open sessions.
|
|
132
|
+
guard: async ({ request, fileSize }) => {
|
|
133
|
+
if (!fileSize) return;
|
|
134
|
+
const { userId } = await getSession(request);
|
|
135
|
+
const pending = await db.upload.count({ where: { userId, status: "pending" } });
|
|
136
|
+
if (pending >= 3)
|
|
137
|
+
throw Object.assign(new Error("Too many pending uploads"), { status: 429 });
|
|
138
|
+
},
|
|
193
139
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
140
|
+
onInit: async ({ request, key, uploadId, contentType, fileSize }) => {
|
|
141
|
+
const { userId } = await getSession(request);
|
|
142
|
+
await db.upload.create({
|
|
143
|
+
data: { key, userId, uploadId, contentType, declaredSize: fileSize, status: "pending" },
|
|
144
|
+
});
|
|
145
|
+
},
|
|
200
146
|
|
|
201
147
|
onComplete: async ({ key, contentLength, contentType, eTag }) => {
|
|
202
148
|
await db.upload.update({
|
|
@@ -210,7 +156,7 @@ hooks: {
|
|
|
210
156
|
},
|
|
211
157
|
},
|
|
212
158
|
}
|
|
213
|
-
|
|
159
|
+
````
|
|
214
160
|
|
|
215
161
|
**Cron job** — abort stale uploads and release S3 part storage:
|
|
216
162
|
|