@dimah-s3/server 1.0.2 → 1.1.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 (100) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/express.js +16 -9
  3. package/dist/adapters/express.js.map +1 -1
  4. package/dist/adapters/fastify.js +16 -9
  5. package/dist/adapters/fastify.js.map +1 -1
  6. package/dist/adapters/hono.d.ts +1 -1
  7. package/dist/adapters/hono.js.map +1 -1
  8. package/dist/adapters/node.d.ts +1 -1
  9. package/dist/adapters/node.js +16 -9
  10. package/dist/adapters/node.js.map +1 -1
  11. package/dist/api/index.js +742 -388
  12. package/dist/api/index.js.map +1 -1
  13. package/dist/api/router.d.ts.map +1 -1
  14. package/dist/api/routes/catalog.d.ts +9 -0
  15. package/dist/api/routes/catalog.d.ts.map +1 -0
  16. package/dist/api/routes/confirm.d.ts +3 -4
  17. package/dist/api/routes/confirm.d.ts.map +1 -1
  18. package/dist/api/routes/delete-batch.d.ts +14 -0
  19. package/dist/api/routes/delete-batch.d.ts.map +1 -0
  20. package/dist/api/routes/delete.d.ts +2 -2
  21. package/dist/api/routes/delete.d.ts.map +1 -1
  22. package/dist/api/routes/download.d.ts +8 -8
  23. package/dist/api/routes/download.d.ts.map +1 -1
  24. package/dist/api/routes/file.d.ts +20 -0
  25. package/dist/api/routes/file.d.ts.map +1 -0
  26. package/dist/api/routes/index.d.ts +76 -50
  27. package/dist/api/routes/index.d.ts.map +1 -1
  28. package/dist/api/routes/multipart/abort.d.ts +2 -2
  29. package/dist/api/routes/multipart/abort.d.ts.map +1 -1
  30. package/dist/api/routes/multipart/complete.d.ts +2 -2
  31. package/dist/api/routes/multipart/complete.d.ts.map +1 -1
  32. package/dist/api/routes/multipart/init.d.ts +10 -12
  33. package/dist/api/routes/multipart/init.d.ts.map +1 -1
  34. package/dist/api/routes/multipart/list-parts.d.ts +2 -2
  35. package/dist/api/routes/multipart/list-parts.d.ts.map +1 -1
  36. package/dist/api/routes/multipart/part.d.ts +4 -6
  37. package/dist/api/routes/multipart/part.d.ts.map +1 -1
  38. package/dist/api/routes/upload.d.ts +10 -14
  39. package/dist/api/routes/upload.d.ts.map +1 -1
  40. package/dist/dimah-s3.d.ts +19 -8
  41. package/dist/dimah-s3.d.ts.map +1 -1
  42. package/dist/errors.d.ts +5 -4
  43. package/dist/errors.d.ts.map +1 -1
  44. package/dist/helpers/best-effort.d.ts +6 -0
  45. package/dist/helpers/best-effort.d.ts.map +1 -0
  46. package/dist/helpers/constraints.d.ts +16 -0
  47. package/dist/helpers/constraints.d.ts.map +1 -0
  48. package/dist/helpers/expires.d.ts +2 -2
  49. package/dist/helpers/features.d.ts +12 -29
  50. package/dist/helpers/features.d.ts.map +1 -1
  51. package/dist/helpers/finalize-confirmed.d.ts +19 -0
  52. package/dist/helpers/finalize-confirmed.d.ts.map +1 -0
  53. package/dist/helpers/head-object.d.ts.map +1 -1
  54. package/dist/helpers/hooks.d.ts +7 -1
  55. package/dist/helpers/hooks.d.ts.map +1 -1
  56. package/dist/helpers/index.d.ts +12 -9
  57. package/dist/helpers/index.d.ts.map +1 -1
  58. package/dist/helpers/list-parts.d.ts +5 -0
  59. package/dist/helpers/list-parts.d.ts.map +1 -1
  60. package/dist/helpers/logger.d.ts +4 -0
  61. package/dist/helpers/logger.d.ts.map +1 -0
  62. package/dist/helpers/previous-key.d.ts +5 -0
  63. package/dist/helpers/previous-key.d.ts.map +1 -0
  64. package/dist/helpers/resolve-route.d.ts +36 -0
  65. package/dist/helpers/resolve-route.d.ts.map +1 -0
  66. package/dist/helpers/resolve-target.d.ts +25 -14
  67. package/dist/helpers/resolve-target.d.ts.map +1 -1
  68. package/dist/index.d.ts +7 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1017 -477
  71. package/dist/index.js.map +1 -1
  72. package/dist/plugin/apply-plugins.d.ts +9 -4
  73. package/dist/plugin/apply-plugins.d.ts.map +1 -1
  74. package/dist/plugin/hook-registry.d.ts +13 -4
  75. package/dist/plugin/hook-registry.d.ts.map +1 -1
  76. package/dist/plugin/index.d.ts +1 -2
  77. package/dist/plugin/index.d.ts.map +1 -1
  78. package/dist/plugin/types.d.ts +12 -7
  79. package/dist/plugin/types.d.ts.map +1 -1
  80. package/dist/plugins.d.ts +5 -3
  81. package/dist/plugins.d.ts.map +1 -1
  82. package/dist/plugins.js +951 -475
  83. package/dist/plugins.js.map +1 -1
  84. package/dist/put.d.ts +15 -0
  85. package/dist/put.d.ts.map +1 -0
  86. package/dist/route.d.ts +13 -0
  87. package/dist/route.d.ts.map +1 -0
  88. package/dist/types/config.d.ts +190 -95
  89. package/dist/types/config.d.ts.map +1 -1
  90. package/dist/types/hook-contexts.d.ts +124 -87
  91. package/dist/types/hook-contexts.d.ts.map +1 -1
  92. package/dist/types/index.d.ts +3 -2
  93. package/dist/types/index.d.ts.map +1 -1
  94. package/package.json +11 -11
  95. package/dist/api/assert-feature-enabled.d.ts +0 -5
  96. package/dist/api/assert-feature-enabled.d.ts.map +0 -1
  97. package/dist/helpers/resolve-object-acl.d.ts +0 -10
  98. package/dist/helpers/resolve-object-acl.d.ts.map +0 -1
  99. package/dist/helpers/resolve-request-acl.d.ts +0 -10
  100. package/dist/helpers/resolve-request-acl.d.ts.map +0 -1
package/dist/api/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createMiddleware, createEndpoint } from 'better-call';
2
- import { S3_API_ROUTES, isAPIError, DimahS3Error, S3_ERROR_CODES, confirmBodySchema, deleteQuerySchema, downloadQuerySchema, multipartAbortBodySchema, multipartCompleteBodySchema, multipartInitBodySchema, multipartListPartsQuerySchema, multipartSignPartBodySchema, uploadBodySchema, parseFileName, S3_MAX_EXPIRES_IN, buildContentDisposition, S3_MAX_POST_OBJECT_BYTES, S3_DEFAULT_EXPIRES_IN } from '@dimah-s3/core';
3
- import { DeleteObjectCommand, GetObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, PutObjectCommand, GetObjectAclCommand, ListPartsCommand, CompleteMultipartUploadCommand, HeadObjectCommand, AbortMultipartUploadCommand } from '@aws-sdk/client-s3';
2
+ import { S3_API_ROUTES, isAPIError, DimahS3Error, S3_ERROR_CODES, confirmBodySchema, deleteQuerySchema, isDimahS3Error, deleteBatchBodySchema, downloadQuerySchema, fileQuerySchema, multipartAbortBodySchema, multipartCompleteBodySchema, multipartInitBodySchema, multipartListPartsQuerySchema, multipartSignPartBodySchema, uploadBodySchema, S3_MAX_EXPIRES_IN, buildContentDisposition, S3_MAX_POST_OBJECT_BYTES, resolveStoredFileName, S3_DEFAULT_EXPIRES_IN, matchesFileTypes, normalizeObjectKey, sanitizeFileName } from '@dimah-s3/core';
3
+ import { DeleteObjectCommand, GetObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, PutObjectCommand, ListPartsCommand, AbortMultipartUploadCommand, CompleteMultipartUploadCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
4
4
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
5
5
  import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
6
6
 
@@ -21,10 +21,18 @@ var errors = {
21
21
  params: { feature }
22
22
  }),
