@dimah-s3/server 1.0.1 → 1.1.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.
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 +194 -89
  89. package/dist/types/config.d.ts.map +1 -1
  90. package/dist/types/hook-contexts.d.ts +126 -85
  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/index.js CHANGED
@@ -1,11 +1,34 @@
1
- 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, normalizeS3ApiBasePath, S3_API_BASE_PATH, S3_DEFAULT_EXPIRES_IN } from '@dimah-s3/core';
1
+ import { S3_API_ROUTES, isAPIError, DimahS3Error, S3_ERROR_CODES, confirmBodySchema, deleteQuerySchema, isDimahS3Error, deleteBatchBodySchema, downloadQuerySchema, fileQuerySchema, multipartAbortBodySchema, multipartCompleteBodySchema, multipartInitBodySchema, multipartListPartsQuerySchema, multipartSignPartBodySchema, uploadBodySchema, resolveStoredFileName, S3_MAX_EXPIRES_IN, buildContentDisposition, S3_MAX_POST_OBJECT_BYTES, normalizeS3ApiBasePath, S3_API_BASE_PATH, matchesFileTypes, S3_DEFAULT_EXPIRES_IN, normalizeObjectKey, sanitizeFileName, ROUTE_NAME_PATTERN } from '@dimah-s3/core';
2
2
  export { DimahS3Error, S3_ERROR_CODES, isAPIError, isDimahS3Error, isS3ErrorCode } from '@dimah-s3/core';
