@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.
- package/README.md +7 -3
- package/dist/adapters/express.js +16 -9
- package/dist/adapters/express.js.map +1 -1
- package/dist/adapters/fastify.js +16 -9
- package/dist/adapters/fastify.js.map +1 -1
- package/dist/adapters/hono.d.ts +1 -1
- package/dist/adapters/hono.js.map +1 -1
- package/dist/adapters/node.d.ts +1 -1
- package/dist/adapters/node.js +16 -9
- package/dist/adapters/node.js.map +1 -1
- package/dist/api/index.js +742 -388
- package/dist/api/index.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/routes/catalog.d.ts +9 -0
- package/dist/api/routes/catalog.d.ts.map +1 -0
- package/dist/api/routes/confirm.d.ts +3 -4
- package/dist/api/routes/confirm.d.ts.map +1 -1
- package/dist/api/routes/delete-batch.d.ts +14 -0
- package/dist/api/routes/delete-batch.d.ts.map +1 -0
- package/dist/api/routes/delete.d.ts +2 -2
- package/dist/api/routes/delete.d.ts.map +1 -1
- package/dist/api/routes/download.d.ts +8 -8
- package/dist/api/routes/download.d.ts.map +1 -1
- package/dist/api/routes/file.d.ts +20 -0
- package/dist/api/routes/file.d.ts.map +1 -0
- package/dist/api/routes/index.d.ts +76 -50
- package/dist/api/routes/index.d.ts.map +1 -1
- package/dist/api/routes/multipart/abort.d.ts +2 -2
- package/dist/api/routes/multipart/abort.d.ts.map +1 -1
- package/dist/api/routes/multipart/complete.d.ts +2 -2
- package/dist/api/routes/multipart/complete.d.ts.map +1 -1
- package/dist/api/routes/multipart/init.d.ts +10 -12
- package/dist/api/routes/multipart/init.d.ts.map +1 -1
- package/dist/api/routes/multipart/list-parts.d.ts +2 -2
- package/dist/api/routes/multipart/list-parts.d.ts.map +1 -1
- package/dist/api/routes/multipart/part.d.ts +4 -6
- package/dist/api/routes/multipart/part.d.ts.map +1 -1
- package/dist/api/routes/upload.d.ts +10 -14
- package/dist/api/routes/upload.d.ts.map +1 -1
- package/dist/dimah-s3.d.ts +19 -8
- package/dist/dimah-s3.d.ts.map +1 -1
- package/dist/errors.d.ts +5 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/helpers/best-effort.d.ts +6 -0
- package/dist/helpers/best-effort.d.ts.map +1 -0
- package/dist/helpers/constraints.d.ts +16 -0
- package/dist/helpers/constraints.d.ts.map +1 -0
- package/dist/helpers/expires.d.ts +2 -2
- package/dist/helpers/features.d.ts +12 -29
- package/dist/helpers/features.d.ts.map +1 -1
- package/dist/helpers/finalize-confirmed.d.ts +19 -0
- package/dist/helpers/finalize-confirmed.d.ts.map +1 -0
- package/dist/helpers/head-object.d.ts.map +1 -1
- package/dist/helpers/hooks.d.ts +7 -1
- package/dist/helpers/hooks.d.ts.map +1 -1
- package/dist/helpers/index.d.ts +12 -9
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/list-parts.d.ts +5 -0
- package/dist/helpers/list-parts.d.ts.map +1 -1
- package/dist/helpers/logger.d.ts +4 -0
- package/dist/helpers/logger.d.ts.map +1 -0
- package/dist/helpers/previous-key.d.ts +5 -0
- package/dist/helpers/previous-key.d.ts.map +1 -0
- package/dist/helpers/resolve-route.d.ts +36 -0
- package/dist/helpers/resolve-route.d.ts.map +1 -0
- package/dist/helpers/resolve-target.d.ts +25 -14
- package/dist/helpers/resolve-target.d.ts.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1017 -477
- package/dist/index.js.map +1 -1
- package/dist/plugin/apply-plugins.d.ts +9 -4
- package/dist/plugin/apply-plugins.d.ts.map +1 -1
- package/dist/plugin/hook-registry.d.ts +13 -4
- package/dist/plugin/hook-registry.d.ts.map +1 -1
- package/dist/plugin/index.d.ts +1 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/types.d.ts +12 -7
- package/dist/plugin/types.d.ts.map +1 -1
- package/dist/plugins.d.ts +5 -3
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +951 -475
- package/dist/plugins.js.map +1 -1
- package/dist/put.d.ts +15 -0
- package/dist/put.d.ts.map +1 -0
- package/dist/route.d.ts +13 -0
- package/dist/route.d.ts.map +1 -0
- package/dist/types/config.d.ts +190 -95
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/hook-contexts.d.ts +124 -87
- package/dist/types/hook-contexts.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +11 -11
- package/dist/api/assert-feature-enabled.d.ts +0 -5
- package/dist/api/assert-feature-enabled.d.ts.map +0 -1
- package/dist/helpers/resolve-object-acl.d.ts +0 -10
- package/dist/helpers/resolve-object-acl.d.ts.map +0 -1
- package/dist/helpers/resolve-request-acl.d.ts +0 -10
- package/dist/helpers/resolve-request-acl.d.ts.map +0 -1
package/dist/plugins.js
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
-
import { S3_API_ROUTES, isAPIError, DimahS3Error, S3_ERROR_CODES, confirmBodySchema, deleteQuerySchema, downloadQuerySchema, multipartAbortBodySchema, multipartCompleteBodySchema, multipartInitBodySchema, multipartListPartsQuerySchema, multipartSignPartBodySchema, uploadBodySchema,
|
|
2
|
-
import { DeleteObjectCommand, GetObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, PutObjectCommand,
|
|
1
|
+
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, ROUTE_NAME_PATTERN, normalizeObjectKey, sanitizeFileName } from '@dimah-s3/core';
|
|
2
|
+
import { DeleteObjectCommand, GetObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, PutObjectCommand, ListPartsCommand, AbortMultipartUploadCommand, CompleteMultipartUploadCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
|
3
3
|
import { createMiddleware, createEndpoint } from 'better-call';
|
|
4
4
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
5
5
|
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
|
|
6
6
|
|
|
7
7
|
// src/plugin/apply-plugins.ts
|
|
8
|
+
async function deleteObjectBestEffort(client, bucket, key) {
|
|
9
|
+
try {
|
|
10
|
+
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
|
11
|
+
} catch {
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async function abortMultipartBestEffort(client, bucket, key, uploadId) {
|
|
15
|
+
try {
|
|
16
|
+
await client.send(
|
|
17
|
+
new AbortMultipartUploadCommand({
|
|
18
|
+
Bucket: bucket,
|
|
19
|
+
Key: key,
|
|
20
|
+
UploadId: uploadId
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
}
|
|
8
26
|
var errors = {
|
|
9
27
|
notFound: () => DimahS3Error.from("NOT_FOUND", S3_ERROR_CODES.NOT_FOUND),
|
|
10
28
|
unauthorized: () => DimahS3Error.from("UNAUTHORIZED", S3_ERROR_CODES.UNAUTHORIZED),
|
|
@@ -21,10 +39,18 @@ var errors = {
|
|
|
21
39
|
params: { feature }
|
|
22
40
|
}),
|
|
23
41
|
invalidKey: () => DimahS3Error.from("BAD_REQUEST", S3_ERROR_CODES.INVALID_KEY),
|
|
24
|
-
|
|
25
|
-
code: S3_ERROR_CODES.
|
|
26
|
-
message: S3_ERROR_CODES.
|
|
27
|
-
params: {
|
|
42
|
+
unknownRoute: (route) => DimahS3Error.from("NOT_FOUND", {
|
|
43
|
+
code: S3_ERROR_CODES.UNKNOWN_ROUTE.code,
|
|
44
|
+
message: S3_ERROR_CODES.UNKNOWN_ROUTE.message,
|
|
45
|
+
params: { route }
|
|
46
|
+
}),
|
|
47
|
+
fileTypeNotAllowed: (fileName, contentType) => DimahS3Error.from("BAD_REQUEST", {
|
|
48
|
+
code: S3_ERROR_CODES.FILE_TYPE_NOT_ALLOWED.code,
|
|
49
|
+
message: S3_ERROR_CODES.FILE_TYPE_NOT_ALLOWED.message,
|
|
50
|
+
params: {
|
|
51
|
+
fileName,
|
|
52
|
+
...contentType ? { contentType } : {}
|
|
53
|
+
}
|
|
28
54
|
}),
|
|
29
55
|
s3NetworkError: (code) => DimahS3Error.from("BAD_GATEWAY", {
|
|
30
56
|
code: S3_ERROR_CODES.S3_NETWORK_ERROR.code,
|
|
@@ -34,11 +60,6 @@ var errors = {
|
|
|
34
60
|
),
|
|
35
61
|
params: { code }
|
|
36
62
|
}),
|
|
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
63
|
multipartPartMissing: (partNumber) => DimahS3Error.from("BAD_REQUEST", {
|
|
43
64
|
code: S3_ERROR_CODES.MULTIPART_PART_MISSING.code,
|
|
44
65
|
message: S3_ERROR_CODES.MULTIPART_PART_MISSING.message.replaceAll(
|
|
@@ -51,6 +72,10 @@ var errors = {
|
|
|
51
72
|
...S3_ERROR_CODES.PAYLOAD_TOO_LARGE,
|
|
52
73
|
message
|
|
53
74
|
}),
|
|
75
|
+
quotaExceeded: (message = S3_ERROR_CODES.QUOTA_EXCEEDED.message) => DimahS3Error.from("PAYLOAD_TOO_LARGE", {
|
|
76
|
+
...S3_ERROR_CODES.QUOTA_EXCEEDED,
|
|
77
|
+
message
|
|
78
|
+
}),
|
|
54
79
|
validationError: (message) => DimahS3Error.from("BAD_REQUEST", {
|
|
55
80
|
code: S3_ERROR_CODES.VALIDATION_ERROR.code,
|
|
56
81
|
message
|
|
@@ -70,119 +95,366 @@ 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
|
-
|
|
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);
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
|
-
async function
|
|
139
|
+
async function runObjectHook(hook, context) {
|
|
140
|
+
if (!hook) return;
|
|
141
|
+
try {
|
|
142
|
+
return await hook(context);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
mapGuardError(err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
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
|
-
|
|
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/
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
139
|
-
if (!
|
|
140
|
-
|
|
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
|
|
180
|
+
if (k.startsWith(`${p}/`)) return k;
|
|
181
|
+
if (k === p) throw errors.invalidKey();
|
|
155
182
|
return `${p}/${k}`;
|
|
156
183
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
function assertRouteName(name) {
|
|
243
|
+
if (!ROUTE_NAME_PATTERN.test(name)) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`dimahS3 route "${name}" is invalid. Use a letter followed by up to 63 letters, digits, underscores, or hyphens.`
|
|
246
|
+
);
|
|
164
247
|
}
|
|
165
|
-
return assertSafeObjectKey(context.proposedKey);
|
|
166
248
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
249
|
+
|
|
250
|
+
// src/helpers/features.ts
|
|
251
|
+
function isEnabled(feature) {
|
|
252
|
+
return feature.enabled;
|
|
253
|
+
}
|
|
254
|
+
function resolveKeyPrefix(name, value) {
|
|
255
|
+
if (value === false) return false;
|
|
256
|
+
const normalized = normalizeObjectKey(value ?? name);
|
|
257
|
+
if (!normalized) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`dimahS3 route "${name}": keyPrefix is not a valid object-key prefix.`
|
|
260
|
+
);
|
|
173
261
|
}
|
|
174
|
-
return
|
|
262
|
+
return normalized;
|
|
175
263
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
264
|
+
function isFeatureOn(value, defaultOn = false) {
|
|
265
|
+
if (value === false) return false;
|
|
266
|
+
if (value === void 0) return defaultOn;
|
|
267
|
+
if (value === true) return true;
|
|
268
|
+
if ("enabled" in value && typeof value.enabled === "boolean") {
|
|
269
|
+
return value.enabled;
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
function normalizeFeature(value, defaultOn = false) {
|
|
274
|
+
if (value === false) return { enabled: false };
|
|
275
|
+
if (value === void 0) {
|
|
276
|
+
if (!defaultOn) return { enabled: false };
|
|
277
|
+
return { enabled: true };
|
|
278
|
+
}
|
|
279
|
+
if (value === true) return { enabled: true };
|
|
280
|
+
return { ...value, enabled: true };
|
|
281
|
+
}
|
|
282
|
+
function skippedPluginIds(plugins) {
|
|
283
|
+
const ids = /* @__PURE__ */ new Set();
|
|
284
|
+
if (!plugins) return ids;
|
|
285
|
+
for (const [id, enabled] of Object.entries(plugins)) {
|
|
286
|
+
if (enabled === false) ids.add(id);
|
|
287
|
+
}
|
|
288
|
+
return ids;
|
|
289
|
+
}
|
|
290
|
+
function splitUpload(upload2) {
|
|
291
|
+
if (upload2 === void 0 || typeof upload2 === "boolean") {
|
|
292
|
+
return { upload: upload2, multipart: void 0 };
|
|
293
|
+
}
|
|
294
|
+
const { multipart, ...rest } = upload2;
|
|
295
|
+
return { upload: rest, multipart };
|
|
296
|
+
}
|
|
297
|
+
function normalizeRoute(name, route, instance) {
|
|
298
|
+
assertRouteName(name);
|
|
299
|
+
const client = route.client ?? instance.client;
|
|
300
|
+
const bucket = route.bucket ?? instance.bucket;
|
|
301
|
+
if (!client) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`dimahS3 route "${name}": set client on the route or on dimahS3().`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (!bucket) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`dimahS3 route "${name}": set bucket on the route or on dimahS3().`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const { upload: uploadInput, multipart: multipartInput } = splitUpload(
|
|
312
|
+
route.upload
|
|
313
|
+
);
|
|
314
|
+
const upload2 = normalizeFeature(uploadInput);
|
|
315
|
+
const download2 = normalizeFeature(route.download);
|
|
316
|
+
const deleteFeature = normalizeFeature(route.delete);
|
|
317
|
+
const multipart = normalizeFeature(multipartInput);
|
|
318
|
+
if (!upload2.enabled && !download2.enabled && !deleteFeature.enabled) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`dimahS3 route "${name}": enable upload, download, or delete.`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const resolvedUpload = upload2.enabled ? { ...upload2, multipart } : { enabled: false, multipart: { enabled: false } };
|
|
324
|
+
if (resolvedUpload.enabled) {
|
|
325
|
+
const maxFileSize = resolvedUpload.maxFileSize;
|
|
326
|
+
if (maxFileSize != null && (!Number.isFinite(maxFileSize) || maxFileSize <= 0)) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`dimahS3 route "${name}": maxFileSize must be a positive number.`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
name,
|
|
334
|
+
client,
|
|
335
|
+
bucket,
|
|
336
|
+
keyPrefix: resolveKeyPrefix(name, route.keyPrefix),
|
|
337
|
+
guard: route.guard,
|
|
338
|
+
skippedPluginIds: skippedPluginIds(route.plugins),
|
|
339
|
+
upload: resolvedUpload,
|
|
340
|
+
download: download2,
|
|
341
|
+
delete: deleteFeature
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function normalizeRoutes(config) {
|
|
345
|
+
const entries = Object.entries(config.routes);
|
|
346
|
+
if (entries.length === 0) {
|
|
347
|
+
throw new Error("dimahS3: at least one route is required.");
|
|
348
|
+
}
|
|
349
|
+
const routes = {};
|
|
350
|
+
for (const [name, route] of entries) {
|
|
351
|
+
routes[name] = normalizeRoute(name, route, config);
|
|
352
|
+
}
|
|
353
|
+
assertDistinctKeyPrefixes(routes);
|
|
354
|
+
return routes;
|
|
355
|
+
}
|
|
356
|
+
function prefixesOverlap(a, b) {
|
|
357
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
358
|
+
}
|
|
359
|
+
function assertDistinctKeyPrefixes(routes) {
|
|
360
|
+
const named = Object.entries(routes).filter(
|
|
361
|
+
(entry) => entry[1].keyPrefix !== false
|
|
362
|
+
);
|
|
363
|
+
for (let i = 0; i < named.length; i++) {
|
|
364
|
+
for (let j = i + 1; j < named.length; j++) {
|
|
365
|
+
const [nameA, routeA] = named[i];
|
|
366
|
+
const [nameB, routeB] = named[j];
|
|
367
|
+
if (prefixesOverlap(routeA.keyPrefix, routeB.keyPrefix)) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`dimahS3: routes "${nameA}" and "${nameB}" have overlapping keyPrefix values ("${routeA.keyPrefix}" and "${routeB.keyPrefix}").`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/helpers/request.ts
|
|
377
|
+
function requestFromHeaders(headers) {
|
|
378
|
+
return new Request("http://dimah-s3.local", { headers });
|
|
379
|
+
}
|
|
380
|
+
function assertWithinMaxFileSize(maxFileSize, size) {
|
|
381
|
+
if (typeof maxFileSize === "number" && size > maxFileSize) {
|
|
382
|
+
throw errors.payloadTooLarge();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function assertDeclaredConstraints(constraints, input) {
|
|
386
|
+
if (constraints?.fileTypes?.length && !matchesFileTypes(input.fileName, input.contentType, constraints.fileTypes)) {
|
|
387
|
+
throw errors.fileTypeNotAllowed(input.fileName, input.contentType);
|
|
388
|
+
}
|
|
389
|
+
assertWithinMaxFileSize(constraints?.maxFileSize, input.fileSize);
|
|
390
|
+
}
|
|
391
|
+
function assertVerifiedConstraints(constraints, input) {
|
|
392
|
+
assertWithinMaxFileSize(constraints?.maxFileSize, input.contentLength);
|
|
393
|
+
const name = input.fileName ?? "";
|
|
394
|
+
if (constraints?.fileTypes?.length && !matchesFileTypes(name, input.contentType, constraints.fileTypes)) {
|
|
395
|
+
throw errors.fileTypeNotAllowed(name || "unknown", input.contentType);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/helpers/resolve-route.ts
|
|
400
|
+
function getResolvedRoute(config, name) {
|
|
401
|
+
const route = config.routes[name];
|
|
402
|
+
if (!route) {
|
|
403
|
+
throw errors.unknownRoute(name);
|
|
404
|
+
}
|
|
405
|
+
return route;
|
|
406
|
+
}
|
|
407
|
+
function featureIsEnabled(route, feature) {
|
|
408
|
+
if (feature === "multipart") return route.upload.multipart.enabled;
|
|
409
|
+
return route[feature].enabled;
|
|
410
|
+
}
|
|
411
|
+
function assertFeatureEnabled(route, feature) {
|
|
412
|
+
if (!featureIsEnabled(route, feature)) {
|
|
413
|
+
throw errors.featureDisabled(feature);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function openRoute(config, name, request, feature) {
|
|
417
|
+
const route = getResolvedRoute(config, name);
|
|
418
|
+
assertFeatureEnabled(route, feature);
|
|
419
|
+
await runHook(route.guard, { request, route: route.name });
|
|
420
|
+
return route;
|
|
421
|
+
}
|
|
422
|
+
function storedObjectContext(request, route, key) {
|
|
423
|
+
return { request, route: route.name, key, bucket: route.bucket };
|
|
424
|
+
}
|
|
425
|
+
async function openUploadTarget(config, input, request, feature) {
|
|
426
|
+
const route = await openRoute(config, input.route, request, feature);
|
|
427
|
+
const fileSize = Math.floor(input.fileSize);
|
|
428
|
+
const fileName = input.fileName;
|
|
429
|
+
assertDeclaredConstraints(route.upload, {
|
|
430
|
+
fileName,
|
|
431
|
+
fileSize,
|
|
432
|
+
contentType: input.contentType
|
|
433
|
+
});
|
|
434
|
+
const target = await resolveUploadTarget(route, {
|
|
435
|
+
request,
|
|
436
|
+
route: route.name,
|
|
437
|
+
file: {
|
|
438
|
+
name: fileName,
|
|
439
|
+
size: fileSize,
|
|
440
|
+
type: input.contentType
|
|
441
|
+
},
|
|
442
|
+
clientMetadata: input.metadata
|
|
184
443
|
});
|
|
185
|
-
return {
|
|
444
|
+
return {
|
|
445
|
+
route,
|
|
446
|
+
...target,
|
|
447
|
+
stored: storedObjectContext(request, route, target.key)
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
async function openStoredTarget(config, input, request, feature) {
|
|
451
|
+
const route = await openRoute(config, input.route, request, feature);
|
|
452
|
+
const target = resolveStoredTarget(route, input.key);
|
|
453
|
+
return {
|
|
454
|
+
route,
|
|
455
|
+
...target,
|
|
456
|
+
stored: storedObjectContext(request, route, target.key)
|
|
457
|
+
};
|
|
186
458
|
}
|
|
187
459
|
async function headObjectOrNotFound(client, bucket, key) {
|
|
188
460
|
return sendOrObjectNotFound(
|
|
@@ -194,7 +466,7 @@ async function headObjectAfterMultipartComplete(client, bucket, key) {
|
|
|
194
466
|
() => client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
|
|
195
467
|
);
|
|
196
468
|
let head = await send();
|
|
197
|
-
for (let attempt = 0; attempt < 4 &&
|
|
469
|
+
for (let attempt = 0; attempt < 4 && typeof head.ContentLength !== "number"; attempt++) {
|
|
198
470
|
await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
|
|
199
471
|
head = await send();
|
|
200
472
|
}
|
|
@@ -227,61 +499,70 @@ async function listAllParts(client, input) {
|
|
|
227
499
|
}
|
|
228
500
|
return parts;
|
|
229
501
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
function isFeatureEnabled(config, feature) {
|
|
239
|
-
return config[feature]?.enabled === true;
|
|
240
|
-
}
|
|
241
|
-
function applyMultipartDefault(config, multipartInput) {
|
|
242
|
-
let next = config;
|
|
243
|
-
if (multipartInput === void 0 && config.upload?.enabled) {
|
|
244
|
-
next = {
|
|
245
|
-
...config,
|
|
246
|
-
multipart: { ...config.multipart, enabled: true }
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
if (next.multipart && next.upload) {
|
|
250
|
-
next = {
|
|
251
|
-
...next,
|
|
252
|
-
multipart: {
|
|
253
|
-
...next.multipart,
|
|
254
|
-
prefix: next.multipart.prefix ?? next.upload.prefix,
|
|
255
|
-
resolveKey: next.multipart.resolveKey ?? next.upload.resolveKey,
|
|
256
|
-
acl: next.multipart.acl ?? next.upload.acl,
|
|
257
|
-
allowClientAcl: next.multipart.allowClientAcl ?? next.upload.allowClientAcl,
|
|
258
|
-
requireFileSize: next.multipart.requireFileSize ?? next.upload.requireFileSize
|
|
259
|
-
}
|
|
260
|
-
};
|
|
502
|
+
function listedPartsByteSize(parts, excludePartNumber) {
|
|
503
|
+
let total = 0;
|
|
504
|
+
for (const part of parts) {
|
|
505
|
+
if (excludePartNumber !== void 0 && part.PartNumber === excludePartNumber) {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
total += part.Size ?? 0;
|
|
261
509
|
}
|
|
262
|
-
return
|
|
510
|
+
return total;
|
|
263
511
|
}
|
|
264
|
-
function
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
512
|
+
async function finalizeConfirmedObject(client, bucket, key, input) {
|
|
513
|
+
const { config, route, stored, head, uploadId, acl } = input;
|
|
514
|
+
const contentLength = requireContentLength(head);
|
|
515
|
+
const fileName = resolveStoredFileName(head.ContentDisposition, key);
|
|
516
|
+
const rawMetadata = head.Metadata ?? {};
|
|
517
|
+
try {
|
|
518
|
+
assertVerifiedConstraints(route.upload, {
|
|
519
|
+
fileName,
|
|
520
|
+
contentType: head.ContentType,
|
|
521
|
+
contentLength
|
|
522
|
+
});
|
|
523
|
+
} catch (err) {
|
|
524
|
+
await deleteObjectBestEffort(client, bucket, key);
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
const metadata = stripPreviousKeyMeta(rawMetadata);
|
|
528
|
+
const context = {
|
|
529
|
+
...stored,
|
|
530
|
+
contentType: head.ContentType,
|
|
531
|
+
contentLength,
|
|
532
|
+
eTag: head.ETag?.replace(/"/g, ""),
|
|
533
|
+
metadata,
|
|
534
|
+
acl,
|
|
535
|
+
fileName,
|
|
536
|
+
versionId: head.VersionId,
|
|
537
|
+
lastModified: head.LastModified?.toISOString(),
|
|
538
|
+
uploadId
|
|
270
539
|
};
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
);
|
|
540
|
+
try {
|
|
541
|
+
await runLifecycleHook(route.upload.onConfirmed, context, config);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
await deleteObjectBestEffort(client, bucket, key);
|
|
544
|
+
throw err;
|
|
277
545
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
546
|
+
const previousKey = previousKeyFromMetadata(rawMetadata);
|
|
547
|
+
if (previousKey && previousKey !== key) {
|
|
548
|
+
try {
|
|
549
|
+
const safe = assertStoredKey(previousKey, route.keyPrefix);
|
|
550
|
+
await deleteObjectBestEffort(client, bucket, safe);
|
|
551
|
+
} catch {
|
|
552
|
+
}
|
|
284
553
|
}
|
|
554
|
+
return {
|
|
555
|
+
key,
|
|
556
|
+
bucket,
|
|
557
|
+
contentType: context.contentType,
|
|
558
|
+
contentLength: context.contentLength,
|
|
559
|
+
eTag: context.eTag,
|
|
560
|
+
metadata: context.metadata ?? {},
|
|
561
|
+
acl,
|
|
562
|
+
fileName,
|
|
563
|
+
versionId: context.versionId,
|
|
564
|
+
lastModified: context.lastModified
|
|
565
|
+
};
|
|
285
566
|
}
|
|
286
567
|
var s3ContextMiddleware = createMiddleware(async (ctx) => {
|
|
287
568
|
const injected = ctx.context;
|
|
@@ -329,138 +610,269 @@ var createS3Endpoint = ((pathOrOptions, optionsOrHandler, maybeHandler) => {
|
|
|
329
610
|
|
|
330
611
|
// src/api/routes/confirm.ts
|
|
331
612
|
async function handleConfirm(config, input, request) {
|
|
332
|
-
const { key, bucket } = await
|
|
613
|
+
const { route, key, bucket, stored } = await openStoredTarget(
|
|
614
|
+
config,
|
|
615
|
+
input,
|
|
333
616
|
request,
|
|
334
|
-
|
|
335
|
-
|
|
617
|
+
"upload"
|
|
618
|
+
);
|
|
619
|
+
await runHook(route.upload.confirmGuard, {
|
|
620
|
+
...stored,
|
|
621
|
+
replace: route.upload.replace
|
|
336
622
|
});
|
|
337
|
-
await
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
623
|
+
const head = await headObjectOrNotFound(route.client, bucket, key);
|
|
624
|
+
return finalizeConfirmedObject(route.client, bucket, key, {
|
|
625
|
+
config,
|
|
626
|
+
route,
|
|
627
|
+
stored,
|
|
628
|
+
head,
|
|
629
|
+
acl: route.upload.acl
|
|
341
630
|
});
|
|
342
|
-
const head = await headObjectOrNotFound(config.client, bucket, key);
|
|
343
|
-
const acl = config.resolveObjectAcl ? await resolveObjectAcl(config.client, bucket, key) : void 0;
|
|
344
|
-
const fileName = parseFileName(head.ContentDisposition);
|
|
345
|
-
const context = {
|
|
346
|
-
request,
|
|
347
|
-
key,
|
|
348
|
-
bucket,
|
|
349
|
-
contentType: head.ContentType,
|
|
350
|
-
contentLength: requireContentLength(head),
|
|
351
|
-
eTag: head.ETag?.replace(/"/g, ""),
|
|
352
|
-
metadata: head.Metadata,
|
|
353
|
-
acl,
|
|
354
|
-
fileName,
|
|
355
|
-
versionId: head.VersionId,
|
|
356
|
-
lastModified: head.LastModified?.toISOString()
|
|
357
|
-
};
|
|
358
|
-
await runLifecycleHook(config.upload?.onConfirmed, context);
|
|
359
|
-
return {
|
|
360
|
-
key,
|
|
361
|
-
bucket,
|
|
362
|
-
contentType: context.contentType,
|
|
363
|
-
contentLength: context.contentLength,
|
|
364
|
-
eTag: context.eTag,
|
|
365
|
-
metadata: context.metadata ?? {},
|
|
366
|
-
acl,
|
|
367
|
-
fileName,
|
|
368
|
-
versionId: context.versionId,
|
|
369
|
-
lastModified: context.lastModified
|
|
370
|
-
};
|
|
371
631
|
}
|
|
372
632
|
var confirm = createS3Endpoint(
|
|
373
633
|
S3_API_ROUTES.uploadConfirm,
|
|
374
634
|
{ method: "POST", body: confirmBodySchema },
|
|
375
635
|
async (ctx) => {
|
|
376
|
-
assertFeatureEnabled(ctx.context.config, "upload");
|
|
377
636
|
return handleConfirm(ctx.context.config, ctx.body, ctx.context.request);
|
|
378
637
|
}
|
|
379
638
|
);
|
|
380
639
|
async function handleDelete(config, input, request) {
|
|
381
|
-
const { key, bucket } = await
|
|
640
|
+
const { route, key, bucket, stored } = await openStoredTarget(
|
|
641
|
+
config,
|
|
642
|
+
input,
|
|
382
643
|
request,
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
await
|
|
387
|
-
|
|
388
|
-
key,
|
|
389
|
-
bucket
|
|
390
|
-
});
|
|
391
|
-
await headObjectOrNotFound(config.client, bucket, key);
|
|
392
|
-
await config.client.send(
|
|
644
|
+
"delete"
|
|
645
|
+
);
|
|
646
|
+
await runHook(route.delete.guard, stored);
|
|
647
|
+
await headObjectOrNotFound(route.client, bucket, key);
|
|
648
|
+
await route.client.send(
|
|
393
649
|
new DeleteObjectCommand({ Bucket: bucket, Key: key })
|
|
394
650
|
);
|
|
395
|
-
await runLifecycleHook(
|
|
651
|
+
await runLifecycleHook(route.delete.onDeleted, stored, config);
|
|
396
652
|
return { success: true, bucket, key };
|
|
397
653
|
}
|
|
398
654
|
var deleteObject = createS3Endpoint(
|
|
399
655
|
S3_API_ROUTES.delete,
|
|
400
656
|
{ method: "DELETE", query: deleteQuerySchema },
|
|
401
657
|
async (ctx) => {
|
|
402
|
-
assertFeatureEnabled(ctx.context.config, "delete");
|
|
403
658
|
return handleDelete(ctx.context.config, ctx.query, ctx.context.request);
|
|
404
659
|
}
|
|
405
660
|
);
|
|
661
|
+
async function deleteOne(config, routeName, key, request) {
|
|
662
|
+
const { route, bucket, stored } = await openStoredTarget(
|
|
663
|
+
config,
|
|
664
|
+
{ route: routeName, key },
|
|
665
|
+
request,
|
|
666
|
+
"delete"
|
|
667
|
+
);
|
|
668
|
+
await runHook(route.delete.guard, stored);
|
|
669
|
+
await headObjectOrNotFound(route.client, bucket, stored.key);
|
|
670
|
+
await route.client.send(
|
|
671
|
+
new DeleteObjectCommand({ Bucket: bucket, Key: stored.key })
|
|
672
|
+
);
|
|
673
|
+
await runLifecycleHook(route.delete.onDeleted, stored, config);
|
|
674
|
+
}
|
|
675
|
+
var deleteBatch = createS3Endpoint(
|
|
676
|
+
S3_API_ROUTES.deleteBatch,
|
|
677
|
+
{ method: "POST", body: deleteBatchBodySchema },
|
|
678
|
+
async (ctx) => {
|
|
679
|
+
const config = ctx.context.config;
|
|
680
|
+
const request = ctx.context.request;
|
|
681
|
+
const results = [];
|
|
682
|
+
for (const key of ctx.body.keys) {
|
|
683
|
+
try {
|
|
684
|
+
await deleteOne(config, ctx.body.route, key, request);
|
|
685
|
+
results.push({ key, success: true });
|
|
686
|
+
} catch (err) {
|
|
687
|
+
results.push({
|
|
688
|
+
key,
|
|
689
|
+
success: false,
|
|
690
|
+
error: isDimahS3Error(err) ? { code: err.code ?? "INTERNAL_ERROR", message: err.message } : { code: "INTERNAL_ERROR", message: "Internal server error" }
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { results };
|
|
695
|
+
}
|
|
696
|
+
);
|
|
697
|
+
function fileUrl(request, basePath, query) {
|
|
698
|
+
const url = new URL(request.url);
|
|
699
|
+
const prefix = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
|
700
|
+
url.pathname = `${prefix}${S3_API_ROUTES.file}`;
|
|
701
|
+
url.search = "";
|
|
702
|
+
url.searchParams.set("route", query.route);
|
|
703
|
+
url.searchParams.set("key", query.key);
|
|
704
|
+
if (query.fileName) url.searchParams.set("fileName", query.fileName);
|
|
705
|
+
if (query.disposition) url.searchParams.set("disposition", query.disposition);
|
|
706
|
+
return url.toString();
|
|
707
|
+
}
|
|
406
708
|
async function handleDownload(config, input, request) {
|
|
407
|
-
const { key, bucket } = await
|
|
709
|
+
const { route, key, bucket, stored } = await openStoredTarget(
|
|
710
|
+
config,
|
|
711
|
+
input,
|
|
408
712
|
request,
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
713
|
+
"download"
|
|
714
|
+
);
|
|
715
|
+
const resolved = await route.download.resolve?.({
|
|
716
|
+
...stored,
|
|
717
|
+
fileName: input.fileName,
|
|
718
|
+
disposition: input.disposition ?? route.download.disposition
|
|
412
719
|
});
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
720
|
+
const fileName = resolved?.fileName ?? input.fileName;
|
|
721
|
+
const disposition = resolved?.disposition ?? input.disposition ?? route.download.disposition ?? "attachment";
|
|
722
|
+
const expiresIn = normalizeExpiresIn(
|
|
723
|
+
resolved?.expiresIn ?? route.download.expiresIn,
|
|
724
|
+
config.maxExpiresIn
|
|
725
|
+
);
|
|
726
|
+
await runHook(route.download.guard, {
|
|
727
|
+
...stored,
|
|
728
|
+
fileName,
|
|
729
|
+
disposition
|
|
420
730
|
});
|
|
421
|
-
await headObjectOrNotFound(
|
|
422
|
-
const
|
|
423
|
-
|
|
731
|
+
await headObjectOrNotFound(route.client, bucket, key);
|
|
732
|
+
const mode = route.download.mode ?? "presign";
|
|
733
|
+
const contentDisposition = fileName ? buildContentDisposition(fileName, disposition) : disposition;
|
|
734
|
+
const url = mode === "proxy" ? fileUrl(request, config.basePath ?? "/api/s3", {
|
|
735
|
+
route: input.route,
|
|
736
|
+
key,
|
|
737
|
+
fileName,
|
|
738
|
+
disposition
|
|
739
|
+
}) : await getSignedUrl(
|
|
740
|
+
route.client,
|
|
424
741
|
new GetObjectCommand({
|
|
425
742
|
Bucket: bucket,
|
|
426
743
|
Key: key,
|
|
427
|
-
ResponseContentDisposition:
|
|
744
|
+
ResponseContentDisposition: contentDisposition
|
|
428
745
|
}),
|
|
429
746
|
{ expiresIn }
|
|
430
747
|
);
|
|
431
|
-
await runLifecycleHook(
|
|
432
|
-
|
|
433
|
-
|
|
748
|
+
await runLifecycleHook(
|
|
749
|
+
route.download.onPresigned,
|
|
750
|
+
{
|
|
751
|
+
...stored,
|
|
752
|
+
fileName,
|
|
753
|
+
url,
|
|
754
|
+
expiresIn: mode === "proxy" ? 0 : expiresIn
|
|
755
|
+
},
|
|
756
|
+
config
|
|
757
|
+
);
|
|
758
|
+
return {
|
|
434
759
|
bucket,
|
|
435
|
-
|
|
760
|
+
key,
|
|
436
761
|
url,
|
|
437
|
-
expiresIn
|
|
438
|
-
|
|
439
|
-
|
|
762
|
+
expiresIn: mode === "proxy" ? 0 : expiresIn,
|
|
763
|
+
mode,
|
|
764
|
+
disposition
|
|
765
|
+
};
|
|
440
766
|
}
|
|
441
767
|
var download = createS3Endpoint(
|
|
442
768
|
S3_API_ROUTES.download,
|
|
443
769
|
{ method: "GET", query: downloadQuerySchema },
|
|
444
770
|
async (ctx) => {
|
|
445
|
-
assertFeatureEnabled(ctx.context.config, "download");
|
|
446
771
|
return handleDownload(ctx.context.config, ctx.query, ctx.context.request);
|
|
447
772
|
}
|
|
448
773
|
);
|
|
449
|
-
|
|
450
|
-
|
|
774
|
+
function catalogUpload(upload2) {
|
|
775
|
+
if (!isEnabled(upload2)) return { enabled: false };
|
|
776
|
+
return {
|
|
777
|
+
enabled: true,
|
|
778
|
+
fileTypes: upload2.fileTypes,
|
|
779
|
+
maxFileSize: upload2.maxFileSize,
|
|
780
|
+
multipart: isEnabled(upload2.multipart),
|
|
781
|
+
...upload2.checksum ? { checksum: true } : {},
|
|
782
|
+
...upload2.replace ? { replace: upload2.replace } : {}
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function catalogDownload(download2) {
|
|
786
|
+
if (!isEnabled(download2)) return { enabled: false };
|
|
787
|
+
return {
|
|
788
|
+
enabled: true,
|
|
789
|
+
...download2.disposition ? { disposition: download2.disposition } : {},
|
|
790
|
+
...download2.mode ? { mode: download2.mode } : {}
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function buildRouteCatalog(config) {
|
|
794
|
+
const routes = {};
|
|
795
|
+
for (const [name, route] of Object.entries(config.routes)) {
|
|
796
|
+
routes[name] = {
|
|
797
|
+
upload: catalogUpload(route.upload),
|
|
798
|
+
download: catalogDownload(route.download),
|
|
799
|
+
delete: isEnabled(route.delete) ? { enabled: true } : { enabled: false }
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
return { routes };
|
|
803
|
+
}
|
|
804
|
+
var catalog = createS3Endpoint(
|
|
805
|
+
S3_API_ROUTES.catalog,
|
|
806
|
+
{ method: "GET" },
|
|
807
|
+
async (ctx) => {
|
|
808
|
+
return buildRouteCatalog(ctx.context.config);
|
|
809
|
+
}
|
|
810
|
+
);
|
|
811
|
+
async function handleFile(config, input, request) {
|
|
812
|
+
const { route, key, bucket, stored } = await openStoredTarget(
|
|
813
|
+
config,
|
|
814
|
+
input,
|
|
451
815
|
request,
|
|
452
|
-
|
|
453
|
-
|
|
816
|
+
"download"
|
|
817
|
+
);
|
|
818
|
+
const resolved = await route.download.resolve?.({
|
|
819
|
+
...stored,
|
|
820
|
+
fileName: input.fileName,
|
|
821
|
+
disposition: input.disposition ?? route.download.disposition
|
|
454
822
|
});
|
|
455
|
-
const
|
|
456
|
-
|
|
823
|
+
const fileName = resolved?.fileName ?? input.fileName;
|
|
824
|
+
const disposition = resolved?.disposition ?? input.disposition ?? route.download.disposition ?? "attachment";
|
|
825
|
+
await runHook(route.download.guard, {
|
|
826
|
+
...stored,
|
|
827
|
+
fileName,
|
|
828
|
+
disposition
|
|
829
|
+
});
|
|
830
|
+
await headObjectOrNotFound(route.client, bucket, key);
|
|
831
|
+
const result = await sendOrObjectNotFound(
|
|
832
|
+
() => route.client.send(
|
|
833
|
+
new GetObjectCommand({
|
|
834
|
+
Bucket: bucket,
|
|
835
|
+
Key: key,
|
|
836
|
+
ResponseContentDisposition: fileName ? buildContentDisposition(fileName, disposition) : `${disposition}`
|
|
837
|
+
})
|
|
838
|
+
)
|
|
839
|
+
);
|
|
840
|
+
const body = result.Body;
|
|
841
|
+
if (!body) throw errors.objectNotFound();
|
|
842
|
+
const stream = "transformToWebStream" in body && typeof body.transformToWebStream === "function" ? body.transformToWebStream() : body;
|
|
843
|
+
const headers = new Headers();
|
|
844
|
+
headers.set("Content-Type", result.ContentType ?? "application/octet-stream");
|
|
845
|
+
headers.set(
|
|
846
|
+
"Content-Disposition",
|
|
847
|
+
fileName ? buildContentDisposition(fileName, disposition) : `${disposition}`
|
|
848
|
+
);
|
|
849
|
+
if (typeof result.ContentLength === "number") {
|
|
850
|
+
headers.set("Content-Length", String(result.ContentLength));
|
|
851
|
+
}
|
|
852
|
+
return new Response(stream, { headers });
|
|
853
|
+
}
|
|
854
|
+
var file = createS3Endpoint(
|
|
855
|
+
S3_API_ROUTES.file,
|
|
856
|
+
{ method: "GET", query: fileQuerySchema },
|
|
857
|
+
async (ctx) => {
|
|
858
|
+
return handleFile(ctx.context.config, ctx.query, ctx.context.request);
|
|
859
|
+
}
|
|
860
|
+
);
|
|
861
|
+
async function handleAbort(config, input, request) {
|
|
862
|
+
const { route, key, bucket, stored } = await openStoredTarget(
|
|
863
|
+
config,
|
|
864
|
+
input,
|
|
457
865
|
request,
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
866
|
+
"multipart"
|
|
867
|
+
);
|
|
868
|
+
const uploadId = input.uploadId;
|
|
869
|
+
await runHook(route.upload.multipart.guard, {
|
|
870
|
+
...stored,
|
|
871
|
+
uploadId,
|
|
872
|
+
action: "abort"
|
|
461
873
|
});
|
|
462
874
|
await sendOrObjectNotFound(
|
|
463
|
-
() =>
|
|
875
|
+
() => route.client.send(
|
|
464
876
|
new AbortMultipartUploadCommand({
|
|
465
877
|
Bucket: bucket,
|
|
466
878
|
Key: key,
|
|
@@ -468,52 +880,68 @@ async function handleAbort(config, input, request) {
|
|
|
468
880
|
})
|
|
469
881
|
)
|
|
470
882
|
);
|
|
471
|
-
await runLifecycleHook(
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
883
|
+
await runLifecycleHook(
|
|
884
|
+
route.upload.multipart.onAbort,
|
|
885
|
+
{
|
|
886
|
+
...stored,
|
|
887
|
+
uploadId
|
|
888
|
+
},
|
|
889
|
+
config
|
|
890
|
+
);
|
|
477
891
|
return { bucket, key, uploadId, aborted: true };
|
|
478
892
|
}
|
|
479
893
|
var multipartAbort = createS3Endpoint(
|
|
480
894
|
S3_API_ROUTES.multipartAbort,
|
|
481
895
|
{ method: "POST", body: multipartAbortBodySchema },
|
|
482
896
|
async (ctx) => {
|
|
483
|
-
assertFeatureEnabled(ctx.context.config, "multipart");
|
|
484
897
|
return handleAbort(ctx.context.config, ctx.body, ctx.context.request);
|
|
485
898
|
}
|
|
486
899
|
);
|
|
487
900
|
async function handleComplete(config, input, request) {
|
|
488
|
-
const { key, bucket } = await
|
|
901
|
+
const { route, key, bucket, stored } = await openStoredTarget(
|
|
902
|
+
config,
|
|
903
|
+
input,
|
|
489
904
|
request,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
});
|
|
905
|
+
"multipart"
|
|
906
|
+
);
|
|
493
907
|
const uploadId = input.uploadId;
|
|
494
|
-
const parts =
|
|
908
|
+
const parts = [
|
|
909
|
+
...new Set(input.parts.map(({ partNumber }) => partNumber))
|
|
910
|
+
].sort((a, b) => a - b);
|
|
495
911
|
const partRefs = parts.map((partNumber) => ({ partNumber }));
|
|
496
|
-
await runHook(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
912
|
+
await runHook(route.upload.multipart.guard, {
|
|
913
|
+
...stored,
|
|
914
|
+
uploadId,
|
|
915
|
+
action: "complete"
|
|
916
|
+
});
|
|
917
|
+
await runHook(route.upload.confirmGuard, {
|
|
918
|
+
...stored,
|
|
500
919
|
uploadId,
|
|
501
|
-
parts: partRefs
|
|
920
|
+
parts: partRefs,
|
|
921
|
+
replace: route.upload.replace
|
|
502
922
|
});
|
|
503
|
-
const listedParts = await listAllParts(
|
|
923
|
+
const listedParts = await listAllParts(route.client, {
|
|
504
924
|
bucket,
|
|
505
925
|
key,
|
|
506
926
|
uploadId
|
|
507
927
|
});
|
|
928
|
+
let assembledBytes = 0;
|
|
508
929
|
const completeParts = parts.map((partNumber) => {
|
|
509
930
|
const found = listedParts.find((p) => p.PartNumber === partNumber);
|
|
510
931
|
if (!found?.ETag) {
|
|
511
932
|
throw errors.multipartPartMissing(partNumber);
|
|
512
933
|
}
|
|
934
|
+
assembledBytes += found.Size ?? 0;
|
|
513
935
|
return { PartNumber: partNumber, ETag: found.ETag };
|
|
514
936
|
});
|
|
937
|
+
try {
|
|
938
|
+
assertWithinMaxFileSize(route.upload.maxFileSize, assembledBytes);
|
|
939
|
+
} catch (err) {
|
|
940
|
+
await abortMultipartBestEffort(route.client, bucket, key, uploadId);
|
|
941
|
+
throw err;
|
|
942
|
+
}
|
|
515
943
|
const completeResult = await sendOrObjectNotFound(
|
|
516
|
-
() =>
|
|
944
|
+
() => route.client.send(
|
|
517
945
|
new CompleteMultipartUploadCommand({
|
|
518
946
|
Bucket: bucket,
|
|
519
947
|
Key: key,
|
|
@@ -523,273 +951,290 @@ async function handleComplete(config, input, request) {
|
|
|
523
951
|
)
|
|
524
952
|
);
|
|
525
953
|
const head = await headObjectAfterMultipartComplete(
|
|
526
|
-
|
|
954
|
+
route.client,
|
|
527
955
|
bucket,
|
|
528
956
|
key
|
|
529
957
|
);
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
await runLifecycleHook(config.multipart?.onComplete, {
|
|
539
|
-
request,
|
|
540
|
-
key,
|
|
541
|
-
bucket,
|
|
958
|
+
const confirmed = await finalizeConfirmedObject(route.client, bucket, key, {
|
|
959
|
+
config,
|
|
960
|
+
route,
|
|
961
|
+
stored,
|
|
962
|
+
head: {
|
|
963
|
+
...head,
|
|
964
|
+
ETag: head.ETag ?? completeResult.ETag
|
|
965
|
+
},
|
|
542
966
|
uploadId,
|
|
543
|
-
|
|
544
|
-
contentType,
|
|
545
|
-
eTag,
|
|
546
|
-
metadata,
|
|
547
|
-
acl,
|
|
548
|
-
fileName,
|
|
549
|
-
versionId,
|
|
550
|
-
lastModified
|
|
967
|
+
acl: route.upload.acl
|
|
551
968
|
});
|
|
552
|
-
return {
|
|
553
|
-
bucket,
|
|
554
|
-
key,
|
|
555
|
-
uploadId,
|
|
556
|
-
contentLength,
|
|
557
|
-
contentType,
|
|
558
|
-
eTag,
|
|
559
|
-
metadata,
|
|
560
|
-
acl,
|
|
561
|
-
fileName,
|
|
562
|
-
versionId,
|
|
563
|
-
lastModified
|
|
564
|
-
};
|
|
969
|
+
return { ...confirmed, uploadId };
|
|
565
970
|
}
|
|
566
971
|
var multipartComplete = createS3Endpoint(
|
|
567
972
|
S3_API_ROUTES.multipartComplete,
|
|
568
973
|
{ method: "POST", body: multipartCompleteBodySchema },
|
|
569
974
|
async (ctx) => {
|
|
570
|
-
assertFeatureEnabled(ctx.context.config, "multipart");
|
|
571
975
|
return handleComplete(ctx.context.config, ctx.body, ctx.context.request);
|
|
572
976
|
}
|
|
573
977
|
);
|
|
574
978
|
async function handleMultipartInit(config, input, request) {
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
await runHook(config.multipart?.initGuard, {
|
|
979
|
+
const fileSize = Math.floor(input.fileSize);
|
|
980
|
+
const fileName = input.fileName;
|
|
981
|
+
const { route, key, bucket, metadata, acl, stored } = await openUploadTarget(
|
|
982
|
+
config,
|
|
983
|
+
{
|
|
984
|
+
route: input.route,
|
|
985
|
+
fileName,
|
|
986
|
+
fileSize,
|
|
987
|
+
contentType: input.contentType,
|
|
988
|
+
metadata: input.metadata
|
|
989
|
+
},
|
|
588
990
|
request,
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
991
|
+
"multipart"
|
|
992
|
+
);
|
|
993
|
+
await runHook(route.upload.guard, {
|
|
994
|
+
...stored,
|
|
995
|
+
file: {
|
|
996
|
+
name: fileName,
|
|
997
|
+
size: fileSize,
|
|
998
|
+
type: input.contentType
|
|
999
|
+
},
|
|
1000
|
+
metadata,
|
|
1001
|
+
clientMetadata: input.metadata,
|
|
594
1002
|
acl,
|
|
595
|
-
|
|
1003
|
+
replace: route.upload.replace
|
|
596
1004
|
});
|
|
597
|
-
const { UploadId } = await
|
|
1005
|
+
const { UploadId } = await route.client.send(
|
|
598
1006
|
new CreateMultipartUploadCommand({
|
|
599
1007
|
Bucket: bucket,
|
|
600
1008
|
Key: key,
|
|
601
1009
|
ContentType: input.contentType,
|
|
602
|
-
ContentDisposition:
|
|
603
|
-
Metadata:
|
|
1010
|
+
ContentDisposition: buildContentDisposition(fileName),
|
|
1011
|
+
Metadata: metadata,
|
|
604
1012
|
ACL: acl
|
|
605
1013
|
})
|
|
606
1014
|
);
|
|
607
1015
|
if (!UploadId) {
|
|
608
1016
|
throw errors.internalError();
|
|
609
1017
|
}
|
|
610
|
-
await runLifecycleHook(
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
1018
|
+
await runLifecycleHook(
|
|
1019
|
+
route.upload.multipart.onInit,
|
|
1020
|
+
{
|
|
1021
|
+
...stored,
|
|
1022
|
+
uploadId: UploadId,
|
|
1023
|
+
file: {
|
|
1024
|
+
name: fileName,
|
|
1025
|
+
size: fileSize,
|
|
1026
|
+
type: input.contentType
|
|
1027
|
+
},
|
|
1028
|
+
metadata,
|
|
1029
|
+
clientMetadata: input.metadata,
|
|
1030
|
+
acl,
|
|
1031
|
+
replace: route.upload.replace
|
|
1032
|
+
},
|
|
1033
|
+
config
|
|
1034
|
+
);
|
|
621
1035
|
return { bucket, key, uploadId: UploadId };
|
|
622
1036
|
}
|
|
623
1037
|
var multipartInit = createS3Endpoint(
|
|
624
1038
|
S3_API_ROUTES.multipartInit,
|
|
625
1039
|
{ method: "POST", body: multipartInitBodySchema },
|
|
626
1040
|
async (ctx) => {
|
|
627
|
-
|
|
628
|
-
ctx.setStatus(201);
|
|
629
|
-
return handleMultipartInit(
|
|
1041
|
+
const result = await handleMultipartInit(
|
|
630
1042
|
ctx.context.config,
|
|
631
1043
|
ctx.body,
|
|
632
1044
|
ctx.context.request
|
|
633
1045
|
);
|
|
1046
|
+
ctx.setStatus(201);
|
|
1047
|
+
return result;
|
|
634
1048
|
}
|
|
635
1049
|
);
|
|
636
1050
|
async function handleListParts(config, input, request) {
|
|
637
|
-
const { key, bucket } = await
|
|
1051
|
+
const { route, key, bucket, stored } = await openStoredTarget(
|
|
1052
|
+
config,
|
|
1053
|
+
input,
|
|
638
1054
|
request,
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
});
|
|
1055
|
+
"multipart"
|
|
1056
|
+
);
|
|
642
1057
|
const uploadId = input.uploadId;
|
|
643
|
-
await runHook(
|
|
644
|
-
|
|
645
|
-
key,
|
|
646
|
-
bucket,
|
|
647
|
-
uploadId
|
|
648
|
-
});
|
|
649
|
-
const listed = await listAllParts(config.client, { bucket, key, uploadId });
|
|
650
|
-
const parts = listed.map((p) => ({
|
|
651
|
-
partNumber: p.PartNumber ?? 0,
|
|
652
|
-
size: p.Size ?? 0,
|
|
653
|
-
eTag: (p.ETag ?? "").replace(/"/g, "")
|
|
654
|
-
}));
|
|
655
|
-
await runLifecycleHook(config.multipart?.onList, {
|
|
656
|
-
request,
|
|
657
|
-
key,
|
|
658
|
-
bucket,
|
|
1058
|
+
await runHook(route.upload.multipart.guard, {
|
|
1059
|
+
...stored,
|
|
659
1060
|
uploadId,
|
|
660
|
-
|
|
1061
|
+
action: "list"
|
|
1062
|
+
});
|
|
1063
|
+
const listed = await listAllParts(route.client, { bucket, key, uploadId });
|
|
1064
|
+
const parts = listed.flatMap((p) => {
|
|
1065
|
+
if (p.PartNumber == null || p.PartNumber < 1) return [];
|
|
1066
|
+
return [
|
|
1067
|
+
{
|
|
1068
|
+
partNumber: p.PartNumber,
|
|
1069
|
+
size: p.Size ?? 0,
|
|
1070
|
+
eTag: (p.ETag ?? "").replace(/"/g, "")
|
|
1071
|
+
}
|
|
1072
|
+
];
|
|
661
1073
|
});
|
|
1074
|
+
await runLifecycleHook(
|
|
1075
|
+
route.upload.multipart.onList,
|
|
1076
|
+
{
|
|
1077
|
+
...stored,
|
|
1078
|
+
uploadId,
|
|
1079
|
+
parts
|
|
1080
|
+
},
|
|
1081
|
+
config
|
|
1082
|
+
);
|
|
662
1083
|
return { parts };
|
|
663
1084
|
}
|
|
664
1085
|
var multipartListParts = createS3Endpoint(
|
|
665
1086
|
S3_API_ROUTES.multipartListParts,
|
|
666
1087
|
{ method: "GET", query: multipartListPartsQuerySchema },
|
|
667
1088
|
async (ctx) => {
|
|
668
|
-
assertFeatureEnabled(ctx.context.config, "multipart");
|
|
669
1089
|
return handleListParts(ctx.context.config, ctx.query, ctx.context.request);
|
|
670
1090
|
}
|
|
671
1091
|
);
|
|
672
1092
|
async function handleSignPart(config, input, request) {
|
|
673
|
-
const { key, bucket } = await
|
|
1093
|
+
const { route, key, bucket, stored } = await openStoredTarget(
|
|
1094
|
+
config,
|
|
1095
|
+
input,
|
|
674
1096
|
request,
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
});
|
|
1097
|
+
"multipart"
|
|
1098
|
+
);
|
|
678
1099
|
const uploadId = input.uploadId;
|
|
679
1100
|
const partNumber = input.partNumber;
|
|
680
|
-
const expiresIn = normalizeExpiresIn(
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1101
|
+
const expiresIn = normalizeExpiresIn(
|
|
1102
|
+
route.upload.expiresIn,
|
|
1103
|
+
config.maxExpiresIn
|
|
1104
|
+
);
|
|
1105
|
+
const partSize = Math.floor(input.partSize);
|
|
1106
|
+
await runHook(route.upload.multipart.guard, {
|
|
1107
|
+
...stored,
|
|
686
1108
|
uploadId,
|
|
1109
|
+
action: "part",
|
|
687
1110
|
partNumber,
|
|
688
|
-
partSize
|
|
1111
|
+
partSize
|
|
689
1112
|
});
|
|
690
|
-
const
|
|
691
|
-
|
|
1113
|
+
const maxFileSize = route.upload.maxFileSize;
|
|
1114
|
+
assertWithinMaxFileSize(maxFileSize, partSize);
|
|
1115
|
+
if (typeof maxFileSize === "number") {
|
|
1116
|
+
const listed = await listAllParts(route.client, { bucket, key, uploadId });
|
|
1117
|
+
assertWithinMaxFileSize(
|
|
1118
|
+
maxFileSize,
|
|
1119
|
+
listedPartsByteSize(listed, partNumber) + partSize
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
const url = await getSignedUrl(
|
|
1123
|
+
route.client,
|
|
692
1124
|
new UploadPartCommand({
|
|
693
1125
|
Bucket: bucket,
|
|
694
1126
|
Key: key,
|
|
695
1127
|
UploadId: uploadId,
|
|
696
1128
|
PartNumber: partNumber,
|
|
697
|
-
|
|
1129
|
+
ContentLength: partSize
|
|
698
1130
|
}),
|
|
699
1131
|
{
|
|
700
1132
|
expiresIn,
|
|
701
|
-
|
|
1133
|
+
signableHeaders: /* @__PURE__ */ new Set(["content-length"])
|
|
702
1134
|
}
|
|
703
1135
|
);
|
|
704
1136
|
return {
|
|
705
|
-
|
|
1137
|
+
url,
|
|
706
1138
|
partNumber,
|
|
707
1139
|
uploadId,
|
|
708
1140
|
bucket,
|
|
709
1141
|
expiresIn,
|
|
710
|
-
|
|
1142
|
+
partSize
|
|
711
1143
|
};
|
|
712
1144
|
}
|
|
713
1145
|
var multipartPart = createS3Endpoint(
|
|
714
1146
|
S3_API_ROUTES.multipartPart,
|
|
715
1147
|
{ method: "POST", body: multipartSignPartBodySchema },
|
|
716
1148
|
async (ctx) => {
|
|
717
|
-
assertFeatureEnabled(ctx.context.config, "multipart");
|
|
718
1149
|
return handleSignPart(ctx.context.config, ctx.body, ctx.context.request);
|
|
719
1150
|
}
|
|
720
1151
|
);
|
|
721
|
-
function
|
|
1152
|
+
function objectUserMetadata(metadata) {
|
|
722
1153
|
if (!metadata) return {};
|
|
723
|
-
const
|
|
1154
|
+
const fields = {};
|
|
724
1155
|
for (const [k, v] of Object.entries(metadata)) {
|
|
725
|
-
|
|
1156
|
+
fields[`x-amz-meta-${k}`] = v;
|
|
726
1157
|
}
|
|
727
|
-
return
|
|
1158
|
+
return fields;
|
|
728
1159
|
}
|
|
729
1160
|
async function handleUpload(config, input, request) {
|
|
730
|
-
const
|
|
1161
|
+
const fileSize = Math.floor(input.fileSize);
|
|
1162
|
+
const fileName = input.fileName;
|
|
1163
|
+
const { route, key, bucket, metadata, acl, stored } = await openUploadTarget(
|
|
1164
|
+
config,
|
|
1165
|
+
{
|
|
1166
|
+
route: input.route,
|
|
1167
|
+
fileName,
|
|
1168
|
+
fileSize,
|
|
1169
|
+
contentType: input.contentType,
|
|
1170
|
+
metadata: input.metadata
|
|
1171
|
+
},
|
|
731
1172
|
request,
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
|
|
738
|
-
const acl = resolveRequestAcl(config.upload, input.acl);
|
|
739
|
-
const contentType = input.contentType ?? "application/octet-stream";
|
|
740
|
-
const fileSize = typeof input.fileSize === "number" && input.fileSize > 0 ? Math.floor(input.fileSize) : null;
|
|
741
|
-
if (config.upload?.requireFileSize && fileSize === null) {
|
|
742
|
-
throw errors.fileSizeRequiredUpload();
|
|
1173
|
+
"upload"
|
|
1174
|
+
);
|
|
1175
|
+
const method = route.upload.method ?? "POST";
|
|
1176
|
+
if (route.upload.checksum && !input.checksum) {
|
|
1177
|
+
throw errors.validationError("Checksum is required for this route");
|
|
743
1178
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1179
|
+
if (method === "POST" && fileSize > S3_MAX_POST_OBJECT_BYTES) {
|
|
1180
|
+
throw errors.payloadTooLarge(
|
|
1181
|
+
`POST uploads cannot exceed ${S3_MAX_POST_OBJECT_BYTES} bytes. Enable upload.multipart or set upload.method to "PUT".`
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
const expiresIn = normalizeExpiresIn(
|
|
1185
|
+
route.upload.expiresIn,
|
|
1186
|
+
config.maxExpiresIn
|
|
1187
|
+
);
|
|
1188
|
+
const contentType = input.contentType ?? "application/octet-stream";
|
|
1189
|
+
const hookCtx = {
|
|
1190
|
+
...stored,
|
|
1191
|
+
file: {
|
|
1192
|
+
name: fileName,
|
|
1193
|
+
size: fileSize,
|
|
1194
|
+
type: input.contentType
|
|
1195
|
+
},
|
|
1196
|
+
metadata,
|
|
1197
|
+
clientMetadata: input.metadata,
|
|
751
1198
|
acl,
|
|
752
|
-
|
|
753
|
-
}
|
|
754
|
-
|
|
1199
|
+
replace: route.upload.replace
|
|
1200
|
+
};
|
|
1201
|
+
await runHook(route.upload.guard, hookCtx);
|
|
755
1202
|
if (method === "PUT") {
|
|
756
1203
|
const putHeaders = {
|
|
757
1204
|
"Content-Type": contentType,
|
|
758
|
-
...
|
|
1205
|
+
...objectUserMetadata(metadata),
|
|
1206
|
+
"Content-Disposition": buildContentDisposition(fileName),
|
|
1207
|
+
...input.checksum ? { "x-amz-checksum-sha256": input.checksum } : {}
|
|
759
1208
|
};
|
|
760
|
-
if (input.fileName) {
|
|
761
|
-
putHeaders["Content-Disposition"] = buildContentDisposition(
|
|
762
|
-
input.fileName
|
|
763
|
-
);
|
|
764
|
-
}
|
|
765
1209
|
const url2 = await getSignedUrl(
|
|
766
|
-
|
|
1210
|
+
route.client,
|
|
767
1211
|
new PutObjectCommand({
|
|
768
1212
|
Bucket: bucket,
|
|
769
1213
|
Key: key,
|
|
770
1214
|
ContentType: contentType,
|
|
771
1215
|
ACL: acl,
|
|
772
|
-
Metadata:
|
|
773
|
-
|
|
774
|
-
|
|
1216
|
+
Metadata: metadata,
|
|
1217
|
+
ContentDisposition: buildContentDisposition(fileName),
|
|
1218
|
+
ContentLength: fileSize,
|
|
1219
|
+
...input.checksum ? { ChecksumSHA256: input.checksum, ChecksumAlgorithm: "SHA256" } : {}
|
|
775
1220
|
}),
|
|
776
1221
|
{
|
|
777
1222
|
expiresIn,
|
|
778
|
-
|
|
1223
|
+
signableHeaders: /* @__PURE__ */ new Set([
|
|
1224
|
+
"content-length",
|
|
1225
|
+
...input.checksum ? ["x-amz-checksum-sha256"] : []
|
|
1226
|
+
])
|
|
779
1227
|
}
|
|
780
1228
|
);
|
|
781
|
-
await runLifecycleHook(
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
url: url2,
|
|
791
|
-
expiresIn
|
|
792
|
-
});
|
|
1229
|
+
await runLifecycleHook(
|
|
1230
|
+
route.upload.onPresigned,
|
|
1231
|
+
{
|
|
1232
|
+
...hookCtx,
|
|
1233
|
+
url: url2,
|
|
1234
|
+
expiresIn
|
|
1235
|
+
},
|
|
1236
|
+
config
|
|
1237
|
+
);
|
|
793
1238
|
return {
|
|
794
1239
|
bucket,
|
|
795
1240
|
key,
|
|
@@ -799,39 +1244,32 @@ async function handleUpload(config, input, request) {
|
|
|
799
1244
|
method: "PUT"
|
|
800
1245
|
};
|
|
801
1246
|
}
|
|
802
|
-
const fields = {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
const rangeMin = fileSize ?? 1;
|
|
812
|
-
const rangeMax = fileSize ?? void 0;
|
|
1247
|
+
const fields = {
|
|
1248
|
+
acl,
|
|
1249
|
+
"Content-Type": contentType,
|
|
1250
|
+
"Content-Disposition": buildContentDisposition(fileName),
|
|
1251
|
+
...objectUserMetadata(metadata),
|
|
1252
|
+
...input.checksum ? { "x-amz-checksum-sha256": input.checksum } : {}
|
|
1253
|
+
};
|
|
813
1254
|
const { url, fields: signedFields } = await createPresignedPost(
|
|
814
|
-
|
|
1255
|
+
route.client,
|
|
815
1256
|
{
|
|
816
1257
|
Bucket: bucket,
|
|
817
1258
|
Key: key,
|
|
818
|
-
Conditions:
|
|
1259
|
+
Conditions: [["content-length-range", fileSize, fileSize]],
|
|
819
1260
|
Fields: fields,
|
|
820
1261
|
Expires: expiresIn
|
|
821
1262
|
}
|
|
822
1263
|
);
|
|
823
|
-
await runLifecycleHook(
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
url,
|
|
833
|
-
expiresIn
|
|
834
|
-
});
|
|
1264
|
+
await runLifecycleHook(
|
|
1265
|
+
route.upload.onPresigned,
|
|
1266
|
+
{
|
|
1267
|
+
...hookCtx,
|
|
1268
|
+
url,
|
|
1269
|
+
expiresIn
|
|
1270
|
+
},
|
|
1271
|
+
config
|
|
1272
|
+
);
|
|
835
1273
|
return {
|
|
836
1274
|
bucket,
|
|
837
1275
|
key,
|
|
@@ -845,7 +1283,6 @@ var upload = createS3Endpoint(
|
|
|
845
1283
|
S3_API_ROUTES.upload,
|
|
846
1284
|
{ method: "POST", body: uploadBodySchema },
|
|
847
1285
|
async (ctx) => {
|
|
848
|
-
assertFeatureEnabled(ctx.context.config, "upload");
|
|
849
1286
|
return handleUpload(ctx.context.config, ctx.body, ctx.context.request);
|
|
850
1287
|
}
|
|
851
1288
|
);
|
|
@@ -855,7 +1292,10 @@ var coreEndpoints = {
|
|
|
855
1292
|
upload,
|
|
856
1293
|
confirm,
|
|
857
1294
|
download,
|
|
1295
|
+
catalog,
|
|
1296
|
+
file,
|
|
858
1297
|
delete: deleteObject,
|
|
1298
|
+
deleteBatch,
|
|
859
1299
|
multipartInit,
|
|
860
1300
|
multipartPart,
|
|
861
1301
|
multipartListParts,
|
|
@@ -879,19 +1319,24 @@ function chainHooks(...hooks) {
|
|
|
879
1319
|
var FEATURE_HOOK_KEYS = {
|
|
880
1320
|
upload: ["guard", "onPresigned", "confirmGuard", "onConfirmed"],
|
|
881
1321
|
download: ["guard", "onPresigned"],
|
|
882
|
-
delete: ["guard", "onDeleted"]
|
|
883
|
-
multipart: [
|
|
884
|
-
"initGuard",
|
|
885
|
-
"partGuard",
|
|
886
|
-
"completeGuard",
|
|
887
|
-
"abortGuard",
|
|
888
|
-
"listGuard",
|
|
889
|
-
"onInit",
|
|
890
|
-
"onComplete",
|
|
891
|
-
"onAbort",
|
|
892
|
-
"onList"
|
|
893
|
-
]
|
|
1322
|
+
delete: ["guard", "onDeleted"]
|
|
894
1323
|
};
|
|
1324
|
+
var MULTIPART_HOOK_KEYS = [
|
|
1325
|
+
"onInit",
|
|
1326
|
+
"guard",
|
|
1327
|
+
"onAbort",
|
|
1328
|
+
"onList"
|
|
1329
|
+
];
|
|
1330
|
+
var LIFECYCLE_FEATURE_HOOK_KEYS = {
|
|
1331
|
+
upload: ["onPresigned", "onConfirmed"],
|
|
1332
|
+
download: ["onPresigned"],
|
|
1333
|
+
delete: ["onDeleted"]
|
|
1334
|
+
};
|
|
1335
|
+
var LIFECYCLE_MULTIPART_HOOK_KEYS = [
|
|
1336
|
+
"onInit",
|
|
1337
|
+
"onAbort",
|
|
1338
|
+
"onList"
|
|
1339
|
+
];
|
|
895
1340
|
|
|
896
1341
|
// src/plugin/types.ts
|
|
897
1342
|
var RESERVED_PLUGIN_IDS = [
|
|
@@ -900,35 +1345,74 @@ var RESERVED_PLUGIN_IDS = [
|
|
|
900
1345
|
"context",
|
|
901
1346
|
"getPlugin",
|
|
902
1347
|
"$ERROR_CODES",
|
|
903
|
-
"$Infer"
|
|
1348
|
+
"$Infer",
|
|
1349
|
+
"upload",
|
|
1350
|
+
"confirm",
|
|
1351
|
+
"download",
|
|
1352
|
+
"catalog",
|
|
1353
|
+
"file",
|
|
1354
|
+
"delete",
|
|
1355
|
+
"deleteBatch",
|
|
1356
|
+
"put",
|
|
1357
|
+
"multipart"
|
|
904
1358
|
];
|
|
905
1359
|
|
|
906
1360
|
// src/plugin/apply-plugins.ts
|
|
907
|
-
function mergeHookField(pluginHooks, userHook) {
|
|
908
|
-
const present = [...pluginHooks, userHook].filter(
|
|
909
|
-
(h) => h != null
|
|
910
|
-
);
|
|
1361
|
+
function mergeHookField(pluginHooks, userHook, order = "plugins-first") {
|
|
1362
|
+
const present = (order === "user-first" ? [userHook, ...pluginHooks] : [...pluginHooks, userHook]).filter((h) => h != null);
|
|
911
1363
|
if (present.length === 0) return void 0;
|
|
912
1364
|
if (present.length === 1) return present[0];
|
|
913
1365
|
return chainHooks(...present);
|
|
914
1366
|
}
|
|
915
|
-
function
|
|
916
|
-
|
|
917
|
-
return userFeature;
|
|
918
|
-
}
|
|
919
|
-
const merged = { ...userFeature ?? {} };
|
|
1367
|
+
function mergeHooks(keys, plugins, userFeature, pick, lifecycleKeys = /* @__PURE__ */ new Set()) {
|
|
1368
|
+
const merged = { ...userFeature };
|
|
920
1369
|
for (const key of keys) {
|
|
921
|
-
const
|
|
922
|
-
(
|
|
1370
|
+
const next = mergeHookField(
|
|
1371
|
+
plugins.map((plugin) => pick(plugin)?.[key]),
|
|
1372
|
+
userFeature[key],
|
|
1373
|
+
lifecycleKeys.has(key) ? "user-first" : "plugins-first"
|
|
923
1374
|
);
|
|
924
|
-
|
|
925
|
-
const next = mergeHookField(fromPlugins, userHook);
|
|
926
|
-
if (next !== void 0) {
|
|
927
|
-
merged[key] = next;
|
|
928
|
-
}
|
|
1375
|
+
if (next !== void 0) merged[key] = next;
|
|
929
1376
|
}
|
|
930
1377
|
return merged;
|
|
931
1378
|
}
|
|
1379
|
+
function applyHooksToRoute(route, plugins) {
|
|
1380
|
+
const active = plugins.filter((p) => !route.skippedPluginIds.has(p.id));
|
|
1381
|
+
const upload2 = isEnabled(route.upload) ? {
|
|
1382
|
+
...mergeHooks(
|
|
1383
|
+
FEATURE_HOOK_KEYS.upload,
|
|
1384
|
+
active,
|
|
1385
|
+
route.upload,
|
|
1386
|
+
(p) => p.hooks?.upload,
|
|
1387
|
+
new Set(LIFECYCLE_FEATURE_HOOK_KEYS.upload)
|
|
1388
|
+
),
|
|
1389
|
+
multipart: isEnabled(route.upload.multipart) ? mergeHooks(
|
|
1390
|
+
MULTIPART_HOOK_KEYS,
|
|
1391
|
+
active,
|
|
1392
|
+
route.upload.multipart,
|
|
1393
|
+
(p) => p.hooks?.upload?.multipart,
|
|
1394
|
+
new Set(LIFECYCLE_MULTIPART_HOOK_KEYS)
|
|
1395
|
+
) : route.upload.multipart
|
|
1396
|
+
} : route.upload;
|
|
1397
|
+
return {
|
|
1398
|
+
...route,
|
|
1399
|
+
upload: upload2,
|
|
1400
|
+
download: isEnabled(route.download) ? mergeHooks(
|
|
1401
|
+
FEATURE_HOOK_KEYS.download,
|
|
1402
|
+
active,
|
|
1403
|
+
route.download,
|
|
1404
|
+
(p) => p.hooks?.download,
|
|
1405
|
+
new Set(LIFECYCLE_FEATURE_HOOK_KEYS.download)
|
|
1406
|
+
) : route.download,
|
|
1407
|
+
delete: isEnabled(route.delete) ? mergeHooks(
|
|
1408
|
+
FEATURE_HOOK_KEYS.delete,
|
|
1409
|
+
active,
|
|
1410
|
+
route.delete,
|
|
1411
|
+
(p) => p.hooks?.delete,
|
|
1412
|
+
new Set(LIFECYCLE_FEATURE_HOOK_KEYS.delete)
|
|
1413
|
+
) : route.delete
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
932
1416
|
function applyPlugins(config) {
|
|
933
1417
|
const plugins = config.plugins ?? [];
|
|
934
1418
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -1017,37 +1501,25 @@ function applyPlugins(config) {
|
|
|
1017
1501
|
}
|
|
1018
1502
|
}
|
|
1019
1503
|
const { plugins: _omit, ...rest } = config;
|
|
1020
|
-
|
|
1504
|
+
const normalized = normalizeRoutes(rest);
|
|
1021
1505
|
for (const plugin of plugins) {
|
|
1022
1506
|
plugin.init?.({ config: rest, getPlugin });
|
|
1023
1507
|
}
|
|
1024
|
-
const
|
|
1025
|
-
const {
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
delete: _delete,
|
|
1029
|
-
multipart: _multipart,
|
|
1030
|
-
...base
|
|
1031
|
-
} = rest;
|
|
1508
|
+
const routes = {};
|
|
1509
|
+
for (const [name, route] of Object.entries(normalized)) {
|
|
1510
|
+
routes[name] = applyHooksToRoute(route, plugins);
|
|
1511
|
+
}
|
|
1032
1512
|
const guard = mergeHookField(
|
|
1033
1513
|
plugins.map((p) => p.hooks?.guard),
|
|
1034
1514
|
rest.guard
|
|
1035
1515
|
);
|
|
1036
1516
|
const merged = {
|
|
1037
|
-
...
|
|
1038
|
-
|
|
1517
|
+
...rest,
|
|
1518
|
+
routes,
|
|
1039
1519
|
...guard !== void 0 ? { guard } : {}
|
|
1040
1520
|
};
|
|
1041
|
-
for (const feature of Object.keys(FEATURE_HOOK_KEYS)) {
|
|
1042
|
-
merged[feature] = mergeFeatureHooks(
|
|
1043
|
-
feature,
|
|
1044
|
-
FEATURE_HOOK_KEYS[feature],
|
|
1045
|
-
plugins,
|
|
1046
|
-
merged[feature]
|
|
1047
|
-
);
|
|
1048
|
-
}
|
|
1049
1521
|
return {
|
|
1050
|
-
config:
|
|
1522
|
+
config: merged,
|
|
1051
1523
|
context,
|
|
1052
1524
|
getPlugin,
|
|
1053
1525
|
endpoints
|
|
@@ -1059,6 +1531,10 @@ function definePlugin(plugin) {
|
|
|
1059
1531
|
return plugin;
|
|
1060
1532
|
}
|
|
1061
1533
|
|
|
1062
|
-
|
|
1534
|
+
// src/types/config.ts
|
|
1535
|
+
var ROUTE_FEATURES = ["upload", "download", "delete"];
|
|
1536
|
+
var ROUTE_OPERATIONS = [...ROUTE_FEATURES, "multipart"];
|
|
1537
|
+
|
|
1538
|
+
export { CORE_ENDPOINT_NAMES, FEATURE_HOOK_KEYS, LIFECYCLE_FEATURE_HOOK_KEYS, LIFECYCLE_MULTIPART_HOOK_KEYS, MULTIPART_HOOK_KEYS, RESERVED_PLUGIN_IDS, ROUTE_FEATURES, ROUTE_OPERATIONS, applyPlugins, chainHooks, createS3Endpoint, createS3Middleware, definePlugin, isFeatureOn };
|
|
1063
1539
|
//# sourceMappingURL=plugins.js.map
|
|
1064
1540
|
//# sourceMappingURL=plugins.js.map
|