@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.
Files changed (2) hide show
  1. package/README.md +70 -124
  2. 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
- const router = createRouter({
39
- s3,
40
- defaultBucket: "my-bucket",
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
- upload: {
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
- download: {
66
- guard: async ({ key, bucket, fileName }) => {
67
- console.log("[download.guard]", { key, bucket, fileName });
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
- delete: {
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
- multipart: {
84
- guard: async ({ key, bucket, fileSize }) => {
85
- console.log("[multipart.guard]", { key, bucket, fileSize });
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
- ## Features
64
+ ## Enabling endpoints
114
65
 
115
- All endpoints are **disabled by default**. You must explicitly opt in via the `features` config this prevents unintended exposure of expensive or sensitive operations (especially multipart, which is vulnerable to [cost attacks](#multipart-cost-attacks)).
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
- features: {
119
- upload: true, // POST /presign/upload + POST /presign/upload/confirm
120
- download: true, // GET /presign/download
121
- delete: true, // DELETE /delete
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
- ## Server Hooks
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
- hooks: {
151
- guard: async ({ request }) => {
152
- const session = await getSession(request);
153
- if (!session) throw Object.assign(new Error("Unauthorized"), { status: 401 });
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
- hooks: {
164
- upload: {
165
- guard: async ({ request, fileSize }) => {
166
- const { userId } = await getSession(request);
167
- const used = await db.storage.getUsed(userId);
168
- if (fileSize && used + fileSize > QUOTA)
169
- throw Object.assign(new Error("Quota exceeded"), { status: 403 });
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
- hooks: {
183
- multipart: {
184
- // Guard runs on init, part, complete, and abort.
185
- // fileSize is only present during init — use it to limit open sessions.
186
- guard: async ({ request, fileSize }) => {
187
- if (!fileSize) return;
188
- const { userId } = await getSession(request);
189
- const pending = await db.upload.count({ where: { userId, status: "pending" } });
190
- if (pending >= 3)
191
- throw Object.assign(new Error("Too many pending uploads"), { status: 429 });
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
- onInit: async ({ request, key, uploadId, contentType, fileSize }) => {
195
- const { userId } = await getSession(request);
196
- await db.upload.create({
197
- data: { key, userId, uploadId, contentType, declaredSize: fileSize, status: "pending" },
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-s3/server",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "Framework-agnostic S3 server handlers — presigned uploads, downloads, deletes, and multipart operations",
5
5
  "keywords": [
6
6
  "s3",