3
- import { DeleteObjectCommand, GetObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, PutObjectCommand, GetObjectAclCommand, ListPartsCommand, CompleteMultipartUploadCommand, HeadObjectCommand, AbortMultipartUploadCommand } from '@aws-sdk/client-s3';
3
+ import { PutObjectCommand, DeleteObjectCommand, GetObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, ListPartsCommand, AbortMultipartUploadCommand, CompleteMultipartUploadCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
4
4
  import { createMiddleware, createEndpoint, createRouter, toResponse } from 'better-call';
5
5
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
6
6
  import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
7
7
 
8
8
  // src/dimah-s3.ts
9
+
10
+ // src/helpers/request.ts
11
+ function requestFromHeaders(headers) {
12
+ return new Request("http://dimah-s3.local", { headers });
13
+ }
14
+ async function deleteObjectBestEffort(client, bucket, key) {
15
+ try {
16
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
17
+ } catch {
18
+ }
19
+ }
20
+ async function abortMultipartBestEffort(client, bucket, key, uploadId) {
21
+ try {
22
+ await client.send(
23
+ new AbortMultipartUploadCommand({
24
+ Bucket: bucket,
25
+ Key: key,
26
+ UploadId: uploadId
27
+ })
28
+ );
29
+ } catch {
30
+ }
31
+ }
9
32
  var errors = {
10
33
  notFound: () => DimahS3Error.from("NOT_FOUND", S3_ERROR_CODES.NOT_FOUND),
11
34
  unauthorized: () => DimahS3Error.from("UNAUTHORIZED", S3_ERROR_CODES.UNAUTHORIZED),
@@ -22,10 +45,18 @@ var errors = {
22
45
  params: { feature }
23
46
  }),
24
47
  invalidKey: () => DimahS3Error.from("BAD_REQUEST", S3_ERROR_CODES.INVALID_KEY),
25
- invalidBucket: (bucket) => DimahS3Error.from("FORBIDDEN", {
26
- code: S3_ERROR_CODES.INVALID_BUCKET.code,
27
- message: S3_ERROR_CODES.INVALID_BUCKET.message,
28
- params: { bucket }
48
+ unknownRoute: (route2) => DimahS3Error.from("NOT_FOUND", {
49
+ code: S3_ERROR_CODES.UNKNOWN_ROUTE.code,
50
+ message: S3_ERROR_CODES.UNKNOWN_ROUTE.message,
51
+ params: { route: route2 }
52
+ }),
53
+ fileTypeNotAllowed: (fileName, contentType) => DimahS3Error.from("BAD_REQUEST", {
54
+ code: S3_ERROR_CODES.FILE_TYPE_NOT_ALLOWED.code,
55
+ message: S3_ERROR_CODES.FILE_TYPE_NOT_ALLOWED.message,
56
+ params: {
57
+ fileName,
58
+ ...contentType ? { contentType } : {}
59
+ }
29
60
  }),
30
61
  s3NetworkError: (code) => DimahS3Error.from("BAD_GATEWAY", {
31
62
  code: S3_ERROR_CODES.S3_NETWORK_ERROR.code,
@@ -35,11 +66,6 @@ var errors = {
35
66
  ),
36
67
  params: { code }
37
68
  }),
38
- fileSizeRequiredUpload: () => DimahS3Error.from("BAD_REQUEST", S3_ERROR_CODES.FILE_SIZE_REQUIRED_UPLOAD),
39
- fileSizeRequiredMultipart: () => DimahS3Error.from(
40
- "BAD_REQUEST",
41
- S3_ERROR_CODES.FILE_SIZE_REQUIRED_MULTIPART
42
- ),
43
69
  multipartPartMissing: (partNumber) => DimahS3Error.from("BAD_REQUEST", {
44
70
  code: S3_ERROR_CODES.MULTIPART_PART_MISSING.code,
45
71
  message: S3_ERROR_CODES.MULTIPART_PART_MISSING.message.replaceAll(
@@ -52,6 +78,10 @@ var errors = {
52
78
  ...S3_ERROR_CODES.PAYLOAD_TOO_LARGE,
53
79
  message
54
80
  }),
81
+ quotaExceeded: (message = S3_ERROR_CODES.QUOTA_EXCEEDED.message) => DimahS3Error.from("PAYLOAD_TOO_LARGE", {
82
+ ...S3_ERROR_CODES.QUOTA_EXCEEDED,
83
+ message
84
+ }),
55
85
  validationError: (message) => DimahS3Error.from("BAD_REQUEST", {
56
86
  code: S3_ERROR_CODES.VALIDATION_ERROR.code,
57
87
  message
@@ -71,119 +101,364 @@ async function sendOrObjectNotFound(send) {
71
101
  throw err;
72
102
  }
73
103
  }
74
- var DEFAULT_ACL_LOOKUP_TIMEOUT_MS = 1500;
75
- async function resolveObjectAcl(s3, bucket, key, timeoutMs = DEFAULT_ACL_LOOKUP_TIMEOUT_MS) {
76
- const controller = new AbortController();
77
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
78
- try {
79
- const result = await s3.send(
80
- new GetObjectAclCommand({ Bucket: bucket, Key: key }),
81
- { abortSignal: controller.signal }
82
- );
83
- const isPublic = result.Grants?.some(
84
- (g) => g.Grantee?.URI === "http://acs.amazonaws.com/groups/global/AllUsers" && (g.Permission === "READ" || g.Permission === "FULL_CONTROL")
85
- );
86
- return isPublic ? "public-read" : "private";
87
- } catch {
88
- return void 0;
89
- } finally {
90
- clearTimeout(timeout);
91
- }
92
- }
93
-
94
- // src/helpers/resolve-request-acl.ts
95
- function resolveRequestAcl(policy, requested) {
96
- if (policy?.acl) return policy.acl;
97
- if (policy?.allowClientAcl && requested) return requested;
98
- return "private";
99
- }
100
104
  function normalizeExpiresIn(value, maxExpiresIn = S3_MAX_EXPIRES_IN) {
101
105
  const n = Number(value);
102
106
  const expires = Number.isFinite(n) && n > 0 ? Math.floor(n) : S3_DEFAULT_EXPIRES_IN;
103
107
  const max = Number.isFinite(maxExpiresIn) && maxExpiresIn > 0 ? Math.floor(maxExpiresIn) : S3_MAX_EXPIRES_IN;
104
108
  return Math.min(expires, max);
105
109
  }
110
+
111
+ // src/helpers/logger.ts
112
+ var defaultLogger = {
113
+ error: (message, ...args) => {
114
+ console.error("[dimah-s3]", message, ...args);
115
+ },
116
+ warn: (message, ...args) => {
117
+ console.warn("[dimah-s3]", message, ...args);
118
+ }
119
+ };
120
+ function resolveLogger(logger) {
121
+ if (logger === false) return {};
122
+ if (!logger) return defaultLogger;
123
+ return logger;
124
+ }
125
+
126
+ // src/helpers/hooks.ts
127
+ function mapGuardError(err) {
128
+ if (isAPIError(err)) throw err;
129
+ if (err instanceof Error && err.message.trim()) {
130
+ throw new DimahS3Error("FORBIDDEN", {
131
+ message: err.message,
132
+ cause: err
133
+ });
134
+ }
135
+ throw errors.forbidden();
136
+ }
106
137
  async function runHook(hook, context) {
107
138
  if (!hook) return;
108
139
  try {
109
140
  await hook(context);
110
141
  } catch (err) {
111
- if (isAPIError(err)) throw err;
112
- if (err instanceof Error && err.message.trim()) {
113
- throw new DimahS3Error("FORBIDDEN", {
114
- message: err.message,
115
- cause: err
116
- });
117
- }
118
- throw errors.forbidden();
142
+ mapGuardError(err);
143
+ }
144
+ }
145
+ async function runObjectHook(hook, context) {
146
+ if (!hook) return;
147
+ try {
148
+ return await hook(context);
149
+ } catch (err) {
150
+ mapGuardError(err);
119
151
  }
120
152
  }
121
- async function runLifecycleHook(hook, context) {
153
+ async function runLifecycleHook(hook, context, config) {
122
154
  if (!hook) return;
123
155
  try {
124
156
  await hook(context);
125
157
  } catch (err) {
126
158
  if (isAPIError(err)) throw err;
127
- console.error("[S3 API] lifecycle hook failed", err);
159
+ resolveLogger(config?.logger).error?.("lifecycle hook failed", err);
160
+ config?.onError?.(err, { request: context.request });
128
161
  throw errors.internalError();
129
162
  }
130
163
  }
131
164
 
132
- // src/helpers/request.ts
133
- function requestFromHeaders(headers) {
134
- return new Request("http://dimah-s3.local", { headers });
165
+ // src/helpers/previous-key.ts
166
+ var DIMAH_PREVIOUS_KEY_META = "dimah-previous-key";
167
+ function stripPreviousKeyMeta(metadata) {
168
+ if (!metadata) return {};
169
+ const { [DIMAH_PREVIOUS_KEY_META]: _omit, ...rest } = metadata;
170
+ return rest;
171
+ }
172
+ function previousKeyFromMetadata(metadata) {
173
+ const value = metadata?.[DIMAH_PREVIOUS_KEY_META]?.trim();
174
+ return value || void 0;
135
175
  }
136
176
 
137
177
  // src/helpers/resolve-target.ts
138
178
  function assertSafeObjectKey(key) {
139
- const trimmed = key.replace(/^\/+/u, "").replace(/\/+$/u, "");
140
- if (!trimmed) {
141
- throw errors.invalidKey();
142
- }
143
- if (trimmed.includes("\\") || trimmed.includes("\0")) {
144
- throw errors.invalidKey();
145
- }
146
- const parts = trimmed.split("/");
147
- if (parts.some((part) => part === "" || part === "." || part === "..")) {
148
- throw errors.invalidKey();
149
- }
150
- return parts.join("/");
179
+ const normalized = normalizeObjectKey(key);
180
+ if (!normalized) throw errors.invalidKey();
181
+ return normalized;
151
182
  }
152
183
  function applyPrefix(prefix, key) {
153
184
  const p = assertSafeObjectKey(prefix);
154
185
  const k = assertSafeObjectKey(key);
155
- if (k === p || k.startsWith(`${p}/`)) return k;
186
+ if (k.startsWith(`${p}/`)) return k;
187
+ if (k === p) throw errors.invalidKey();
156
188
  return `${p}/${k}`;
157
189
  }
158
- async function resolveObjectKey(policy, context) {
159
- if (policy?.resolveKey) {
160
- return assertSafeObjectKey(await policy.resolveKey(context));
190
+ function nestKeyUnderPrefix(keyPrefix, key) {
191
+ const safe = assertSafeObjectKey(key);
192
+ if (keyPrefix === false) return safe;
193
+ return applyPrefix(keyPrefix, safe);
194
+ }
195
+ function assertStoredKey(key, keyPrefix) {
196
+ const safe = assertSafeObjectKey(key);
197
+ if (keyPrefix === false) return safe;
198
+ const root = assertSafeObjectKey(keyPrefix);
199
+ if (safe.startsWith(`${root}/`)) return safe;
200
+ throw errors.invalidKey();
201
+ }
202
+ function objectKeyFileName(fileName) {
203
+ const sanitized = sanitizeFileName(fileName);
204
+ const leaf = sanitized.split("/").filter((part) => part.length > 0).at(-1);
205
+ if (!leaf || leaf === "." || leaf === "..") return "file";
206
+ return leaf;
207
+ }
208
+ function generateObjectKey(folder, fileName) {
209
+ const leaf = `${crypto.randomUUID()}/${objectKeyFileName(fileName)}`;
210
+ if (folder === false) return assertSafeObjectKey(leaf);
211
+ return applyPrefix(folder, leaf);
212
+ }
213
+ function resolveAcl(info, route2) {
214
+ return info?.acl ?? route2.upload.acl ?? "private";
215
+ }
216
+ async function resolveUploadTarget(route2, context) {
217
+ const bucket = route2.bucket;
218
+ const objectContext = {
219
+ ...context,
220
+ bucket,
221
+ keyPrefix: route2.keyPrefix
222
+ };
223
+ const info = await runObjectHook(route2.upload.object, objectContext) ?? void 0;
224
+ const key = info?.key ? nestKeyUnderPrefix(route2.keyPrefix, info.key) : generateObjectKey(
225
+ info?.folder != null && info.folder !== "" ? nestKeyUnderPrefix(route2.keyPrefix, info.folder) : route2.keyPrefix === false ? false : route2.keyPrefix,
226
+ context.file.name
227
+ );
228
+ const metadata = { ...info?.metadata };
229
+ if (info?.previousKey) {
230
+ metadata[DIMAH_PREVIOUS_KEY_META] = nestKeyUnderPrefix(
231
+ route2.keyPrefix,
232
+ info.previousKey
233
+ );
234
+ }
235
+ return {
236
+ key,
237
+ bucket,
238
+ metadata: Object.keys(metadata).length > 0 ? metadata : info?.metadata,
239
+ acl: resolveAcl(info, route2)
240
+ };
241
+ }
242
+ function resolveStoredTarget(route2, key) {
243
+ return {
244
+ key: assertStoredKey(key, route2.keyPrefix),
245
+ bucket: route2.bucket
246
+ };
247
+ }
248
+ function route(config) {
249
+ return config;
250
+ }
251
+ function assertRouteName(name) {
252
+ if (!ROUTE_NAME_PATTERN.test(name)) {
253
+ throw new Error(
254
+ `dimahS3 route "${name}" is invalid. Use a letter followed by up to 63 letters, digits, underscores, or hyphens.`
255
+ );
161
256
  }
162
- const prefix = typeof policy?.prefix === "function" ? await policy.prefix(context) : policy?.prefix;
163
- if (prefix) {
164
- return applyPrefix(prefix, context.proposedKey);
257
+ }
258
+
259
+ // src/helpers/features.ts
260
+ function isEnabled(feature) {
261
+ return feature.enabled;
262
+ }
263
+ function resolveKeyPrefix(name, value) {
264
+ if (value === false) return false;
265
+ const normalized = normalizeObjectKey(value ?? name);
266
+ if (!normalized) {
267
+ throw new Error(
268
+ `dimahS3 route "${name}": keyPrefix is not a valid object-key prefix.`
269
+ );
165
270
  }
166
- return assertSafeObjectKey(context.proposedKey);
271
+ return normalized;
167
272
  }
168
- function resolveBucket(config, requested) {
169
- if (!requested) return config.bucket;
170
- if (config.allowClientBucket) return requested;
171
- if (config.buckets?.length) {
172
- if (config.buckets.includes(requested)) return requested;
173
- throw errors.invalidBucket(requested);
273
+ function isFeatureOn(value, defaultOn = false) {
274
+ if (value === false) return false;
275
+ if (value === void 0) return defaultOn;
276
+ if (value === true) return true;
277
+ if ("enabled" in value && typeof value.enabled === "boolean") {
278
+ return value.enabled;
174
279
  }
175
- return config.bucket;
280
+ return true;
176
281
  }
177
- async function resolveRequestTarget(config, policy, input) {
178
- const bucket = resolveBucket(config, input.bucket);
179
- const key = await resolveObjectKey(policy, {
180
- request: input.request,
181
- proposedKey: input.key,
182
- fileName: input.fileName,
183
- contentType: input.contentType,
184
- bucket
282
+ function normalizeFeature(value, defaultOn = false) {
283
+ if (value === false) return { enabled: false };
284
+ if (value === void 0) {
285
+ if (!defaultOn) return { enabled: false };
286
+ return { enabled: true };
287
+ }
288
+ if (value === true) return { enabled: true };
289
+ return { ...value, enabled: true };
290
+ }
291
+ function skippedPluginIds(plugins) {
292
+ const ids = /* @__PURE__ */ new Set();
293
+ if (!plugins) return ids;
294
+ for (const [id, enabled] of Object.entries(plugins)) {
295
+ if (enabled === false) ids.add(id);
296
+ }
297
+ return ids;
298
+ }
299
+ function splitUpload(upload2) {
300
+ if (upload2 === void 0 || typeof upload2 === "boolean") {
301
+ return { upload: upload2, multipart: void 0 };
302
+ }
303
+ const { multipart, ...rest } = upload2;
304
+ return { upload: rest, multipart };
305
+ }
306
+ function normalizeRoute(name, route2, instance) {
307
+ assertRouteName(name);
308
+ const client = route2.client ?? instance.client;
309
+ const bucket = route2.bucket ?? instance.bucket;
310
+ if (!client) {
311
+ throw new Error(
312
+ `dimahS3 route "${name}": set client on the route or on dimahS3().`
313
+ );
314
+ }
315
+ if (!bucket) {
316
+ throw new Error(
317
+ `dimahS3 route "${name}": set bucket on the route or on dimahS3().`
318
+ );
319
+ }
320
+ const { upload: uploadInput, multipart: multipartInput } = splitUpload(
321
+ route2.upload
322
+ );
323
+ const upload2 = normalizeFeature(uploadInput);
324
+ const download2 = normalizeFeature(route2.download);
325
+ const deleteFeature = normalizeFeature(route2.delete);
326
+ const multipart = normalizeFeature(multipartInput);
327
+ if (!upload2.enabled && !download2.enabled && !deleteFeature.enabled) {
328
+ throw new Error(
329
+ `dimahS3 route "${name}": enable upload, download, or delete.`
330
+ );
331
+ }
332
+ const resolvedUpload = upload2.enabled ? { ...upload2, multipart } : { enabled: false, multipart: { enabled: false } };
333
+ if (resolvedUpload.enabled) {
334
+ const maxFileSize = resolvedUpload.maxFileSize;
335
+ if (maxFileSize != null && (!Number.isFinite(maxFileSize) || maxFileSize <= 0)) {
336
+ throw new Error(
337
+ `dimahS3 route "${name}": maxFileSize must be a positive number.`
338
+ );
339
+ }
340
+ }
341
+ return {
342
+ name,
343
+ client,
344
+ bucket,
345
+ keyPrefix: resolveKeyPrefix(name, route2.keyPrefix),
346
+ guard: route2.guard,
347
+ skippedPluginIds: skippedPluginIds(route2.plugins),
348
+ upload: resolvedUpload,
349
+ download: download2,
350
+ delete: deleteFeature
351
+ };
352
+ }
353
+ function normalizeRoutes(config) {
354
+ const entries = Object.entries(config.routes);
355
+ if (entries.length === 0) {
356
+ throw new Error("dimahS3: at least one route is required.");
357
+ }
358
+ const routes = {};
359
+ for (const [name, route2] of entries) {
360
+ routes[name] = normalizeRoute(name, route2, config);
361
+ }
362
+ assertDistinctKeyPrefixes(routes);
363
+ return routes;
364
+ }
365
+ function prefixesOverlap(a, b) {
366
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
367
+ }
368
+ function assertDistinctKeyPrefixes(routes) {
369
+ const named = Object.entries(routes).filter(
370
+ (entry) => entry[1].keyPrefix !== false
371
+ );
372
+ for (let i = 0; i < named.length; i++) {
373
+ for (let j = i + 1; j < named.length; j++) {
374
+ const [nameA, routeA] = named[i];
375
+ const [nameB, routeB] = named[j];
376
+ if (prefixesOverlap(routeA.keyPrefix, routeB.keyPrefix)) {
377
+ throw new Error(
378
+ `dimahS3: routes "${nameA}" and "${nameB}" have overlapping keyPrefix values ("${routeA.keyPrefix}" and "${routeB.keyPrefix}").`
379
+ );
380
+ }
381
+ }
382
+ }
383
+ }
384
+ function assertWithinMaxFileSize(maxFileSize, size) {
385
+ if (typeof maxFileSize === "number" && size > maxFileSize) {
386
+ throw errors.payloadTooLarge();
387
+ }
388
+ }
389
+ function assertDeclaredConstraints(constraints, input) {
390
+ if (constraints?.fileTypes?.length && !matchesFileTypes(input.fileName, input.contentType, constraints.fileTypes)) {
391
+ throw errors.fileTypeNotAllowed(input.fileName, input.contentType);
392
+ }
393
+ assertWithinMaxFileSize(constraints?.maxFileSize, input.fileSize);
394
+ }
395
+ function assertVerifiedConstraints(constraints, input) {
396
+ assertWithinMaxFileSize(constraints?.maxFileSize, input.contentLength);
397
+ const name = input.fileName ?? "";
398
+ if (constraints?.fileTypes?.length && !matchesFileTypes(name, input.contentType, constraints.fileTypes)) {
399
+ throw errors.fileTypeNotAllowed(name || "unknown", input.contentType);
400
+ }
401
+ }
402
+
403
+ // src/helpers/resolve-route.ts
404
+ function getResolvedRoute(config, name) {
405
+ const route2 = config.routes[name];
406
+ if (!route2) {
407
+ throw errors.unknownRoute(name);
408
+ }
409
+ return route2;
410
+ }
411
+ function featureIsEnabled(route2, feature) {
412
+ if (feature === "multipart") return route2.upload.multipart.enabled;
413
+ return route2[feature].enabled;
414
+ }
415
+ function assertFeatureEnabled(route2, feature) {
416
+ if (!featureIsEnabled(route2, feature)) {
417
+ throw errors.featureDisabled(feature);
418
+ }
419
+ }
420
+ async function openRoute(config, name, request, feature) {
421
+ const route2 = getResolvedRoute(config, name);
422
+ assertFeatureEnabled(route2, feature);
423
+ await runHook(route2.guard, { request, route: route2.name });
424
+ return route2;
425
+ }
426
+ function storedObjectContext(request, route2, key) {
427
+ return { request, route: route2.name, key, bucket: route2.bucket };
428
+ }
429
+ async function openUploadTarget(config, input, request, feature) {
430
+ const route2 = await openRoute(config, input.route, request, feature);
431
+ const fileSize = Math.floor(input.fileSize);
432
+ const fileName = input.fileName;
433
+ assertDeclaredConstraints(route2.upload, {
434
+ fileName,
435
+ fileSize,
436
+ contentType: input.contentType
437
+ });
438
+ const target = await resolveUploadTarget(route2, {
439
+ request,
440
+ route: route2.name,
441
+ file: {
442
+ name: fileName,
443
+ size: fileSize,
444
+ type: input.contentType
445
+ },
446
+ clientMetadata: input.metadata
185
447
  });
186
- return { key, bucket };
448
+ return {
449
+ route: route2,
450
+ ...target,
451
+ stored: storedObjectContext(request, route2, target.key)
452
+ };
453
+ }
454
+ async function openStoredTarget(config, input, request, feature) {
455
+ const route2 = await openRoute(config, input.route, request, feature);
456
+ const target = resolveStoredTarget(route2, input.key);
457
+ return {
458
+ route: route2,
459
+ ...target,
460
+ stored: storedObjectContext(request, route2, target.key)
461
+ };
187
462
  }
188
463
  async function headObjectOrNotFound(client, bucket, key) {
189
464
  return sendOrObjectNotFound(
@@ -195,7 +470,7 @@ async function headObjectAfterMultipartComplete(client, bucket, key) {
195
470
  () => client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
196
471
  );
197
472
  let head = await send();
198
- for (let attempt = 0; attempt < 4 && !head.ContentLength; attempt++) {
473
+ for (let attempt = 0; attempt < 4 && typeof head.ContentLength !== "number"; attempt++) {
199
474
  await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
200
475
  head = await send();
201
476
  }
@@ -228,61 +503,126 @@ async function listAllParts(client, input) {
228
503
  }
229
504
  return parts;
230
505
  }
231
-
232
- // src/helpers/features.ts
233
- function normalizeFeature(value) {
234
- if (value === void 0) return void 0;
235
- if (value === false) return { enabled: false };
236
- if (value === true) return { enabled: true };
237
- return { ...value, enabled: true };
238
- }
239
- function isFeatureEnabled(config, feature) {
240
- return config[feature]?.enabled === true;
506
+ function listedPartsByteSize(parts, excludePartNumber) {
507
+ let total = 0;
508
+ for (const part of parts) {
509
+ if (excludePartNumber !== void 0 && part.PartNumber === excludePartNumber) {
510
+ continue;
511
+ }
512
+ total += part.Size ?? 0;
513
+ }
514
+ return total;
241
515
  }
242
- function applyMultipartDefault(config, multipartInput) {
243
- let next = config;
244
- if (multipartInput === void 0 && config.upload?.enabled) {
245
- next = {
246
- ...config,
247
- multipart: { ...config.multipart, enabled: true }
248
- };
516
+ async function finalizeConfirmedObject(client, bucket, key, input) {
517
+ const { config, route: route2, stored, head, uploadId, acl } = input;
518
+ const contentLength = requireContentLength(head);
519
+ const fileName = resolveStoredFileName(head.ContentDisposition, key);
520
+ const rawMetadata = head.Metadata ?? {};
521
+ try {
522
+ assertVerifiedConstraints(route2.upload, {
523
+ fileName,
524
+ contentType: head.ContentType,
525
+ contentLength
526
+ });
527
+ } catch (err) {
528
+ await deleteObjectBestEffort(client, bucket, key);
529
+ throw err;
249
530
  }
250
- if (next.multipart && next.upload) {
251
- next = {
252
- ...next,
253
- multipart: {
254
- ...next.multipart,
255
- prefix: next.multipart.prefix ?? next.upload.prefix,
256
- resolveKey: next.multipart.resolveKey ?? next.upload.resolveKey,
257
- acl: next.multipart.acl ?? next.upload.acl,
258
- allowClientAcl: next.multipart.allowClientAcl ?? next.upload.allowClientAcl,
259
- requireFileSize: next.multipart.requireFileSize ?? next.upload.requireFileSize
260
- }
261
- };
531
+ const metadata = stripPreviousKeyMeta(rawMetadata);
532
+ const context = {
533
+ ...stored,
534
+ contentType: head.ContentType,
535
+ contentLength,
536
+ eTag: head.ETag?.replace(/"/g, ""),
537
+ metadata,
538
+ acl,
539
+ fileName,
540
+ versionId: head.VersionId,
541
+ lastModified: head.LastModified?.toISOString(),
542
+ uploadId
543
+ };
544
+ try {
545
+ await runLifecycleHook(route2.upload.onConfirmed, context, config);
546
+ } catch (err) {
547
+ await deleteObjectBestEffort(client, bucket, key);
548
+ throw err;
549
+ }
550
+ const previousKey = previousKeyFromMetadata(rawMetadata);
551
+ if (previousKey && previousKey !== key) {
552
+ try {
553
+ const safe = assertStoredKey(previousKey, route2.keyPrefix);
554
+ await deleteObjectBestEffort(client, bucket, safe);
555
+ } catch {
556
+ }
262
557
  }
263
- return next;
264
- }
265
- function normalizeFeatures(config) {
266
558
  return {
267
- upload: normalizeFeature(config.upload),
268
- download: normalizeFeature(config.download),
269
- delete: normalizeFeature(config.delete),
270
- multipart: normalizeFeature(config.multipart)
559
+ key,
560
+ bucket,
561
+ contentType: context.contentType,
562
+ contentLength: context.contentLength,
563
+ eTag: context.eTag,
564
+ metadata: context.metadata ?? {},
565
+ acl,
566
+ fileName,
567
+ versionId: context.versionId,
568
+ lastModified: context.lastModified
271
569
  };
272
570
  }
273
- function assertExclusiveBucketFlags(config) {
274
- if (config.allowClientBucket && config.buckets?.length) {
275
- throw new Error(
276
- "dimahS3: set either allowClientBucket or buckets, not both. allowClientBucket honors any client-sent bucket; buckets is an allowlist."
277
- );
278
- }
279
- }
280
571
 
281
- // src/api/assert-feature-enabled.ts
282
- function assertFeatureEnabled(config, feature) {
283
- if (!isFeatureEnabled(config, feature)) {
284
- throw errors.featureDisabled(feature);
285
- }
572
+ // src/put.ts
573
+ function toBytes(body) {
574
+ if (typeof body === "string") return new TextEncoder().encode(body);
575
+ if (body instanceof ArrayBuffer) return new Uint8Array(body);
576
+ return body;
577
+ }
578
+ async function putObject(config, input) {
579
+ const body = toBytes(input.body);
580
+ const request = requestFromHeaders(input.headers);
581
+ await runHook(config.guard, { request });
582
+ const fileSize = body.byteLength;
583
+ const { route: route2, key, bucket, metadata, acl, stored } = await openUploadTarget(
584
+ config,
585
+ {
586
+ route: input.route,
587
+ fileName: input.fileName,
588
+ fileSize,
589
+ contentType: input.contentType,
590
+ metadata: input.metadata
591
+ },
592
+ request,
593
+ "upload"
594
+ );
595
+ await runHook(route2.upload.guard, {
596
+ ...stored,
597
+ file: {
598
+ name: input.fileName,
599
+ size: fileSize,
600
+ type: input.contentType
601
+ },
602
+ metadata,
603
+ clientMetadata: input.metadata,
604
+ acl,
605
+ replace: route2.upload.replace
606
+ });
607
+ await route2.client.send(
608
+ new PutObjectCommand({
609
+ Bucket: bucket,
610
+ Key: key,
611
+ Body: body,
612
+ ContentType: input.contentType,
613
+ ContentLength: fileSize,
614
+ Metadata: metadata,
615
+ ACL: acl
616
+ })
617
+ );
618
+ const head = await headObjectOrNotFound(route2.client, bucket, key);
619
+ return finalizeConfirmedObject(route2.client, bucket, key, {
620
+ config,
621
+ route: route2,
622
+ stored,
623
+ head,
624
+ acl
625
+ });
286
626
  }
287
627
  var s3ContextMiddleware = createMiddleware(async (ctx) => {
288
628
  const injected = ctx.context;
@@ -330,138 +670,269 @@ var createS3Endpoint = ((pathOrOptions, optionsOrHandler, maybeHandler) => {
330
670
 
331
671
  // src/api/routes/confirm.ts
332
672
  async function handleConfirm(config, input, request) {
333
- const { key, bucket } = await resolveRequestTarget(config, config.upload, {
673
+ const { route: route2, key, bucket, stored } = await openStoredTarget(
674
+ config,
675
+ input,
334
676
  request,
335
- key: input.key,
336
- bucket: input.bucket
677
+ "upload"
678
+ );
679
+ await runHook(route2.upload.confirmGuard, {
680
+ ...stored,
681
+ replace: route2.upload.replace
337
682
  });
338
- await runHook(config.upload?.confirmGuard, {
339
- request,
340
- key,
341
- bucket
683
+ const head = await headObjectOrNotFound(route2.client, bucket, key);
684
+ return finalizeConfirmedObject(route2.client, bucket, key, {
685
+ config,
686
+ route: route2,
687
+ stored,
688
+ head,
689
+ acl: route2.upload.acl
342
690
  });
343
- const head = await headObjectOrNotFound(config.client, bucket, key);
344
- const acl = config.resolveObjectAcl ? await resolveObjectAcl(config.client, bucket, key) : void 0;
345
- const fileName = parseFileName(head.ContentDisposition);
346
- const context = {
347
- request,
348
- key,
349
- bucket,
350
- contentType: head.ContentType,
351
- contentLength: requireContentLength(head),
352
- eTag: head.ETag?.replace(/"/g, ""),
353
- metadata: head.Metadata,
354
- acl,
355
- fileName,
356
- versionId: head.VersionId,
357
- lastModified: head.LastModified?.toISOString()
358
- };
359
- await runLifecycleHook(config.upload?.onConfirmed, context);
360
- return {
361
- key,
362
- bucket,
363
- contentType: context.contentType,
364
- contentLength: context.contentLength,
365
- eTag: context.eTag,
366
- metadata: context.metadata ?? {},
367
- acl,
368
- fileName,
369
- versionId: context.versionId,
370
- lastModified: context.lastModified
371
- };
372
691
  }
373
692
  var confirm = createS3Endpoint(
374
693
  S3_API_ROUTES.uploadConfirm,
375
694
  { method: "POST", body: confirmBodySchema },
376
695
  async (ctx) => {
377
- assertFeatureEnabled(ctx.context.config, "upload");
378
696
  return handleConfirm(ctx.context.config, ctx.body, ctx.context.request);
379
697
  }
380
698
  );
381
699
  async function handleDelete(config, input, request) {
382
- const { key, bucket } = await resolveRequestTarget(config, config.delete, {
383
- request,
384
- key: input.key,
385
- bucket: input.bucket
386
- });
387
- await runHook(config.delete?.guard, {
700
+ const { route: route2, key, bucket, stored } = await openStoredTarget(
701
+ config,
702
+ input,
388
703
  request,
389
- key,
390
- bucket
391
- });
392
- await headObjectOrNotFound(config.client, bucket, key);
393
- await config.client.send(
704
+ "delete"
705
+ );
706
+ await runHook(route2.delete.guard, stored);
707
+ await headObjectOrNotFound(route2.client, bucket, key);
708
+ await route2.client.send(
394
709
  new DeleteObjectCommand({ Bucket: bucket, Key: key })
395
710
  );
396
- await runLifecycleHook(config.delete?.onDeleted, { request, key, bucket });
711
+ await runLifecycleHook(route2.delete.onDeleted, stored, config);
397
712
  return { success: true, bucket, key };
398
713
  }
399
714
  var deleteObject = createS3Endpoint(
400
715
  S3_API_ROUTES.delete,
401
716
  { method: "DELETE", query: deleteQuerySchema },
402
717
  async (ctx) => {
403
- assertFeatureEnabled(ctx.context.config, "delete");
404
718
  return handleDelete(ctx.context.config, ctx.query, ctx.context.request);
405
719
  }
406
720
  );
721
+ async function deleteOne(config, routeName, key, request) {
722
+ const { route: route2, bucket, stored } = await openStoredTarget(
723
+ config,
724
+ { route: routeName, key },
725
+ request,
726
+ "delete"
727
+ );
728
+ await runHook(route2.delete.guard, stored);
729
+ await headObjectOrNotFound(route2.client, bucket, stored.key);
730
+ await route2.client.send(
731
+ new DeleteObjectCommand({ Bucket: bucket, Key: stored.key })
732
+ );
733
+ await runLifecycleHook(route2.delete.onDeleted, stored, config);
734
+ }
735
+ var deleteBatch = createS3Endpoint(
736
+ S3_API_ROUTES.deleteBatch,
737
+ { method: "POST", body: deleteBatchBodySchema },
738
+ async (ctx) => {
739
+ const config = ctx.context.config;
740
+ const request = ctx.context.request;
741
+ const results = [];
742
+ for (const key of ctx.body.keys) {
743
+ try {
744
+ await deleteOne(config, ctx.body.route, key, request);
745
+ results.push({ key, success: true });
746
+ } catch (err) {
747
+ results.push({
748
+ key,
749
+ success: false,
750
+ error: isDimahS3Error(err) ? { code: err.code ?? "INTERNAL_ERROR", message: err.message } : { code: "INTERNAL_ERROR", message: "Internal server error" }
751
+ });
752
+ }
753
+ }
754
+ return { results };
755
+ }
756
+ );
757
+ function fileUrl(request, basePath, query) {
758
+ const url = new URL(request.url);
759
+ const prefix = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
760
+ url.pathname = `${prefix}${S3_API_ROUTES.file}`;
761
+ url.search = "";
762
+ url.searchParams.set("route", query.route);
763
+ url.searchParams.set("key", query.key);
764
+ if (query.fileName) url.searchParams.set("fileName", query.fileName);
765
+ if (query.disposition) url.searchParams.set("disposition", query.disposition);
766
+ return url.toString();
767
+ }
407
768
  async function handleDownload(config, input, request) {
408
- const { key, bucket } = await resolveRequestTarget(config, config.download, {
769
+ const { route: route2, key, bucket, stored } = await openStoredTarget(
770
+ config,
771
+ input,
409
772
  request,
410
- key: input.key,
411
- bucket: input.bucket,
412
- fileName: input.fileName
773
+ "download"
774
+ );
775
+ const resolved = await route2.download.resolve?.({
776
+ ...stored,
777
+ fileName: input.fileName,
778
+ disposition: input.disposition ?? route2.download.disposition
413
779
  });
414
- const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
415
- const fileName = input.fileName;
416
- await runHook(config.download?.guard, {
417
- request,
418
- key,
419
- bucket,
420
- fileName
780
+ const fileName = resolved?.fileName ?? input.fileName;
781
+ const disposition = resolved?.disposition ?? input.disposition ?? route2.download.disposition ?? "attachment";
782
+ const expiresIn = normalizeExpiresIn(
783
+ resolved?.expiresIn ?? route2.download.expiresIn,
784
+ config.maxExpiresIn
785
+ );
786
+ await runHook(route2.download.guard, {
787
+ ...stored,
788
+ fileName,
789
+ disposition
421
790
  });
422
- await headObjectOrNotFound(config.client, bucket, key);
423
- const url = await getSignedUrl(
424
- config.client,
791
+ await headObjectOrNotFound(route2.client, bucket, key);
792
+ const mode = route2.download.mode ?? "presign";
793
+ const contentDisposition = fileName ? buildContentDisposition(fileName, disposition) : disposition;
794
+ const url = mode === "proxy" ? fileUrl(request, config.basePath ?? "/api/s3", {
795
+ route: input.route,
796
+ key,
797
+ fileName,
798
+ disposition
799
+ }) : await getSignedUrl(
800
+ route2.client,
425
801
  new GetObjectCommand({
426
802
  Bucket: bucket,
427
803
  Key: key,
428
- ResponseContentDisposition: fileName ? buildContentDisposition(fileName) : "attachment"
804
+ ResponseContentDisposition: contentDisposition
429
805
  }),
430
806
  { expiresIn }
431
807
  );
432
- await runLifecycleHook(config.download?.onPresigned, {
433
- request,
434
- key,
808
+ await runLifecycleHook(
809
+ route2.download.onPresigned,
810
+ {
811
+ ...stored,
812
+ fileName,
813
+ url,
814
+ expiresIn: mode === "proxy" ? 0 : expiresIn
815
+ },
816
+ config
817
+ );
818
+ return {
435
819
  bucket,
436
- fileName,
820
+ key,
437
821
  url,
438
- expiresIn
439
- });
440
- return { bucket, key, url, expiresIn };
822
+ expiresIn: mode === "proxy" ? 0 : expiresIn,
823
+ mode,
824
+ disposition
825
+ };
441
826
  }
442
827
  var download = createS3Endpoint(
443
828
  S3_API_ROUTES.download,
444
829
  { method: "GET", query: downloadQuerySchema },
445
830
  async (ctx) => {
446
- assertFeatureEnabled(ctx.context.config, "download");
447
831
  return handleDownload(ctx.context.config, ctx.query, ctx.context.request);
448
832
  }
449
833
  );
450
- async function handleAbort(config, input, request) {
451
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
834
+ function catalogUpload(upload2) {
835
+ if (!isEnabled(upload2)) return { enabled: false };
836
+ return {
837
+ enabled: true,
838
+ fileTypes: upload2.fileTypes,
839
+ maxFileSize: upload2.maxFileSize,
840
+ multipart: isEnabled(upload2.multipart),
841
+ ...upload2.checksum ? { checksum: true } : {},
842
+ ...upload2.replace ? { replace: upload2.replace } : {}
843
+ };
844
+ }
845
+ function catalogDownload(download2) {
846
+ if (!isEnabled(download2)) return { enabled: false };
847
+ return {
848
+ enabled: true,
849
+ ...download2.disposition ? { disposition: download2.disposition } : {},
850
+ ...download2.mode ? { mode: download2.mode } : {}
851
+ };
852
+ }
853
+ function buildRouteCatalog(config) {
854
+ const routes = {};
855
+ for (const [name, route2] of Object.entries(config.routes)) {
856
+ routes[name] = {
857
+ upload: catalogUpload(route2.upload),
858
+ download: catalogDownload(route2.download),
859
+ delete: isEnabled(route2.delete) ? { enabled: true } : { enabled: false }
860
+ };
861
+ }
862
+ return { routes };
863
+ }
864
+ var catalog = createS3Endpoint(
865
+ S3_API_ROUTES.catalog,
866
+ { method: "GET" },
867
+ async (ctx) => {
868
+ return buildRouteCatalog(ctx.context.config);
869
+ }
870
+ );
871
+ async function handleFile(config, input, request) {
872
+ const { route: route2, key, bucket, stored } = await openStoredTarget(
873
+ config,
874
+ input,
452
875
  request,
453
- key: input.key,
454
- bucket: input.bucket
876
+ "download"
877
+ );
878
+ const resolved = await route2.download.resolve?.({
879
+ ...stored,
880
+ fileName: input.fileName,
881
+ disposition: input.disposition ?? route2.download.disposition
455
882
  });
456
- const uploadId = input.uploadId;
457
- await runHook(config.multipart?.abortGuard, {
883
+ const fileName = resolved?.fileName ?? input.fileName;
884
+ const disposition = resolved?.disposition ?? input.disposition ?? route2.download.disposition ?? "attachment";
885
+ await runHook(route2.download.guard, {
886
+ ...stored,
887
+ fileName,
888
+ disposition
889
+ });
890
+ await headObjectOrNotFound(route2.client, bucket, key);
891
+ const result = await sendOrObjectNotFound(
892
+ () => route2.client.send(
893
+ new GetObjectCommand({
894
+ Bucket: bucket,
895
+ Key: key,
896
+ ResponseContentDisposition: fileName ? buildContentDisposition(fileName, disposition) : `${disposition}`
897
+ })
898
+ )
899
+ );
900
+ const body = result.Body;
901
+ if (!body) throw errors.objectNotFound();
902
+ const stream = "transformToWebStream" in body && typeof body.transformToWebStream === "function" ? body.transformToWebStream() : body;
903
+ const headers = new Headers();
904
+ headers.set("Content-Type", result.ContentType ?? "application/octet-stream");
905
+ headers.set(
906
+ "Content-Disposition",
907
+ fileName ? buildContentDisposition(fileName, disposition) : `${disposition}`
908
+ );
909
+ if (typeof result.ContentLength === "number") {
910
+ headers.set("Content-Length", String(result.ContentLength));
911
+ }
912
+ return new Response(stream, { headers });
913
+ }
914
+ var file = createS3Endpoint(
915
+ S3_API_ROUTES.file,
916
+ { method: "GET", query: fileQuerySchema },
917
+ async (ctx) => {
918
+ return handleFile(ctx.context.config, ctx.query, ctx.context.request);
919
+ }
920
+ );
921
+ async function handleAbort(config, input, request) {
922
+ const { route: route2, key, bucket, stored } = await openStoredTarget(
923
+ config,
924
+ input,
458
925
  request,
459
- key,
460
- bucket,
461
- uploadId
926
+ "multipart"
927
+ );
928
+ const uploadId = input.uploadId;
929
+ await runHook(route2.upload.multipart.guard, {
930
+ ...stored,
931
+ uploadId,
932
+ action: "abort"
462
933
  });
463
934
  await sendOrObjectNotFound(
464
- () => config.client.send(
935
+ () => route2.client.send(
465
936
  new AbortMultipartUploadCommand({
466
937
  Bucket: bucket,
467
938
  Key: key,
@@ -469,52 +940,68 @@ async function handleAbort(config, input, request) {
469
940
  })
470
941
  )
471
942
  );
472
- await runLifecycleHook(config.multipart?.onAbort, {
473
- request,
474
- key,
475
- bucket,
476
- uploadId
477
- });
943
+ await runLifecycleHook(
944
+ route2.upload.multipart.onAbort,
945
+ {
946
+ ...stored,
947
+ uploadId
948
+ },
949
+ config
950
+ );
478
951
  return { bucket, key, uploadId, aborted: true };
479
952
  }
480
953
  var multipartAbort = createS3Endpoint(
481
954
  S3_API_ROUTES.multipartAbort,
482
955
  { method: "POST", body: multipartAbortBodySchema },
483
956
  async (ctx) => {
484
- assertFeatureEnabled(ctx.context.config, "multipart");
485
957
  return handleAbort(ctx.context.config, ctx.body, ctx.context.request);
486
958
  }
487
959
  );
488
960
  async function handleComplete(config, input, request) {
489
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
961
+ const { route: route2, key, bucket, stored } = await openStoredTarget(
962
+ config,
963
+ input,
490
964
  request,
491
- key: input.key,
492
- bucket: input.bucket
493
- });
965
+ "multipart"
966
+ );
494
967
  const uploadId = input.uploadId;
495
- const parts = input.parts.map(({ partNumber }) => partNumber).sort((a, b) => a - b);
968
+ const parts = [
969
+ ...new Set(input.parts.map(({ partNumber }) => partNumber))
970
+ ].sort((a, b) => a - b);
496
971
  const partRefs = parts.map((partNumber) => ({ partNumber }));
497
- await runHook(config.multipart?.completeGuard, {
498
- request,
499
- key,
500
- bucket,
972
+ await runHook(route2.upload.multipart.guard, {
973
+ ...stored,
974
+ uploadId,
975
+ action: "complete"
976
+ });
977
+ await runHook(route2.upload.confirmGuard, {
978
+ ...stored,
501
979
  uploadId,
502
- parts: partRefs
980
+ parts: partRefs,
981
+ replace: route2.upload.replace
503
982
  });
504
- const listedParts = await listAllParts(config.client, {
983
+ const listedParts = await listAllParts(route2.client, {
505
984
  bucket,
506
985
  key,
507
986
  uploadId
508
987
  });
988
+ let assembledBytes = 0;
509
989
  const completeParts = parts.map((partNumber) => {
510
990
  const found = listedParts.find((p) => p.PartNumber === partNumber);
511
991
  if (!found?.ETag) {
512
992
  throw errors.multipartPartMissing(partNumber);
513
993
  }
994
+ assembledBytes += found.Size ?? 0;
514
995
  return { PartNumber: partNumber, ETag: found.ETag };
515
996
  });
997
+ try {
998
+ assertWithinMaxFileSize(route2.upload.maxFileSize, assembledBytes);
999
+ } catch (err) {
1000
+ await abortMultipartBestEffort(route2.client, bucket, key, uploadId);
1001
+ throw err;
1002
+ }
516
1003
  const completeResult = await sendOrObjectNotFound(
517
- () => config.client.send(
1004
+ () => route2.client.send(
518
1005
  new CompleteMultipartUploadCommand({
519
1006
  Bucket: bucket,
520
1007
  Key: key,
@@ -524,273 +1011,290 @@ async function handleComplete(config, input, request) {
524
1011
  )
525
1012
  );
526
1013
  const head = await headObjectAfterMultipartComplete(
527
- config.client,
1014
+ route2.client,
528
1015
  bucket,
529
1016
  key
530
1017
  );
531
- const contentLength = requireContentLength(head);
532
- const contentType = head.ContentType;
533
- const eTag = (head.ETag ?? completeResult.ETag ?? "").replace(/"/g, "");
534
- const metadata = head.Metadata ?? {};
535
- const versionId = head.VersionId;
536
- const lastModified = head.LastModified?.toISOString();
537
- const acl = config.resolveObjectAcl ? await resolveObjectAcl(config.client, bucket, key) : void 0;
538
- const fileName = parseFileName(head.ContentDisposition);
539
- await runLifecycleHook(config.multipart?.onComplete, {
540
- request,
541
- key,
542
- bucket,
1018
+ const confirmed = await finalizeConfirmedObject(route2.client, bucket, key, {
1019
+ config,
1020
+ route: route2,
1021
+ stored,
1022
+ head: {
1023
+ ...head,
1024
+ ETag: head.ETag ?? completeResult.ETag
1025
+ },
543
1026
  uploadId,
544
- contentLength,
545
- contentType,
546
- eTag,
547
- metadata,
548
- acl,
549
- fileName,
550
- versionId,
551
- lastModified
1027
+ acl: route2.upload.acl
552
1028
  });
553
- return {
554
- bucket,
555
- key,
556
- uploadId,
557
- contentLength,
558
- contentType,
559
- eTag,
560
- metadata,
561
- acl,
562
- fileName,
563
- versionId,
564
- lastModified
565
- };
1029
+ return { ...confirmed, uploadId };
566
1030
  }
567
1031
  var multipartComplete = createS3Endpoint(
568
1032
  S3_API_ROUTES.multipartComplete,
569
1033
  { method: "POST", body: multipartCompleteBodySchema },
570
1034
  async (ctx) => {
571
- assertFeatureEnabled(ctx.context.config, "multipart");
572
1035
  return handleComplete(ctx.context.config, ctx.body, ctx.context.request);
573
1036
  }
574
1037
  );
575
1038
  async function handleMultipartInit(config, input, request) {
576
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
577
- request,
578
- key: input.key,
579
- bucket: input.bucket,
580
- fileName: input.fileName,
581
- contentType: input.contentType
582
- });
583
- const acl = resolveRequestAcl(config.multipart, input.acl);
584
- const fileSize = typeof input.fileSize === "number" && input.fileSize > 0 ? Math.floor(input.fileSize) : void 0;
585
- if (config.multipart?.requireFileSize && fileSize === void 0) {
586
- throw errors.fileSizeRequiredMultipart();
587
- }
588
- await runHook(config.multipart?.initGuard, {
1039
+ const fileSize = Math.floor(input.fileSize);
1040
+ const fileName = input.fileName;
1041
+ const { route: route2, key, bucket, metadata, acl, stored } = await openUploadTarget(
1042
+ config,
1043
+ {
1044
+ route: input.route,
1045
+ fileName,
1046
+ fileSize,
1047
+ contentType: input.contentType,
1048
+ metadata: input.metadata
1049
+ },
589
1050
  request,
590
- key,
591
- bucket,
592
- fileSize,
593
- contentType: input.contentType,
594
- metadata: input.metadata,
1051
+ "multipart"
1052
+ );
1053
+ await runHook(route2.upload.guard, {
1054
+ ...stored,
1055
+ file: {
1056
+ name: fileName,
1057
+ size: fileSize,
1058
+ type: input.contentType
1059
+ },
1060
+ metadata,
1061
+ clientMetadata: input.metadata,
595
1062
  acl,
596
- fileName: input.fileName
1063
+ replace: route2.upload.replace
597
1064
  });
598
- const { UploadId } = await config.client.send(
1065
+ const { UploadId } = await route2.client.send(
599
1066
  new CreateMultipartUploadCommand({
600
1067
  Bucket: bucket,
601
1068
  Key: key,
602
1069
  ContentType: input.contentType,
603
- ContentDisposition: input.fileName ? buildContentDisposition(input.fileName) : void 0,
604
- Metadata: input.metadata,
1070
+ ContentDisposition: buildContentDisposition(fileName),
1071
+ Metadata: metadata,
605
1072
  ACL: acl
606
1073
  })
607
1074
  );
608
1075
  if (!UploadId) {
609
1076
  throw errors.internalError();
610
1077
  }
611
- await runLifecycleHook(config.multipart?.onInit, {
612
- request,
613
- key,
614
- bucket,
615
- uploadId: UploadId,
616
- contentType: input.contentType,
617
- fileSize,
618
- metadata: input.metadata,
619
- acl,
620
- fileName: input.fileName
621
- });
1078
+ await runLifecycleHook(
1079
+ route2.upload.multipart.onInit,
1080
+ {
1081
+ ...stored,
1082
+ uploadId: UploadId,
1083
+ file: {
1084
+ name: fileName,
1085
+ size: fileSize,
1086
+ type: input.contentType
1087
+ },
1088
+ metadata,
1089
+ clientMetadata: input.metadata,
1090
+ acl,
1091
+ replace: route2.upload.replace
1092
+ },
1093
+ config
1094
+ );
622
1095
  return { bucket, key, uploadId: UploadId };
623
1096
  }
624
1097
  var multipartInit = createS3Endpoint(
625
1098
  S3_API_ROUTES.multipartInit,
626
1099
  { method: "POST", body: multipartInitBodySchema },
627
1100
  async (ctx) => {
628
- assertFeatureEnabled(ctx.context.config, "multipart");
629
- ctx.setStatus(201);
630
- return handleMultipartInit(
1101
+ const result = await handleMultipartInit(
631
1102
  ctx.context.config,
632
1103
  ctx.body,
633
1104
  ctx.context.request
634
1105
  );
1106
+ ctx.setStatus(201);
1107
+ return result;
635
1108
  }
636
1109
  );
637
1110
  async function handleListParts(config, input, request) {
638
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
1111
+ const { route: route2, key, bucket, stored } = await openStoredTarget(
1112
+ config,
1113
+ input,
639
1114
  request,
640
- key: input.key,
641
- bucket: input.bucket
642
- });
1115
+ "multipart"
1116
+ );
643
1117
  const uploadId = input.uploadId;
644
- await runHook(config.multipart?.listGuard, {
645
- request,
646
- key,
647
- bucket,
648
- uploadId
649
- });
650
- const listed = await listAllParts(config.client, { bucket, key, uploadId });
651
- const parts = listed.map((p) => ({
652
- partNumber: p.PartNumber ?? 0,
653
- size: p.Size ?? 0,
654
- eTag: (p.ETag ?? "").replace(/"/g, "")
655
- }));
656
- await runLifecycleHook(config.multipart?.onList, {
657
- request,
658
- key,
659
- bucket,
1118
+ await runHook(route2.upload.multipart.guard, {
1119
+ ...stored,
660
1120
  uploadId,
661
- parts
1121
+ action: "list"
662
1122
  });
1123
+ const listed = await listAllParts(route2.client, { bucket, key, uploadId });
1124
+ const parts = listed.flatMap((p) => {
1125
+ if (p.PartNumber == null || p.PartNumber < 1) return [];
1126
+ return [
1127
+ {
1128
+ partNumber: p.PartNumber,
1129
+ size: p.Size ?? 0,
1130
+ eTag: (p.ETag ?? "").replace(/"/g, "")
1131
+ }
1132
+ ];
1133
+ });
1134
+ await runLifecycleHook(
1135
+ route2.upload.multipart.onList,
1136
+ {
1137
+ ...stored,
1138
+ uploadId,
1139
+ parts
1140
+ },
1141
+ config
1142
+ );
663
1143
  return { parts };
664
1144
  }
665
1145
  var multipartListParts = createS3Endpoint(
666
1146
  S3_API_ROUTES.multipartListParts,
667
1147
  { method: "GET", query: multipartListPartsQuerySchema },
668
1148
  async (ctx) => {
669
- assertFeatureEnabled(ctx.context.config, "multipart");
670
1149
  return handleListParts(ctx.context.config, ctx.query, ctx.context.request);
671
1150
  }
672
1151
  );
673
1152
  async function handleSignPart(config, input, request) {
674
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
1153
+ const { route: route2, key, bucket, stored } = await openStoredTarget(
1154
+ config,
1155
+ input,
675
1156
  request,
676
- key: input.key,
677
- bucket: input.bucket
678
- });
1157
+ "multipart"
1158
+ );
679
1159
  const uploadId = input.uploadId;
680
1160
  const partNumber = input.partNumber;
681
- const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
682
- const partSize = typeof input.partSize === "number" && input.partSize > 0 ? Math.floor(input.partSize) : null;
683
- await runHook(config.multipart?.partGuard, {
684
- request,
685
- key,
686
- bucket,
1161
+ const expiresIn = normalizeExpiresIn(
1162
+ route2.upload.expiresIn,
1163
+ config.maxExpiresIn
1164
+ );
1165
+ const partSize = Math.floor(input.partSize);
1166
+ await runHook(route2.upload.multipart.guard, {
1167
+ ...stored,
687
1168
  uploadId,
1169
+ action: "part",
688
1170
  partNumber,
689
- partSize: partSize ?? void 0
1171
+ partSize
690
1172
  });
691
- const presignedUrl = await getSignedUrl(
692
- config.client,
1173
+ const maxFileSize = route2.upload.maxFileSize;
1174
+ assertWithinMaxFileSize(maxFileSize, partSize);
1175
+ if (typeof maxFileSize === "number") {
1176
+ const listed = await listAllParts(route2.client, { bucket, key, uploadId });
1177
+ assertWithinMaxFileSize(
1178
+ maxFileSize,
1179
+ listedPartsByteSize(listed, partNumber) + partSize
1180
+ );
1181
+ }
1182
+ const url = await getSignedUrl(
1183
+ route2.client,
693
1184
  new UploadPartCommand({
694
1185
  Bucket: bucket,
695
1186
  Key: key,
696
1187
  UploadId: uploadId,
697
1188
  PartNumber: partNumber,
698
- ...partSize !== null ? { ContentLength: partSize } : {}
1189
+ ContentLength: partSize
699
1190
  }),
700
1191
  {
701
1192
  expiresIn,
702
- ...partSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
1193
+ signableHeaders: /* @__PURE__ */ new Set(["content-length"])
703
1194
  }
704
1195
  );
705
1196
  return {
706
- presignedUrl,
1197
+ url,
707
1198
  partNumber,
708
1199
  uploadId,
709
1200
  bucket,
710
1201
  expiresIn,
711
- ...partSize !== null ? { partSize } : {}
1202
+ partSize
712
1203
  };
713
1204
  }
714
1205
  var multipartPart = createS3Endpoint(
715
1206
  S3_API_ROUTES.multipartPart,
716
1207
  { method: "POST", body: multipartSignPartBodySchema },
717
1208
  async (ctx) => {
718
- assertFeatureEnabled(ctx.context.config, "multipart");
719
1209
  return handleSignPart(ctx.context.config, ctx.body, ctx.context.request);
720
1210
  }
721
1211
  );
722
- function putMetadataHeaders(metadata) {
1212
+ function objectUserMetadata(metadata) {
723
1213
  if (!metadata) return {};
724
- const headers = {};
1214
+ const fields = {};
725
1215
  for (const [k, v] of Object.entries(metadata)) {
726
- headers[`x-amz-meta-${k}`] = v;
1216
+ fields[`x-amz-meta-${k}`] = v;
727
1217
  }
728
- return headers;
1218
+ return fields;
729
1219
  }
730
1220
  async function handleUpload(config, input, request) {
731
- const { key, bucket } = await resolveRequestTarget(config, config.upload, {
1221
+ const fileSize = Math.floor(input.fileSize);
1222
+ const fileName = input.fileName;
1223
+ const { route: route2, key, bucket, metadata, acl, stored } = await openUploadTarget(
1224
+ config,
1225
+ {
1226
+ route: input.route,
1227
+ fileName,
1228
+ fileSize,
1229
+ contentType: input.contentType,
1230
+ metadata: input.metadata
1231
+ },
732
1232
  request,
733
- key: input.key,
734
- bucket: input.bucket,
735
- fileName: input.fileName,
736
- contentType: input.contentType
737
- });
738
- const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
739
- const acl = resolveRequestAcl(config.upload, input.acl);
740
- const contentType = input.contentType ?? "application/octet-stream";
741
- const fileSize = typeof input.fileSize === "number" && input.fileSize > 0 ? Math.floor(input.fileSize) : null;
742
- if (config.upload?.requireFileSize && fileSize === null) {
743
- throw errors.fileSizeRequiredUpload();
1233
+ "upload"
1234
+ );
1235
+ const method = route2.upload.method ?? "POST";
1236
+ if (route2.upload.checksum && !input.checksum) {
1237
+ throw errors.validationError("Checksum is required for this route");
744
1238
  }
745
- await runHook(config.upload?.guard, {
746
- request,
747
- key,
748
- bucket,
749
- contentType: input.contentType,
750
- fileSize: fileSize ?? void 0,
751
- metadata: input.metadata,
1239
+ if (method === "POST" && fileSize > S3_MAX_POST_OBJECT_BYTES) {
1240
+ throw errors.payloadTooLarge(
1241
+ `POST uploads cannot exceed ${S3_MAX_POST_OBJECT_BYTES} bytes. Enable upload.multipart or set upload.method to "PUT".`
1242
+ );
1243
+ }
1244
+ const expiresIn = normalizeExpiresIn(
1245
+ route2.upload.expiresIn,
1246
+ config.maxExpiresIn
1247
+ );
1248
+ const contentType = input.contentType ?? "application/octet-stream";
1249
+ const hookCtx = {
1250
+ ...stored,
1251
+ file: {
1252
+ name: fileName,
1253
+ size: fileSize,
1254
+ type: input.contentType
1255
+ },
1256
+ metadata,
1257
+ clientMetadata: input.metadata,
752
1258
  acl,
753
- fileName: input.fileName
754
- });
755
- const method = config.upload?.method ?? "POST";
1259
+ replace: route2.upload.replace
1260
+ };
1261
+ await runHook(route2.upload.guard, hookCtx);
756
1262
  if (method === "PUT") {
757
1263
  const putHeaders = {
758
1264
  "Content-Type": contentType,
759
- ...putMetadataHeaders(input.metadata)
1265
+ ...objectUserMetadata(metadata),
1266
+ "Content-Disposition": buildContentDisposition(fileName),
1267
+ ...input.checksum ? { "x-amz-checksum-sha256": input.checksum } : {}
760
1268
  };
761
- if (input.fileName) {
762
- putHeaders["Content-Disposition"] = buildContentDisposition(
763
- input.fileName
764
- );
765
- }
766
1269
  const url2 = await getSignedUrl(
767
- config.client,
1270
+ route2.client,
768
1271
  new PutObjectCommand({
769
1272
  Bucket: bucket,
770
1273
  Key: key,
771
1274
  ContentType: contentType,
772
1275
  ACL: acl,
773
- Metadata: input.metadata,
774
- ...input.fileName ? { ContentDisposition: buildContentDisposition(input.fileName) } : {},
775
- ...fileSize !== null ? { ContentLength: fileSize } : {}
1276
+ Metadata: metadata,
1277
+ ContentDisposition: buildContentDisposition(fileName),
1278
+ ContentLength: fileSize,
1279
+ ...input.checksum ? { ChecksumSHA256: input.checksum, ChecksumAlgorithm: "SHA256" } : {}
776
1280
  }),
777
1281
  {
778
1282
  expiresIn,
779
- ...fileSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
1283
+ signableHeaders: /* @__PURE__ */ new Set([
1284
+ "content-length",
1285
+ ...input.checksum ? ["x-amz-checksum-sha256"] : []
1286
+ ])
780
1287
  }
781
1288
  );
782
- await runLifecycleHook(config.upload?.onPresigned, {
783
- request,
784
- key,
785
- bucket,
786
- contentType: input.contentType,
787
- fileSize: fileSize ?? void 0,
788
- metadata: input.metadata,
789
- acl,
790
- fileName: input.fileName,
791
- url: url2,
792
- expiresIn
793
- });
1289
+ await runLifecycleHook(
1290
+ route2.upload.onPresigned,
1291
+ {
1292
+ ...hookCtx,
1293
+ url: url2,
1294
+ expiresIn
1295
+ },
1296
+ config
1297
+ );
794
1298
  return {
795
1299
  bucket,
796
1300
  key,
@@ -800,39 +1304,32 @@ async function handleUpload(config, input, request) {
800
1304
  method: "PUT"
801
1305
  };
802
1306
  }
803
- const fields = { acl, "Content-Type": contentType };
804
- if (input.fileName) {
805
- fields["Content-Disposition"] = buildContentDisposition(input.fileName);
806
- }
807
- if (input.metadata) {
808
- for (const [k, v] of Object.entries(input.metadata)) {
809
- fields[`x-amz-meta-${k}`] = v;
810
- }
811
- }
812
- const rangeMin = fileSize ?? 1;
813
- const rangeMax = fileSize ?? void 0;
1307
+ const fields = {
1308
+ acl,
1309
+ "Content-Type": contentType,
1310
+ "Content-Disposition": buildContentDisposition(fileName),
1311
+ ...objectUserMetadata(metadata),
1312
+ ...input.checksum ? { "x-amz-checksum-sha256": input.checksum } : {}
1313
+ };
814
1314
  const { url, fields: signedFields } = await createPresignedPost(
815
- config.client,
1315
+ route2.client,
816
1316
  {
817
1317
  Bucket: bucket,
818
1318
  Key: key,
819
- Conditions: rangeMax !== void 0 ? [["content-length-range", rangeMin, rangeMax]] : [["content-length-range", rangeMin, S3_MAX_POST_OBJECT_BYTES]],
1319
+ Conditions: [["content-length-range", fileSize, fileSize]],
820
1320
  Fields: fields,
821
1321
  Expires: expiresIn
822
1322
  }
823
1323
  );
824
- await runLifecycleHook(config.upload?.onPresigned, {
825
- request,
826
- key,
827
- bucket,
828
- contentType: input.contentType,
829
- fileSize: fileSize ?? void 0,
830
- metadata: input.metadata,
831
- acl,
832
- fileName: input.fileName,
833
- url,
834
- expiresIn
835
- });
1324
+ await runLifecycleHook(
1325
+ route2.upload.onPresigned,
1326
+ {
1327
+ ...hookCtx,
1328
+ url,
1329
+ expiresIn
1330
+ },
1331
+ config
1332
+ );
836
1333
  return {
837
1334
  bucket,
838
1335
  key,
@@ -846,7 +1343,6 @@ var upload = createS3Endpoint(
846
1343
  S3_API_ROUTES.upload,
847
1344
  { method: "POST", body: uploadBodySchema },
848
1345
  async (ctx) => {
849
- assertFeatureEnabled(ctx.context.config, "upload");
850
1346
  return handleUpload(ctx.context.config, ctx.body, ctx.context.request);
851
1347
  }
852
1348
  );
@@ -856,7 +1352,10 @@ var coreEndpoints = {
856
1352
  upload,
857
1353
  confirm,
858
1354
  download,
1355
+ catalog,
1356
+ file,
859
1357
  delete: deleteObject,
1358
+ deleteBatch,
860
1359
  multipartInit,
861
1360
  multipartPart,
862
1361
  multipartListParts,
@@ -880,19 +1379,24 @@ function chainHooks(...hooks) {
880
1379
  var FEATURE_HOOK_KEYS = {
881
1380
  upload: ["guard", "onPresigned", "confirmGuard", "onConfirmed"],
882
1381
  download: ["guard", "onPresigned"],
883
- delete: ["guard", "onDeleted"],
884
- multipart: [
885
- "initGuard",
886
- "partGuard",
887
- "completeGuard",
888
- "abortGuard",
889
- "listGuard",
890
- "onInit",
891
- "onComplete",
892
- "onAbort",
893
- "onList"
894
- ]
1382
+ delete: ["guard", "onDeleted"]
1383
+ };
1384
+ var MULTIPART_HOOK_KEYS = [
1385
+ "onInit",
1386
+ "guard",
1387
+ "onAbort",
1388
+ "onList"
1389
+ ];
1390
+ var LIFECYCLE_FEATURE_HOOK_KEYS = {
1391
+ upload: ["onPresigned", "onConfirmed"],
1392
+ download: ["onPresigned"],
1393
+ delete: ["onDeleted"]
895
1394
  };
1395
+ var LIFECYCLE_MULTIPART_HOOK_KEYS = [
1396
+ "onInit",
1397
+ "onAbort",
1398
+ "onList"
1399
+ ];
896
1400
 
897
1401
  // src/plugin/types.ts
898
1402
  var RESERVED_PLUGIN_IDS = [
@@ -901,35 +1405,74 @@ var RESERVED_PLUGIN_IDS = [
901
1405
  "context",
902
1406
  "getPlugin",
903
1407
  "$ERROR_CODES",
904
- "$Infer"
1408
+ "$Infer",
1409
+ "upload",
1410
+ "confirm",
1411
+ "download",
1412
+ "catalog",
1413
+ "file",
1414
+ "delete",
1415
+ "deleteBatch",
1416
+ "put",
1417
+ "multipart"
905
1418
  ];
906
1419
 
907
1420
  // src/plugin/apply-plugins.ts
908
- function mergeHookField(pluginHooks, userHook) {
909
- const present = [...pluginHooks, userHook].filter(
910
- (h) => h != null
911
- );
1421
+ function mergeHookField(pluginHooks, userHook, order = "plugins-first") {
1422
+ const present = (order === "user-first" ? [userHook, ...pluginHooks] : [...pluginHooks, userHook]).filter((h) => h != null);
912
1423
  if (present.length === 0) return void 0;
913
1424
  if (present.length === 1) return present[0];
914
1425
  return chainHooks(...present);
915
1426
  }
916
- function mergeFeatureHooks(feature, keys, plugins, userFeature) {
917
- if (!userFeature && plugins.every((p) => !p.hooks?.[feature])) {
918
- return userFeature;
919
- }
920
- const merged = { ...userFeature ?? {} };
1427
+ function mergeHooks(keys, plugins, userFeature, pick, lifecycleKeys = /* @__PURE__ */ new Set()) {
1428
+ const merged = { ...userFeature };
921
1429
  for (const key of keys) {
922
- const fromPlugins = plugins.map(
923
- (p) => p.hooks?.[feature]?.[key]
1430
+ const next = mergeHookField(
1431
+ plugins.map((plugin) => pick(plugin)?.[key]),
1432
+ userFeature[key],
1433
+ lifecycleKeys.has(key) ? "user-first" : "plugins-first"
924
1434
  );
925
- const userHook = userFeature?.[key];
926
- const next = mergeHookField(fromPlugins, userHook);
927
- if (next !== void 0) {
928
- merged[key] = next;
929
- }
1435
+ if (next !== void 0) merged[key] = next;
930
1436
  }
931
1437
  return merged;
932
1438
  }
1439
+ function applyHooksToRoute(route2, plugins) {
1440
+ const active = plugins.filter((p) => !route2.skippedPluginIds.has(p.id));
1441
+ const upload2 = isEnabled(route2.upload) ? {
1442
+ ...mergeHooks(
1443
+ FEATURE_HOOK_KEYS.upload,
1444
+ active,
1445
+ route2.upload,
1446
+ (p) => p.hooks?.upload,
1447
+ new Set(LIFECYCLE_FEATURE_HOOK_KEYS.upload)
1448
+ ),
1449
+ multipart: isEnabled(route2.upload.multipart) ? mergeHooks(
1450
+ MULTIPART_HOOK_KEYS,
1451
+ active,
1452
+ route2.upload.multipart,
1453
+ (p) => p.hooks?.upload?.multipart,
1454
+ new Set(LIFECYCLE_MULTIPART_HOOK_KEYS)
1455
+ ) : route2.upload.multipart
1456
+ } : route2.upload;
1457
+ return {
1458
+ ...route2,
1459
+ upload: upload2,
1460
+ download: isEnabled(route2.download) ? mergeHooks(
1461
+ FEATURE_HOOK_KEYS.download,
1462
+ active,
1463
+ route2.download,
1464
+ (p) => p.hooks?.download,
1465
+ new Set(LIFECYCLE_FEATURE_HOOK_KEYS.download)
1466
+ ) : route2.download,
1467
+ delete: isEnabled(route2.delete) ? mergeHooks(
1468
+ FEATURE_HOOK_KEYS.delete,
1469
+ active,
1470
+ route2.delete,
1471
+ (p) => p.hooks?.delete,
1472
+ new Set(LIFECYCLE_FEATURE_HOOK_KEYS.delete)
1473
+ ) : route2.delete
1474
+ };
1475
+ }
933
1476
  function applyPlugins(config) {
934
1477
  const plugins = config.plugins ?? [];
935
1478
  const seen = /* @__PURE__ */ new Set();
@@ -1018,37 +1561,25 @@ function applyPlugins(config) {
1018
1561
  }
1019
1562
  }
1020
1563
  const { plugins: _omit, ...rest } = config;
1021
- assertExclusiveBucketFlags(rest);
1564
+ const normalized = normalizeRoutes(rest);
1022
1565
  for (const plugin of plugins) {
1023
1566
  plugin.init?.({ config: rest, getPlugin });
1024
1567
  }
1025
- const features = normalizeFeatures(rest);
1026
- const {
1027
- upload: _upload,
1028
- download: _download,
1029
- delete: _delete,
1030
- multipart: _multipart,
1031
- ...base
1032
- } = rest;
1568
+ const routes = {};
1569
+ for (const [name, route2] of Object.entries(normalized)) {
1570
+ routes[name] = applyHooksToRoute(route2, plugins);
1571
+ }
1033
1572
  const guard = mergeHookField(
1034
1573
  plugins.map((p) => p.hooks?.guard),
1035
1574
  rest.guard
1036
1575
  );
1037
1576
  const merged = {
1038
- ...base,
1039
- ...features,
1577
+ ...rest,
1578
+ routes,
1040
1579
  ...guard !== void 0 ? { guard } : {}
1041
1580
  };
1042
- for (const feature of Object.keys(FEATURE_HOOK_KEYS)) {
1043
- merged[feature] = mergeFeatureHooks(
1044
- feature,
1045
- FEATURE_HOOK_KEYS[feature],
1046
- plugins,
1047
- merged[feature]
1048
- );
1049
- }
1050
1581
  return {
1051
- config: applyMultipartDefault(merged, rest.multipart),
1582
+ config: merged,
1052
1583
  context,
1053
1584
  getPlugin,
1054
1585
  endpoints
@@ -1089,15 +1620,18 @@ var NETWORK_CODES = /* @__PURE__ */ new Set([
1089
1620
  "ETIMEDOUT",
1090
1621
  "ENOTFOUND"
1091
1622
  ]);
1092
- function onS3RouterError(error) {
1623
+ function onS3RouterError(error, config) {
1093
1624
  if (isAPIError(error)) return;
1094
1625
  const code = error?.code;
1626
+ const logger = resolveLogger(config?.logger);
1095
1627
  if (typeof code === "string" && NETWORK_CODES.has(code)) {
1096
1628
  const networkErr = errors.s3NetworkError(code);
1097
- console.error("[S3 API]", networkErr.message, error);
1629
+ logger.error?.(networkErr.message, error);
1630
+ config?.onError?.(error, {});
1098
1631
  throw networkErr;
1099
1632
  }
1100
- console.error("[S3 API]", error);
1633
+ logger.error?.(error instanceof Error ? error.message : String(error), error);
1634
+ config?.onError?.(error, {});
1101
1635
  throw errors.internalError();
1102
1636
  }
1103
1637
  function withUnmatchedRouteJson(handler) {
@@ -1117,7 +1651,7 @@ function createS3Router(endpoints, env) {
1117
1651
  basePath,
1118
1652
  routerContext: { config: env.config },
1119
1653
  openapi: { disabled: true },
1120
- onError: onS3RouterError
1654
+ onError: (error) => onS3RouterError(error, env.config)
1121
1655
  });
1122
1656
  return {
1123
1657
  endpoints: bindEndpoints(router.endpoints, { config: env.config }),
@@ -1138,6 +1672,7 @@ function dimahS3(config) {
1138
1672
  const bound = router.endpoints;
1139
1673
  const api = {
1140
1674
  ...bound,
1675
+ deleteMany: bound.deleteBatch,
1141
1676
  multipart: {
1142
1677
  init: bound.multipartInit,
1143
1678
  signPart: bound.multipartPart,
@@ -1153,10 +1688,15 @@ function dimahS3(config) {
1153
1688
  getPlugin,
1154
1689
  $ERROR_CODES: S3_ERROR_CODES,
1155
1690
  $Infer: {},
1691
+ put: (input) => putObject(resolved, input),
1156
1692
  ...context
1157
1693
  };
1158
1694
  }
1159
1695
 
1160
- export { createS3Endpoint, createS3Middleware, definePlugin, dimahS3, errors };
1696
+ // src/types/config.ts
1697
+ var ROUTE_FEATURES = ["upload", "download", "delete"];
1698
+ var ROUTE_OPERATIONS = [...ROUTE_FEATURES, "multipart"];
1699
+
1700
+ export { ROUTE_FEATURES, ROUTE_OPERATIONS, chainHooks, createS3Endpoint, createS3Middleware, definePlugin, dimahS3, errors, isFeatureOn, putObject, route };
1161
1701
  //# sourceMappingURL=index.js.map
1162
1702
  //# sourceMappingURL=index.js.map