23
23
  invalidKey: () => DimahS3Error.from("BAD_REQUEST", S3_ERROR_CODES.INVALID_KEY),
24
- invalidBucket: (bucket) => DimahS3Error.from("FORBIDDEN", {
25
- code: S3_ERROR_CODES.INVALID_BUCKET.code,
26
- message: S3_ERROR_CODES.INVALID_BUCKET.message,
27
- params: { bucket }
24
+ unknownRoute: (route) => DimahS3Error.from("NOT_FOUND", {
25
+ code: S3_ERROR_CODES.UNKNOWN_ROUTE.code,
26
+ message: S3_ERROR_CODES.UNKNOWN_ROUTE.message,
27
+ params: { route }
28
+ }),
29
+ fileTypeNotAllowed: (fileName, contentType) => DimahS3Error.from("BAD_REQUEST", {
30
+ code: S3_ERROR_CODES.FILE_TYPE_NOT_ALLOWED.code,
31
+ message: S3_ERROR_CODES.FILE_TYPE_NOT_ALLOWED.message,
32
+ params: {
33
+ fileName,
34
+ ...contentType ? { contentType } : {}
35
+ }
28
36
  }),
29
37
  s3NetworkError: (code) => DimahS3Error.from("BAD_GATEWAY", {
30
38
  code: S3_ERROR_CODES.S3_NETWORK_ERROR.code,
@@ -34,11 +42,6 @@ var errors = {
34
42
  ),
35
43
  params: { code }
36
44
  }),
37
- fileSizeRequiredUpload: () => DimahS3Error.from("BAD_REQUEST", S3_ERROR_CODES.FILE_SIZE_REQUIRED_UPLOAD),
38
- fileSizeRequiredMultipart: () => DimahS3Error.from(
39
- "BAD_REQUEST",
40
- S3_ERROR_CODES.FILE_SIZE_REQUIRED_MULTIPART
41
- ),
42
45
  multipartPartMissing: (partNumber) => DimahS3Error.from("BAD_REQUEST", {
43
46
  code: S3_ERROR_CODES.MULTIPART_PART_MISSING.code,
44
47
  message: S3_ERROR_CODES.MULTIPART_PART_MISSING.message.replaceAll(
@@ -51,11 +54,33 @@ var errors = {
51
54
  ...S3_ERROR_CODES.PAYLOAD_TOO_LARGE,
52
55
  message
53
56
  }),
57
+ quotaExceeded: (message = S3_ERROR_CODES.QUOTA_EXCEEDED.message) => DimahS3Error.from("PAYLOAD_TOO_LARGE", {
58
+ ...S3_ERROR_CODES.QUOTA_EXCEEDED,
59
+ message
60
+ }),
54
61
  validationError: (message) => DimahS3Error.from("BAD_REQUEST", {
55
62
  code: S3_ERROR_CODES.VALIDATION_ERROR.code,
56
63
  message
57
64
  })
58
65
  };
66
+ async function deleteObjectBestEffort(client, bucket, key) {
67
+ try {
68
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
69
+ } catch {
70
+ }
71
+ }
72
+ async function abortMultipartBestEffort(client, bucket, key, uploadId) {
73
+ try {
74
+ await client.send(
75
+ new AbortMultipartUploadCommand({
76
+ Bucket: bucket,
77
+ Key: key,
78
+ UploadId: uploadId
79
+ })
80
+ );
81
+ } catch {
82
+ }
83
+ }
59
84
 
60
85
  // src/helpers/is-aws-not-found.ts
61
86
  function isAwsNotFound(err) {
@@ -70,119 +95,238 @@ async function sendOrObjectNotFound(send) {
70
95
  throw err;
71
96
  }
72
97
  }
73
- var DEFAULT_ACL_LOOKUP_TIMEOUT_MS = 1500;
74
- async function resolveObjectAcl(s3, bucket, key, timeoutMs = DEFAULT_ACL_LOOKUP_TIMEOUT_MS) {
75
- const controller = new AbortController();
76
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
77
- try {
78
- const result = await s3.send(
79
- new GetObjectAclCommand({ Bucket: bucket, Key: key }),
80
- { abortSignal: controller.signal }
81
- );
82
- const isPublic = result.Grants?.some(
83
- (g) => g.Grantee?.URI === "http://acs.amazonaws.com/groups/global/AllUsers" && (g.Permission === "READ" || g.Permission === "FULL_CONTROL")
84
- );
85
- return isPublic ? "public-read" : "private";
86
- } catch {
87
- return void 0;
88
- } finally {
89
- clearTimeout(timeout);
90
- }
91
- }
92
-
93
- // src/helpers/resolve-request-acl.ts
94
- function resolveRequestAcl(policy, requested) {
95
- if (policy?.acl) return policy.acl;
96
- if (policy?.allowClientAcl && requested) return requested;
97
- return "private";
98
- }
99
98
  function normalizeExpiresIn(value, maxExpiresIn = S3_MAX_EXPIRES_IN) {
100
99
  const n = Number(value);
101
100
  const expires = Number.isFinite(n) && n > 0 ? Math.floor(n) : S3_DEFAULT_EXPIRES_IN;
102
101
  const max = Number.isFinite(maxExpiresIn) && maxExpiresIn > 0 ? Math.floor(maxExpiresIn) : S3_MAX_EXPIRES_IN;
103
102
  return Math.min(expires, max);
104
103
  }
104
+
105
+ // src/helpers/logger.ts
106
+ var defaultLogger = {
107
+ error: (message, ...args) => {
108
+ console.error("[dimah-s3]", message, ...args);
109
+ },
110
+ warn: (message, ...args) => {
111
+ console.warn("[dimah-s3]", message, ...args);
112
+ }
113
+ };
114
+ function resolveLogger(logger) {
115
+ if (logger === false) return {};
116
+ if (!logger) return defaultLogger;
117
+ return logger;
118
+ }
119
+
120
+ // src/helpers/hooks.ts
121
+ function mapGuardError(err) {
122
+ if (isAPIError(err)) throw err;
123
+ if (err instanceof Error && err.message.trim()) {
124
+ throw new DimahS3Error("FORBIDDEN", {
125
+ message: err.message,
126
+ cause: err
127
+ });
128
+ }
129
+ throw errors.forbidden();
130
+ }
105
131
  async function runHook(hook, context) {
106
132
  if (!hook) return;
107
133
  try {
108
134
  await hook(context);
109
135
  } catch (err) {
110
- if (isAPIError(err)) throw err;
111
- if (err instanceof Error && err.message.trim()) {
112
- throw new DimahS3Error("FORBIDDEN", {
113
- message: err.message,
114
- cause: err
115
- });
116
- }
117
- throw errors.forbidden();
136
+ mapGuardError(err);
137
+ }
138
+ }
139
+ async function runObjectHook(hook, context) {
140
+ if (!hook) return;
141
+ try {
142
+ return await hook(context);
143
+ } catch (err) {
144
+ mapGuardError(err);
118
145
  }
119
146
  }
120
- async function runLifecycleHook(hook, context) {
147
+ async function runLifecycleHook(hook, context, config) {
121
148
  if (!hook) return;
122
149
  try {
123
150
  await hook(context);
124
151
  } catch (err) {
125
152
  if (isAPIError(err)) throw err;
126
- console.error("[S3 API] lifecycle hook failed", err);
153
+ resolveLogger(config?.logger).error?.("lifecycle hook failed", err);
154
+ config?.onError?.(err, { request: context.request });
127
155
  throw errors.internalError();
128
156
  }
129
157
  }
130
158
 
131
- // src/helpers/request.ts
132
- function requestFromHeaders(headers) {
133
- return new Request("http://dimah-s3.local", { headers });
159
+ // src/helpers/previous-key.ts
160
+ var DIMAH_PREVIOUS_KEY_META = "dimah-previous-key";
161
+ function stripPreviousKeyMeta(metadata) {
162
+ if (!metadata) return {};
163
+ const { [DIMAH_PREVIOUS_KEY_META]: _omit, ...rest } = metadata;
164
+ return rest;
165
+ }
166
+ function previousKeyFromMetadata(metadata) {
167
+ const value = metadata?.[DIMAH_PREVIOUS_KEY_META]?.trim();
168
+ return value || void 0;
134
169
  }
135
170
 
136
171
  // src/helpers/resolve-target.ts
137
172
  function assertSafeObjectKey(key) {
138
- const trimmed = key.replace(/^\/+/u, "").replace(/\/+$/u, "");
139
- if (!trimmed) {
140
- throw errors.invalidKey();
141
- }
142
- if (trimmed.includes("\\") || trimmed.includes("\0")) {
143
- throw errors.invalidKey();
144
- }
145
- const parts = trimmed.split("/");
146
- if (parts.some((part) => part === "" || part === "." || part === "..")) {
147
- throw errors.invalidKey();
148
- }
149
- return parts.join("/");
173
+ const normalized = normalizeObjectKey(key);
174
+ if (!normalized) throw errors.invalidKey();
175
+ return normalized;
150
176
  }
151
177
  function applyPrefix(prefix, key) {
152
178
  const p = assertSafeObjectKey(prefix);
153
179
  const k = assertSafeObjectKey(key);
154
- if (k === p || k.startsWith(`${p}/`)) return k;
180
+ if (k.startsWith(`${p}/`)) return k;
181
+ if (k === p) throw errors.invalidKey();
155
182
  return `${p}/${k}`;
156
183
  }
157
- async function resolveObjectKey(policy, context) {
158
- if (policy?.resolveKey) {
159
- return assertSafeObjectKey(await policy.resolveKey(context));
184
+ function nestKeyUnderPrefix(keyPrefix, key) {
185
+ const safe = assertSafeObjectKey(key);
186
+ if (keyPrefix === false) return safe;
187
+ return applyPrefix(keyPrefix, safe);
188
+ }
189
+ function assertStoredKey(key, keyPrefix) {
190
+ const safe = assertSafeObjectKey(key);
191
+ if (keyPrefix === false) return safe;
192
+ const root = assertSafeObjectKey(keyPrefix);
193
+ if (safe.startsWith(`${root}/`)) return safe;
194
+ throw errors.invalidKey();
195
+ }
196
+ function objectKeyFileName(fileName) {
197
+ const sanitized = sanitizeFileName(fileName);
198
+ const leaf = sanitized.split("/").filter((part) => part.length > 0).at(-1);
199
+ if (!leaf || leaf === "." || leaf === "..") return "file";
200
+ return leaf;
201
+ }
202
+ function generateObjectKey(folder, fileName) {
203
+ const leaf = `${crypto.randomUUID()}/${objectKeyFileName(fileName)}`;
204
+ if (folder === false) return assertSafeObjectKey(leaf);
205
+ return applyPrefix(folder, leaf);
206
+ }
207
+ function resolveAcl(info, route) {
208
+ return info?.acl ?? route.upload.acl ?? "private";
209
+ }
210
+ async function resolveUploadTarget(route, context) {
211
+ const bucket = route.bucket;
212
+ const objectContext = {
213
+ ...context,
214
+ bucket,
215
+ keyPrefix: route.keyPrefix
216
+ };
217
+ const info = await runObjectHook(route.upload.object, objectContext) ?? void 0;
218
+ const key = info?.key ? nestKeyUnderPrefix(route.keyPrefix, info.key) : generateObjectKey(
219
+ info?.folder != null && info.folder !== "" ? nestKeyUnderPrefix(route.keyPrefix, info.folder) : route.keyPrefix === false ? false : route.keyPrefix,
220
+ context.file.name
221
+ );
222
+ const metadata = { ...info?.metadata };
223
+ if (info?.previousKey) {
224
+ metadata[DIMAH_PREVIOUS_KEY_META] = nestKeyUnderPrefix(
225
+ route.keyPrefix,
226
+ info.previousKey
227
+ );
160
228
  }
161
- const prefix = typeof policy?.prefix === "function" ? await policy.prefix(context) : policy?.prefix;
162
- if (prefix) {
163
- return applyPrefix(prefix, context.proposedKey);
229
+ return {
230
+ key,
231
+ bucket,
232
+ metadata: Object.keys(metadata).length > 0 ? metadata : info?.metadata,
233
+ acl: resolveAcl(info, route)
234
+ };
235
+ }
236
+ function resolveStoredTarget(route, key) {
237
+ return {
238
+ key: assertStoredKey(key, route.keyPrefix),
239
+ bucket: route.bucket
240
+ };
241
+ }
242
+
243
+ // src/helpers/features.ts
244
+ function isEnabled(feature) {
245
+ return feature.enabled;
246
+ }
247
+
248
+ // src/helpers/request.ts
249
+ function requestFromHeaders(headers) {
250
+ return new Request("http://dimah-s3.local", { headers });
251
+ }
252
+ function assertWithinMaxFileSize(maxFileSize, size) {
253
+ if (typeof maxFileSize === "number" && size > maxFileSize) {
254
+ throw errors.payloadTooLarge();
164
255
  }
165
- return assertSafeObjectKey(context.proposedKey);
166
256
  }
167
- function resolveBucket(config, requested) {
168
- if (!requested) return config.bucket;
169
- if (config.allowClientBucket) return requested;
170
- if (config.buckets?.length) {
171
- if (config.buckets.includes(requested)) return requested;
172
- throw errors.invalidBucket(requested);
257
+ function assertDeclaredConstraints(constraints, input) {
258
+ if (constraints?.fileTypes?.length && !matchesFileTypes(input.fileName, input.contentType, constraints.fileTypes)) {
259
+ throw errors.fileTypeNotAllowed(input.fileName, input.contentType);
173
260
  }
174
- return config.bucket;
261
+ assertWithinMaxFileSize(constraints?.maxFileSize, input.fileSize);
175
262
  }
176
- async function resolveRequestTarget(config, policy, input) {
177
- const bucket = resolveBucket(config, input.bucket);
178
- const key = await resolveObjectKey(policy, {
179
- request: input.request,
180
- proposedKey: input.key,
181
- fileName: input.fileName,
182
- contentType: input.contentType,
183
- bucket
263
+ function assertVerifiedConstraints(constraints, input) {
264
+ assertWithinMaxFileSize(constraints?.maxFileSize, input.contentLength);
265
+ const name = input.fileName ?? "";
266
+ if (constraints?.fileTypes?.length && !matchesFileTypes(name, input.contentType, constraints.fileTypes)) {
267
+ throw errors.fileTypeNotAllowed(name || "unknown", input.contentType);
268
+ }
269
+ }
270
+
271
+ // src/helpers/resolve-route.ts
272
+ function getResolvedRoute(config, name) {
273
+ const route = config.routes[name];
274
+ if (!route) {
275
+ throw errors.unknownRoute(name);
276
+ }
277
+ return route;
278
+ }
279
+ function featureIsEnabled(route, feature) {
280
+ if (feature === "multipart") return route.upload.multipart.enabled;
281
+ return route[feature].enabled;
282
+ }
283
+ function assertFeatureEnabled(route, feature) {
284
+ if (!featureIsEnabled(route, feature)) {
285
+ throw errors.featureDisabled(feature);
286
+ }
287
+ }
288
+ async function openRoute(config, name, request, feature) {
289
+ const route = getResolvedRoute(config, name);
290
+ assertFeatureEnabled(route, feature);
291
+ await runHook(route.guard, { request, route: route.name });
292
+ return route;
293
+ }
294
+ function storedObjectContext(request, route, key) {
295
+ return { request, route: route.name, key, bucket: route.bucket };
296
+ }
297
+ async function openUploadTarget(config, input, request, feature) {
298
+ const route = await openRoute(config, input.route, request, feature);
299
+ const fileSize = Math.floor(input.fileSize);
300
+ const fileName = input.fileName;
301
+ assertDeclaredConstraints(route.upload, {
302
+ fileName,
303
+ fileSize,
304
+ contentType: input.contentType
305
+ });
306
+ const target = await resolveUploadTarget(route, {
307
+ request,
308
+ route: route.name,
309
+ file: {
310
+ name: fileName,
311
+ size: fileSize,
312
+ type: input.contentType
313
+ },
314
+ clientMetadata: input.metadata
184
315
  });
185
- return { key, bucket };
316
+ return {
317
+ route,
318
+ ...target,
319
+ stored: storedObjectContext(request, route, target.key)
320
+ };
321
+ }
322
+ async function openStoredTarget(config, input, request, feature) {
323
+ const route = await openRoute(config, input.route, request, feature);
324
+ const target = resolveStoredTarget(route, input.key);
325
+ return {
326
+ route,
327
+ ...target,
328
+ stored: storedObjectContext(request, route, target.key)
329
+ };
186
330
  }
187
331
  async function headObjectOrNotFound(client, bucket, key) {
188
332
  return sendOrObjectNotFound(
@@ -194,7 +338,7 @@ async function headObjectAfterMultipartComplete(client, bucket, key) {
194
338
  () => client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
195
339
  );
196
340
  let head = await send();
197
- for (let attempt = 0; attempt < 4 && !head.ContentLength; attempt++) {
341
+ for (let attempt = 0; attempt < 4 && typeof head.ContentLength !== "number"; attempt++) {
198
342
  await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
199
343
  head = await send();
200
344
  }
@@ -227,10 +371,70 @@ async function listAllParts(client, input) {
227
371
  }
228
372
  return parts;
229
373
  }
230
-
231
- // src/helpers/features.ts
232
- function isFeatureEnabled(config, feature) {
233
- return config[feature]?.enabled === true;
374
+ function listedPartsByteSize(parts, excludePartNumber) {
375
+ let total = 0;
376
+ for (const part of parts) {
377
+ if (excludePartNumber !== void 0 && part.PartNumber === excludePartNumber) {
378
+ continue;
379
+ }
380
+ total += part.Size ?? 0;
381
+ }
382
+ return total;
383
+ }
384
+ async function finalizeConfirmedObject(client, bucket, key, input) {
385
+ const { config, route, stored, head, uploadId, acl } = input;
386
+ const contentLength = requireContentLength(head);
387
+ const fileName = resolveStoredFileName(head.ContentDisposition, key);
388
+ const rawMetadata = head.Metadata ?? {};
389
+ try {
390
+ assertVerifiedConstraints(route.upload, {
391
+ fileName,
392
+ contentType: head.ContentType,
393
+ contentLength
394
+ });
395
+ } catch (err) {
396
+ await deleteObjectBestEffort(client, bucket, key);
397
+ throw err;
398
+ }
399
+ const metadata = stripPreviousKeyMeta(rawMetadata);
400
+ const context = {
401
+ ...stored,
402
+ contentType: head.ContentType,
403
+ contentLength,
404
+ eTag: head.ETag?.replace(/"/g, ""),
405
+ metadata,
406
+ acl,
407
+ fileName,
408
+ versionId: head.VersionId,
409
+ lastModified: head.LastModified?.toISOString(),
410
+ uploadId
411
+ };
412
+ try {
413
+ await runLifecycleHook(route.upload.onConfirmed, context, config);
414
+ } catch (err) {
415
+ await deleteObjectBestEffort(client, bucket, key);
416
+ throw err;
417
+ }
418
+ const previousKey = previousKeyFromMetadata(rawMetadata);
419
+ if (previousKey && previousKey !== key) {
420
+ try {
421
+ const safe = assertStoredKey(previousKey, route.keyPrefix);
422
+ await deleteObjectBestEffort(client, bucket, safe);
423
+ } catch {
424
+ }
425
+ }
426
+ return {
427
+ key,
428
+ bucket,
429
+ contentType: context.contentType,
430
+ contentLength: context.contentLength,
431
+ eTag: context.eTag,
432
+ metadata: context.metadata ?? {},
433
+ acl,
434
+ fileName,
435
+ versionId: context.versionId,
436
+ lastModified: context.lastModified
437
+ };
234
438
  }
235
439
 
236
440
  // src/api/create-s3-middleware.ts
@@ -277,148 +481,270 @@ var createS3Endpoint = ((pathOrOptions, optionsOrHandler, maybeHandler) => {
277
481
  optionsOrHandler
278
482
  );
279
483
  });
280
-
281
- // src/api/assert-feature-enabled.ts
282
- function assertFeatureEnabled(config, feature) {
283
- if (!isFeatureEnabled(config, feature)) {
284
- throw errors.featureDisabled(feature);
285
- }
286
- }
287
-
288
- // src/api/routes/confirm.ts
289
484
  async function handleConfirm(config, input, request) {
290
- const { key, bucket } = await resolveRequestTarget(config, config.upload, {
485
+ const { route, key, bucket, stored } = await openStoredTarget(
486
+ config,
487
+ input,
291
488
  request,
292
- key: input.key,
293
- bucket: input.bucket
489
+ "upload"
490
+ );
491
+ await runHook(route.upload.confirmGuard, {
492
+ ...stored,
493
+ replace: route.upload.replace
294
494
  });
295
- await runHook(config.upload?.confirmGuard, {
296
- request,
297
- key,
298
- bucket
495
+ const head = await headObjectOrNotFound(route.client, bucket, key);
496
+ return finalizeConfirmedObject(route.client, bucket, key, {
497
+ config,
498
+ route,
499
+ stored,
500
+ head,
501
+ acl: route.upload.acl
299
502
  });
300
- const head = await headObjectOrNotFound(config.client, bucket, key);
301
- const acl = config.resolveObjectAcl ? await resolveObjectAcl(config.client, bucket, key) : void 0;
302
- const fileName = parseFileName(head.ContentDisposition);
303
- const context = {
304
- request,
305
- key,
306
- bucket,
307
- contentType: head.ContentType,
308
- contentLength: requireContentLength(head),
309
- eTag: head.ETag?.replace(/"/g, ""),
310
- metadata: head.Metadata,
311
- acl,
312
- fileName,
313
- versionId: head.VersionId,
314
- lastModified: head.LastModified?.toISOString()
315
- };
316
- await runLifecycleHook(config.upload?.onConfirmed, context);
317
- return {
318
- key,
319
- bucket,
320
- contentType: context.contentType,
321
- contentLength: context.contentLength,
322
- eTag: context.eTag,
323
- metadata: context.metadata ?? {},
324
- acl,
325
- fileName,
326
- versionId: context.versionId,
327
- lastModified: context.lastModified
328
- };
329
503
  }
330
504
  var confirm = createS3Endpoint(
331
505
  S3_API_ROUTES.uploadConfirm,
332
506
  { method: "POST", body: confirmBodySchema },
333
507
  async (ctx) => {
334
- assertFeatureEnabled(ctx.context.config, "upload");
335
508
  return handleConfirm(ctx.context.config, ctx.body, ctx.context.request);
336
509
  }
337
510
  );
338
511
  async function handleDelete(config, input, request) {
339
- const { key, bucket } = await resolveRequestTarget(config, config.delete, {
512
+ const { route, key, bucket, stored } = await openStoredTarget(
513
+ config,
514
+ input,
340
515
  request,
341
- key: input.key,
342
- bucket: input.bucket
343
- });
344
- await runHook(config.delete?.guard, {
345
- request,
346
- key,
347
- bucket
348
- });
349
- await headObjectOrNotFound(config.client, bucket, key);
350
- await config.client.send(
516
+ "delete"
517
+ );
518
+ await runHook(route.delete.guard, stored);
519
+ await headObjectOrNotFound(route.client, bucket, key);
520
+ await route.client.send(
351
521
  new DeleteObjectCommand({ Bucket: bucket, Key: key })
352
522
  );
353
- await runLifecycleHook(config.delete?.onDeleted, { request, key, bucket });
523
+ await runLifecycleHook(route.delete.onDeleted, stored, config);
354
524
  return { success: true, bucket, key };
355
525
  }
356
526
  var deleteObject = createS3Endpoint(
357
527
  S3_API_ROUTES.delete,
358
528
  { method: "DELETE", query: deleteQuerySchema },
359
529
  async (ctx) => {
360
- assertFeatureEnabled(ctx.context.config, "delete");
361
530
  return handleDelete(ctx.context.config, ctx.query, ctx.context.request);
362
531
  }
363
532
  );
533
+ async function deleteOne(config, routeName, key, request) {
534
+ const { route, bucket, stored } = await openStoredTarget(
535
+ config,
536
+ { route: routeName, key },
537
+ request,
538
+ "delete"
539
+ );
540
+ await runHook(route.delete.guard, stored);
541
+ await headObjectOrNotFound(route.client, bucket, stored.key);
542
+ await route.client.send(
543
+ new DeleteObjectCommand({ Bucket: bucket, Key: stored.key })
544
+ );
545
+ await runLifecycleHook(route.delete.onDeleted, stored, config);
546
+ }
547
+ var deleteBatch = createS3Endpoint(
548
+ S3_API_ROUTES.deleteBatch,
549
+ { method: "POST", body: deleteBatchBodySchema },
550
+ async (ctx) => {
551
+ const config = ctx.context.config;
552
+ const request = ctx.context.request;
553
+ const results = [];
554
+ for (const key of ctx.body.keys) {
555
+ try {
556
+ await deleteOne(config, ctx.body.route, key, request);
557
+ results.push({ key, success: true });
558
+ } catch (err) {
559
+ results.push({
560
+ key,
561
+ success: false,
562
+ error: isDimahS3Error(err) ? { code: err.code ?? "INTERNAL_ERROR", message: err.message } : { code: "INTERNAL_ERROR", message: "Internal server error" }
563
+ });
564
+ }
565
+ }
566
+ return { results };
567
+ }
568
+ );
569
+ function fileUrl(request, basePath, query) {
570
+ const url = new URL(request.url);
571
+ const prefix = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
572
+ url.pathname = `${prefix}${S3_API_ROUTES.file}`;
573
+ url.search = "";
574
+ url.searchParams.set("route", query.route);
575
+ url.searchParams.set("key", query.key);
576
+ if (query.fileName) url.searchParams.set("fileName", query.fileName);
577
+ if (query.disposition) url.searchParams.set("disposition", query.disposition);
578
+ return url.toString();
579
+ }
364
580
  async function handleDownload(config, input, request) {
365
- const { key, bucket } = await resolveRequestTarget(config, config.download, {
581
+ const { route, key, bucket, stored } = await openStoredTarget(
582
+ config,
583
+ input,
366
584
  request,
367
- key: input.key,
368
- bucket: input.bucket,
369
- fileName: input.fileName
585
+ "download"
586
+ );
587
+ const resolved = await route.download.resolve?.({
588
+ ...stored,
589
+ fileName: input.fileName,
590
+ disposition: input.disposition ?? route.download.disposition
370
591
  });
371
- const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
372
- const fileName = input.fileName;
373
- await runHook(config.download?.guard, {
374
- request,
375
- key,
376
- bucket,
377
- fileName
592
+ const fileName = resolved?.fileName ?? input.fileName;
593
+ const disposition = resolved?.disposition ?? input.disposition ?? route.download.disposition ?? "attachment";
594
+ const expiresIn = normalizeExpiresIn(
595
+ resolved?.expiresIn ?? route.download.expiresIn,
596
+ config.maxExpiresIn
597
+ );
598
+ await runHook(route.download.guard, {
599
+ ...stored,
600
+ fileName,
601
+ disposition
378
602
  });
379
- await headObjectOrNotFound(config.client, bucket, key);
380
- const url = await getSignedUrl(
381
- config.client,
603
+ await headObjectOrNotFound(route.client, bucket, key);
604
+ const mode = route.download.mode ?? "presign";
605
+ const contentDisposition = fileName ? buildContentDisposition(fileName, disposition) : disposition;
606
+ const url = mode === "proxy" ? fileUrl(request, config.basePath ?? "/api/s3", {
607
+ route: input.route,
608
+ key,
609
+ fileName,
610
+ disposition
611
+ }) : await getSignedUrl(
612
+ route.client,
382
613
  new GetObjectCommand({
383
614
  Bucket: bucket,
384
615
  Key: key,
385
- ResponseContentDisposition: fileName ? buildContentDisposition(fileName) : "attachment"
616
+ ResponseContentDisposition: contentDisposition
386
617
  }),
387
618
  { expiresIn }
388
619
  );
389
- await runLifecycleHook(config.download?.onPresigned, {
390
- request,
391
- key,
620
+ await runLifecycleHook(
621
+ route.download.onPresigned,
622
+ {
623
+ ...stored,
624
+ fileName,
625
+ url,
626
+ expiresIn: mode === "proxy" ? 0 : expiresIn
627
+ },
628
+ config
629
+ );
630
+ return {
392
631
  bucket,
393
- fileName,
632
+ key,
394
633
  url,
395
- expiresIn
396
- });
397
- return { bucket, key, url, expiresIn };
634
+ expiresIn: mode === "proxy" ? 0 : expiresIn,
635
+ mode,
636
+ disposition
637
+ };
398
638
  }
399
639
  var download = createS3Endpoint(
400
640
  S3_API_ROUTES.download,
401
641
  { method: "GET", query: downloadQuerySchema },
402
642
  async (ctx) => {
403
- assertFeatureEnabled(ctx.context.config, "download");
404
643
  return handleDownload(ctx.context.config, ctx.query, ctx.context.request);
405
644
  }
406
645
  );
407
- async function handleAbort(config, input, request) {
408
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
646
+ function catalogUpload(upload2) {
647
+ if (!isEnabled(upload2)) return { enabled: false };
648
+ return {
649
+ enabled: true,
650
+ fileTypes: upload2.fileTypes,
651
+ maxFileSize: upload2.maxFileSize,
652
+ multipart: isEnabled(upload2.multipart),
653
+ ...upload2.checksum ? { checksum: true } : {},
654
+ ...upload2.replace ? { replace: upload2.replace } : {}
655
+ };
656
+ }
657
+ function catalogDownload(download2) {
658
+ if (!isEnabled(download2)) return { enabled: false };
659
+ return {
660
+ enabled: true,
661
+ ...download2.disposition ? { disposition: download2.disposition } : {},
662
+ ...download2.mode ? { mode: download2.mode } : {}
663
+ };
664
+ }
665
+ function buildRouteCatalog(config) {
666
+ const routes = {};
667
+ for (const [name, route] of Object.entries(config.routes)) {
668
+ routes[name] = {
669
+ upload: catalogUpload(route.upload),
670
+ download: catalogDownload(route.download),
671
+ delete: isEnabled(route.delete) ? { enabled: true } : { enabled: false }
672
+ };
673
+ }
674
+ return { routes };
675
+ }
676
+ var catalog = createS3Endpoint(
677
+ S3_API_ROUTES.catalog,
678
+ { method: "GET" },
679
+ async (ctx) => {
680
+ return buildRouteCatalog(ctx.context.config);
681
+ }
682
+ );
683
+ async function handleFile(config, input, request) {
684
+ const { route, key, bucket, stored } = await openStoredTarget(
685
+ config,
686
+ input,
409
687
  request,
410
- key: input.key,
411
- bucket: input.bucket
688
+ "download"
689
+ );
690
+ const resolved = await route.download.resolve?.({
691
+ ...stored,
692
+ fileName: input.fileName,
693
+ disposition: input.disposition ?? route.download.disposition
412
694
  });
413
- const uploadId = input.uploadId;
414
- await runHook(config.multipart?.abortGuard, {
695
+ const fileName = resolved?.fileName ?? input.fileName;
696
+ const disposition = resolved?.disposition ?? input.disposition ?? route.download.disposition ?? "attachment";
697
+ await runHook(route.download.guard, {
698
+ ...stored,
699
+ fileName,
700
+ disposition
701
+ });
702
+ await headObjectOrNotFound(route.client, bucket, key);
703
+ const result = await sendOrObjectNotFound(
704
+ () => route.client.send(
705
+ new GetObjectCommand({
706
+ Bucket: bucket,
707
+ Key: key,
708
+ ResponseContentDisposition: fileName ? buildContentDisposition(fileName, disposition) : `${disposition}`
709
+ })
710
+ )
711
+ );
712
+ const body = result.Body;
713
+ if (!body) throw errors.objectNotFound();
714
+ const stream = "transformToWebStream" in body && typeof body.transformToWebStream === "function" ? body.transformToWebStream() : body;
715
+ const headers = new Headers();
716
+ headers.set("Content-Type", result.ContentType ?? "application/octet-stream");
717
+ headers.set(
718
+ "Content-Disposition",
719
+ fileName ? buildContentDisposition(fileName, disposition) : `${disposition}`
720
+ );
721
+ if (typeof result.ContentLength === "number") {
722
+ headers.set("Content-Length", String(result.ContentLength));
723
+ }
724
+ return new Response(stream, { headers });
725
+ }
726
+ var file = createS3Endpoint(
727
+ S3_API_ROUTES.file,
728
+ { method: "GET", query: fileQuerySchema },
729
+ async (ctx) => {
730
+ return handleFile(ctx.context.config, ctx.query, ctx.context.request);
731
+ }
732
+ );
733
+ async function handleAbort(config, input, request) {
734
+ const { route, key, bucket, stored } = await openStoredTarget(
735
+ config,
736
+ input,
415
737
  request,
416
- key,
417
- bucket,
418
- uploadId
738
+ "multipart"
739
+ );
740
+ const uploadId = input.uploadId;
741
+ await runHook(route.upload.multipart.guard, {
742
+ ...stored,
743
+ uploadId,
744
+ action: "abort"
419
745
  });
420
746
  await sendOrObjectNotFound(
421
- () => config.client.send(
747
+ () => route.client.send(
422
748
  new AbortMultipartUploadCommand({
423
749
  Bucket: bucket,
424
750
  Key: key,
@@ -426,52 +752,68 @@ async function handleAbort(config, input, request) {
426
752
  })
427
753
  )
428
754
  );
429
- await runLifecycleHook(config.multipart?.onAbort, {
430
- request,
431
- key,
432
- bucket,
433
- uploadId
434
- });
755
+ await runLifecycleHook(
756
+ route.upload.multipart.onAbort,
757
+ {
758
+ ...stored,
759
+ uploadId
760
+ },
761
+ config
762
+ );
435
763
  return { bucket, key, uploadId, aborted: true };
436
764
  }
437
765
  var multipartAbort = createS3Endpoint(
438
766
  S3_API_ROUTES.multipartAbort,
439
767
  { method: "POST", body: multipartAbortBodySchema },
440
768
  async (ctx) => {
441
- assertFeatureEnabled(ctx.context.config, "multipart");
442
769
  return handleAbort(ctx.context.config, ctx.body, ctx.context.request);
443
770
  }
444
771
  );
445
772
  async function handleComplete(config, input, request) {
446
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
773
+ const { route, key, bucket, stored } = await openStoredTarget(
774
+ config,
775
+ input,
447
776
  request,
448
- key: input.key,
449
- bucket: input.bucket
450
- });
777
+ "multipart"
778
+ );
451
779
  const uploadId = input.uploadId;
452
- const parts = input.parts.map(({ partNumber }) => partNumber).sort((a, b) => a - b);
780
+ const parts = [
781
+ ...new Set(input.parts.map(({ partNumber }) => partNumber))
782
+ ].sort((a, b) => a - b);
453
783
  const partRefs = parts.map((partNumber) => ({ partNumber }));
454
- await runHook(config.multipart?.completeGuard, {
455
- request,
456
- key,
457
- bucket,
784
+ await runHook(route.upload.multipart.guard, {
785
+ ...stored,
786
+ uploadId,
787
+ action: "complete"
788
+ });
789
+ await runHook(route.upload.confirmGuard, {
790
+ ...stored,
458
791
  uploadId,
459
- parts: partRefs
792
+ parts: partRefs,
793
+ replace: route.upload.replace
460
794
  });
461
- const listedParts = await listAllParts(config.client, {
795
+ const listedParts = await listAllParts(route.client, {
462
796
  bucket,
463
797
  key,
464
798
  uploadId
465
799
  });
800
+ let assembledBytes = 0;
466
801
  const completeParts = parts.map((partNumber) => {
467
802
  const found = listedParts.find((p) => p.PartNumber === partNumber);
468
803
  if (!found?.ETag) {
469
804
  throw errors.multipartPartMissing(partNumber);
470
805
  }
806
+ assembledBytes += found.Size ?? 0;
471
807
  return { PartNumber: partNumber, ETag: found.ETag };
472
808
  });
809
+ try {
810
+ assertWithinMaxFileSize(route.upload.maxFileSize, assembledBytes);
811
+ } catch (err) {
812
+ await abortMultipartBestEffort(route.client, bucket, key, uploadId);
813
+ throw err;
814
+ }
473
815
  const completeResult = await sendOrObjectNotFound(
474
- () => config.client.send(
816
+ () => route.client.send(
475
817
  new CompleteMultipartUploadCommand({
476
818
  Bucket: bucket,
477
819
  Key: key,
@@ -481,273 +823,290 @@ async function handleComplete(config, input, request) {
481
823
  )
482
824
  );
483
825
  const head = await headObjectAfterMultipartComplete(
484
- config.client,
826
+ route.client,
485
827
  bucket,
486
828
  key
487
829
  );
488
- const contentLength = requireContentLength(head);
489
- const contentType = head.ContentType;
490
- const eTag = (head.ETag ?? completeResult.ETag ?? "").replace(/"/g, "");
491
- const metadata = head.Metadata ?? {};
492
- const versionId = head.VersionId;
493
- const lastModified = head.LastModified?.toISOString();
494
- const acl = config.resolveObjectAcl ? await resolveObjectAcl(config.client, bucket, key) : void 0;
495
- const fileName = parseFileName(head.ContentDisposition);
496
- await runLifecycleHook(config.multipart?.onComplete, {
497
- request,
498
- key,
499
- bucket,
830
+ const confirmed = await finalizeConfirmedObject(route.client, bucket, key, {
831
+ config,
832
+ route,
833
+ stored,
834
+ head: {
835
+ ...head,
836
+ ETag: head.ETag ?? completeResult.ETag
837
+ },
500
838
  uploadId,
501
- contentLength,
502
- contentType,
503
- eTag,
504
- metadata,
505
- acl,
506
- fileName,
507
- versionId,
508
- lastModified
839
+ acl: route.upload.acl
509
840
  });
510
- return {
511
- bucket,
512
- key,
513
- uploadId,
514
- contentLength,
515
- contentType,
516
- eTag,
517
- metadata,
518
- acl,
519
- fileName,
520
- versionId,
521
- lastModified
522
- };
841
+ return { ...confirmed, uploadId };
523
842
  }
524
843
  var multipartComplete = createS3Endpoint(
525
844
  S3_API_ROUTES.multipartComplete,
526
845
  { method: "POST", body: multipartCompleteBodySchema },
527
846
  async (ctx) => {
528
- assertFeatureEnabled(ctx.context.config, "multipart");
529
847
  return handleComplete(ctx.context.config, ctx.body, ctx.context.request);
530
848
  }
531
849
  );
532
850
  async function handleMultipartInit(config, input, request) {
533
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
534
- request,
535
- key: input.key,
536
- bucket: input.bucket,
537
- fileName: input.fileName,
538
- contentType: input.contentType
539
- });
540
- const acl = resolveRequestAcl(config.multipart, input.acl);
541
- const fileSize = typeof input.fileSize === "number" && input.fileSize > 0 ? Math.floor(input.fileSize) : void 0;
542
- if (config.multipart?.requireFileSize && fileSize === void 0) {
543
- throw errors.fileSizeRequiredMultipart();
544
- }
545
- await runHook(config.multipart?.initGuard, {
851
+ const fileSize = Math.floor(input.fileSize);
852
+ const fileName = input.fileName;
853
+ const { route, key, bucket, metadata, acl, stored } = await openUploadTarget(
854
+ config,
855
+ {
856
+ route: input.route,
857
+ fileName,
858
+ fileSize,
859
+ contentType: input.contentType,
860
+ metadata: input.metadata
861
+ },
546
862
  request,
547
- key,
548
- bucket,
549
- fileSize,
550
- contentType: input.contentType,
551
- metadata: input.metadata,
863
+ "multipart"
864
+ );
865
+ await runHook(route.upload.guard, {
866
+ ...stored,
867
+ file: {
868
+ name: fileName,
869
+ size: fileSize,
870
+ type: input.contentType
871
+ },
872
+ metadata,
873
+ clientMetadata: input.metadata,
552
874
  acl,
553
- fileName: input.fileName
875
+ replace: route.upload.replace
554
876
  });
555
- const { UploadId } = await config.client.send(
877
+ const { UploadId } = await route.client.send(
556
878
  new CreateMultipartUploadCommand({
557
879
  Bucket: bucket,
558
880
  Key: key,
559
881
  ContentType: input.contentType,
560
- ContentDisposition: input.fileName ? buildContentDisposition(input.fileName) : void 0,
561
- Metadata: input.metadata,
882
+ ContentDisposition: buildContentDisposition(fileName),
883
+ Metadata: metadata,
562
884
  ACL: acl
563
885
  })
564
886
  );
565
887
  if (!UploadId) {
566
888
  throw errors.internalError();
567
889
  }
568
- await runLifecycleHook(config.multipart?.onInit, {
569
- request,
570
- key,
571
- bucket,
572
- uploadId: UploadId,
573
- contentType: input.contentType,
574
- fileSize,
575
- metadata: input.metadata,
576
- acl,
577
- fileName: input.fileName
578
- });
890
+ await runLifecycleHook(
891
+ route.upload.multipart.onInit,
892
+ {
893
+ ...stored,
894
+ uploadId: UploadId,
895
+ file: {
896
+ name: fileName,
897
+ size: fileSize,
898
+ type: input.contentType
899
+ },
900
+ metadata,
901
+ clientMetadata: input.metadata,
902
+ acl,
903
+ replace: route.upload.replace
904
+ },
905
+ config
906
+ );
579
907
  return { bucket, key, uploadId: UploadId };
580
908
  }
581
909
  var multipartInit = createS3Endpoint(
582
910
  S3_API_ROUTES.multipartInit,
583
911
  { method: "POST", body: multipartInitBodySchema },
584
912
  async (ctx) => {
585
- assertFeatureEnabled(ctx.context.config, "multipart");
586
- ctx.setStatus(201);
587
- return handleMultipartInit(
913
+ const result = await handleMultipartInit(
588
914
  ctx.context.config,
589
915
  ctx.body,
590
916
  ctx.context.request
591
917
  );
918
+ ctx.setStatus(201);
919
+ return result;
592
920
  }
593
921
  );
594
922
  async function handleListParts(config, input, request) {
595
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
923
+ const { route, key, bucket, stored } = await openStoredTarget(
924
+ config,
925
+ input,
596
926
  request,
597
- key: input.key,
598
- bucket: input.bucket
599
- });
927
+ "multipart"
928
+ );
600
929
  const uploadId = input.uploadId;
601
- await runHook(config.multipart?.listGuard, {
602
- request,
603
- key,
604
- bucket,
605
- uploadId
606
- });
607
- const listed = await listAllParts(config.client, { bucket, key, uploadId });
608
- const parts = listed.map((p) => ({
609
- partNumber: p.PartNumber ?? 0,
610
- size: p.Size ?? 0,
611
- eTag: (p.ETag ?? "").replace(/"/g, "")
612
- }));
613
- await runLifecycleHook(config.multipart?.onList, {
614
- request,
615
- key,
616
- bucket,
930
+ await runHook(route.upload.multipart.guard, {
931
+ ...stored,
617
932
  uploadId,
618
- parts
933
+ action: "list"
619
934
  });
935
+ const listed = await listAllParts(route.client, { bucket, key, uploadId });
936
+ const parts = listed.flatMap((p) => {
937
+ if (p.PartNumber == null || p.PartNumber < 1) return [];
938
+ return [
939
+ {
940
+ partNumber: p.PartNumber,
941
+ size: p.Size ?? 0,
942
+ eTag: (p.ETag ?? "").replace(/"/g, "")
943
+ }
944
+ ];
945
+ });
946
+ await runLifecycleHook(
947
+ route.upload.multipart.onList,
948
+ {
949
+ ...stored,
950
+ uploadId,
951
+ parts
952
+ },
953
+ config
954
+ );
620
955
  return { parts };
621
956
  }
622
957
  var multipartListParts = createS3Endpoint(
623
958
  S3_API_ROUTES.multipartListParts,
624
959
  { method: "GET", query: multipartListPartsQuerySchema },
625
960
  async (ctx) => {
626
- assertFeatureEnabled(ctx.context.config, "multipart");
627
961
  return handleListParts(ctx.context.config, ctx.query, ctx.context.request);
628
962
  }
629
963
  );
630
964
  async function handleSignPart(config, input, request) {
631
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
965
+ const { route, key, bucket, stored } = await openStoredTarget(
966
+ config,
967
+ input,
632
968
  request,
633
- key: input.key,
634
- bucket: input.bucket
635
- });
969
+ "multipart"
970
+ );
636
971
  const uploadId = input.uploadId;
637
972
  const partNumber = input.partNumber;
638
- const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
639
- const partSize = typeof input.partSize === "number" && input.partSize > 0 ? Math.floor(input.partSize) : null;
640
- await runHook(config.multipart?.partGuard, {
641
- request,
642
- key,
643
- bucket,
973
+ const expiresIn = normalizeExpiresIn(
974
+ route.upload.expiresIn,
975
+ config.maxExpiresIn
976
+ );
977
+ const partSize = Math.floor(input.partSize);
978
+ await runHook(route.upload.multipart.guard, {
979
+ ...stored,
644
980
  uploadId,
981
+ action: "part",
645
982
  partNumber,
646
- partSize: partSize ?? void 0
983
+ partSize
647
984
  });
648
- const presignedUrl = await getSignedUrl(
649
- config.client,
985
+ const maxFileSize = route.upload.maxFileSize;
986
+ assertWithinMaxFileSize(maxFileSize, partSize);
987
+ if (typeof maxFileSize === "number") {
988
+ const listed = await listAllParts(route.client, { bucket, key, uploadId });
989
+ assertWithinMaxFileSize(
990
+ maxFileSize,
991
+ listedPartsByteSize(listed, partNumber) + partSize
992
+ );
993
+ }
994
+ const url = await getSignedUrl(
995
+ route.client,
650
996
  new UploadPartCommand({
651
997
  Bucket: bucket,
652
998
  Key: key,
653
999
  UploadId: uploadId,
654
1000
  PartNumber: partNumber,
655
- ...partSize !== null ? { ContentLength: partSize } : {}
1001
+ ContentLength: partSize
656
1002
  }),
657
1003
  {
658
1004
  expiresIn,
659
- ...partSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
1005
+ signableHeaders: /* @__PURE__ */ new Set(["content-length"])
660
1006
  }
661
1007
  );
662
1008
  return {
663
- presignedUrl,
1009
+ url,
664
1010
  partNumber,
665
1011
  uploadId,
666
1012
  bucket,
667
1013
  expiresIn,
668
- ...partSize !== null ? { partSize } : {}
1014
+ partSize
669
1015
  };
670
1016
  }
671
1017
  var multipartPart = createS3Endpoint(
672
1018
  S3_API_ROUTES.multipartPart,
673
1019
  { method: "POST", body: multipartSignPartBodySchema },
674
1020
  async (ctx) => {
675
- assertFeatureEnabled(ctx.context.config, "multipart");
676
1021
  return handleSignPart(ctx.context.config, ctx.body, ctx.context.request);
677
1022
  }
678
1023
  );
679
- function putMetadataHeaders(metadata) {
1024
+ function objectUserMetadata(metadata) {
680
1025
  if (!metadata) return {};
681
- const headers = {};
1026
+ const fields = {};
682
1027
  for (const [k, v] of Object.entries(metadata)) {
683
- headers[`x-amz-meta-${k}`] = v;
1028
+ fields[`x-amz-meta-${k}`] = v;
684
1029
  }
685
- return headers;
1030
+ return fields;
686
1031
  }
687
1032
  async function handleUpload(config, input, request) {
688
- const { key, bucket } = await resolveRequestTarget(config, config.upload, {
1033
+ const fileSize = Math.floor(input.fileSize);
1034
+ const fileName = input.fileName;
1035
+ const { route, key, bucket, metadata, acl, stored } = await openUploadTarget(
1036
+ config,
1037
+ {
1038
+ route: input.route,
1039
+ fileName,
1040
+ fileSize,
1041
+ contentType: input.contentType,
1042
+ metadata: input.metadata
1043
+ },
689
1044
  request,
690
- key: input.key,
691
- bucket: input.bucket,
692
- fileName: input.fileName,
693
- contentType: input.contentType
694
- });
695
- const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
696
- const acl = resolveRequestAcl(config.upload, input.acl);
697
- const contentType = input.contentType ?? "application/octet-stream";
698
- const fileSize = typeof input.fileSize === "number" && input.fileSize > 0 ? Math.floor(input.fileSize) : null;
699
- if (config.upload?.requireFileSize && fileSize === null) {
700
- throw errors.fileSizeRequiredUpload();
1045
+ "upload"
1046
+ );
1047
+ const method = route.upload.method ?? "POST";
1048
+ if (route.upload.checksum && !input.checksum) {
1049
+ throw errors.validationError("Checksum is required for this route");
701
1050
  }
702
- await runHook(config.upload?.guard, {
703
- request,
704
- key,
705
- bucket,
706
- contentType: input.contentType,
707
- fileSize: fileSize ?? void 0,
708
- metadata: input.metadata,
1051
+ if (method === "POST" && fileSize > S3_MAX_POST_OBJECT_BYTES) {
1052
+ throw errors.payloadTooLarge(
1053
+ `POST uploads cannot exceed ${S3_MAX_POST_OBJECT_BYTES} bytes. Enable upload.multipart or set upload.method to "PUT".`
1054
+ );
1055
+ }
1056
+ const expiresIn = normalizeExpiresIn(
1057
+ route.upload.expiresIn,
1058
+ config.maxExpiresIn
1059
+ );
1060
+ const contentType = input.contentType ?? "application/octet-stream";
1061
+ const hookCtx = {
1062
+ ...stored,
1063
+ file: {
1064
+ name: fileName,
1065
+ size: fileSize,
1066
+ type: input.contentType
1067
+ },
1068
+ metadata,
1069
+ clientMetadata: input.metadata,
709
1070
  acl,
710
- fileName: input.fileName
711
- });
712
- const method = config.upload?.method ?? "POST";
1071
+ replace: route.upload.replace
1072
+ };
1073
+ await runHook(route.upload.guard, hookCtx);
713
1074
  if (method === "PUT") {
714
1075
  const putHeaders = {
715
1076
  "Content-Type": contentType,
716
- ...putMetadataHeaders(input.metadata)
1077
+ ...objectUserMetadata(metadata),
1078
+ "Content-Disposition": buildContentDisposition(fileName),
1079
+ ...input.checksum ? { "x-amz-checksum-sha256": input.checksum } : {}
717
1080
  };
718
- if (input.fileName) {
719
- putHeaders["Content-Disposition"] = buildContentDisposition(
720
- input.fileName
721
- );
722
- }
723
1081
  const url2 = await getSignedUrl(
724
- config.client,
1082
+ route.client,
725
1083
  new PutObjectCommand({
726
1084
  Bucket: bucket,
727
1085
  Key: key,
728
1086
  ContentType: contentType,
729
1087
  ACL: acl,
730
- Metadata: input.metadata,
731
- ...input.fileName ? { ContentDisposition: buildContentDisposition(input.fileName) } : {},
732
- ...fileSize !== null ? { ContentLength: fileSize } : {}
1088
+ Metadata: metadata,
1089
+ ContentDisposition: buildContentDisposition(fileName),
1090
+ ContentLength: fileSize,
1091
+ ...input.checksum ? { ChecksumSHA256: input.checksum, ChecksumAlgorithm: "SHA256" } : {}
733
1092
  }),
734
1093
  {
735
1094
  expiresIn,
736
- ...fileSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
1095
+ signableHeaders: /* @__PURE__ */ new Set([
1096
+ "content-length",
1097
+ ...input.checksum ? ["x-amz-checksum-sha256"] : []
1098
+ ])
737
1099
  }
738
1100
  );
739
- await runLifecycleHook(config.upload?.onPresigned, {
740
- request,
741
- key,
742
- bucket,
743
- contentType: input.contentType,
744
- fileSize: fileSize ?? void 0,
745
- metadata: input.metadata,
746
- acl,
747
- fileName: input.fileName,
748
- url: url2,
749
- expiresIn
750
- });
1101
+ await runLifecycleHook(
1102
+ route.upload.onPresigned,
1103
+ {
1104
+ ...hookCtx,
1105
+ url: url2,
1106
+ expiresIn
1107
+ },
1108
+ config
1109
+ );
751
1110
  return {
752
1111
  bucket,
753
1112
  key,
@@ -757,39 +1116,32 @@ async function handleUpload(config, input, request) {
757
1116
  method: "PUT"
758
1117
  };
759
1118
  }
760
- const fields = { acl, "Content-Type": contentType };
761
- if (input.fileName) {
762
- fields["Content-Disposition"] = buildContentDisposition(input.fileName);
763
- }
764
- if (input.metadata) {
765
- for (const [k, v] of Object.entries(input.metadata)) {
766
- fields[`x-amz-meta-${k}`] = v;
767
- }
768
- }
769
- const rangeMin = fileSize ?? 1;
770
- const rangeMax = fileSize ?? void 0;
1119
+ const fields = {
1120
+ acl,
1121
+ "Content-Type": contentType,
1122
+ "Content-Disposition": buildContentDisposition(fileName),
1123
+ ...objectUserMetadata(metadata),
1124
+ ...input.checksum ? { "x-amz-checksum-sha256": input.checksum } : {}
1125
+ };
771
1126
  const { url, fields: signedFields } = await createPresignedPost(
772
- config.client,
1127
+ route.client,
773
1128
  {
774
1129
  Bucket: bucket,
775
1130
  Key: key,
776
- Conditions: rangeMax !== void 0 ? [["content-length-range", rangeMin, rangeMax]] : [["content-length-range", rangeMin, S3_MAX_POST_OBJECT_BYTES]],
1131
+ Conditions: [["content-length-range", fileSize, fileSize]],
777
1132
  Fields: fields,
778
1133
  Expires: expiresIn
779
1134
  }
780
1135
  );
781
- await runLifecycleHook(config.upload?.onPresigned, {
782
- request,
783
- key,
784
- bucket,
785
- contentType: input.contentType,
786
- fileSize: fileSize ?? void 0,
787
- metadata: input.metadata,
788
- acl,
789
- fileName: input.fileName,
790
- url,
791
- expiresIn
792
- });
1136
+ await runLifecycleHook(
1137
+ route.upload.onPresigned,
1138
+ {
1139
+ ...hookCtx,
1140
+ url,
1141
+ expiresIn
1142
+ },
1143
+ config
1144
+ );
793
1145
  return {
794
1146
  bucket,
795
1147
  key,
@@ -803,7 +1155,6 @@ var upload = createS3Endpoint(
803
1155
  S3_API_ROUTES.upload,
804
1156
  { method: "POST", body: uploadBodySchema },
805
1157
  async (ctx) => {
806
- assertFeatureEnabled(ctx.context.config, "upload");
807
1158
  return handleUpload(ctx.context.config, ctx.body, ctx.context.request);
808
1159
  }
809
1160
  );
@@ -813,7 +1164,10 @@ var coreEndpoints = {
813
1164
  upload,
814
1165
  confirm,
815
1166
  download,
1167
+ catalog,
1168
+ file,
816
1169
  delete: deleteObject,
1170
+ deleteBatch,
817
1171
  multipartInit,
818
1172
  multipartPart,
819
1173
  multipartListParts,