lean-s3 0.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 +162 -0
- package/package.json +41 -0
- package/src/AmzDate.js +58 -0
- package/src/KeyCache.js +38 -0
- package/src/S3Client.js +576 -0
- package/src/S3Error.js +55 -0
- package/src/S3File.js +293 -0
- package/src/S3Stat.js +76 -0
- package/src/index.d.ts +80 -0
- package/src/index.js +4 -0
- package/src/sign.js +136 -0
- package/src/test-common.js +94 -0
- package/src/url.js +89 -0
package/src/S3Client.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { request, Dispatcher, Agent } from "undici";
|
|
4
|
+
|
|
5
|
+
import S3File from "./S3File.js";
|
|
6
|
+
import S3Error from "./S3Error.js";
|
|
7
|
+
import KeyCache from "./KeyCache.js";
|
|
8
|
+
import * as amzDate from "./AmzDate.js";
|
|
9
|
+
import * as sign from "./sign.js";
|
|
10
|
+
import {
|
|
11
|
+
buildRequestUrl,
|
|
12
|
+
getRangeHeader,
|
|
13
|
+
prepareHeadersForSigning,
|
|
14
|
+
} from "./url.js";
|
|
15
|
+
|
|
16
|
+
export const write = Symbol("write");
|
|
17
|
+
export const stream = Symbol("stream");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {import("./index.d.ts").S3ClientOptions} S3ClientOptions
|
|
21
|
+
* @typedef {import("./index.d.ts").PresignableHttpMethod} PresignableHttpMethod
|
|
22
|
+
* @typedef {import("./index.d.ts").StorageClass} StorageClass
|
|
23
|
+
* @typedef {import("./index.d.ts").Acl} Acl
|
|
24
|
+
* @typedef {import("./index.d.ts").S3FilePresignOptions} S3FilePresignOptions
|
|
25
|
+
* @typedef {import("./index.d.ts").OverridableS3ClientOptions} OverridableS3ClientOptions
|
|
26
|
+
* @typedef {import("./index.d.ts").CreateFileInstanceOptions} CreateFileInstanceOptions
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A configured S3 bucket instance for managing files.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```js
|
|
34
|
+
* // Basic bucket setup
|
|
35
|
+
* const bucket = new S3Client({
|
|
36
|
+
* bucket: "my-bucket",
|
|
37
|
+
* accessKeyId: "key",
|
|
38
|
+
* secretAccessKey: "secret"
|
|
39
|
+
* });
|
|
40
|
+
* // Get file instance
|
|
41
|
+
* const file = bucket.file("image.jpg");
|
|
42
|
+
* await file.delete();
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export default class S3Client {
|
|
46
|
+
/** @type {Readonly<S3ClientOptions>} */
|
|
47
|
+
#options;
|
|
48
|
+
#keyCache = new KeyCache();
|
|
49
|
+
|
|
50
|
+
// TODO: pass options to this in client
|
|
51
|
+
/** @type {Dispatcher} */
|
|
52
|
+
#dispatcher = new Agent();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a new instance of an S3 bucket so that credentials can be managed from a single instance instead of being passed to every method.
|
|
56
|
+
*
|
|
57
|
+
* @param {S3ClientOptions} options The default options to use for the S3 client.
|
|
58
|
+
*/
|
|
59
|
+
constructor(options) {
|
|
60
|
+
if (!options) {
|
|
61
|
+
throw new Error("`options` is required.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
accessKeyId,
|
|
66
|
+
secretAccessKey,
|
|
67
|
+
endpoint,
|
|
68
|
+
region,
|
|
69
|
+
bucket,
|
|
70
|
+
sessionToken,
|
|
71
|
+
} = options;
|
|
72
|
+
|
|
73
|
+
if (!accessKeyId || typeof accessKeyId !== "string") {
|
|
74
|
+
throw new Error("`accessKeyId` is required.");
|
|
75
|
+
}
|
|
76
|
+
if (!secretAccessKey || typeof secretAccessKey !== "string") {
|
|
77
|
+
throw new Error("`secretAccessKey` is required.");
|
|
78
|
+
}
|
|
79
|
+
if (!endpoint || typeof endpoint !== "string") {
|
|
80
|
+
throw new Error("`endpoint` is required.");
|
|
81
|
+
}
|
|
82
|
+
if (!region || typeof region !== "string") {
|
|
83
|
+
throw new Error("`region` is required.");
|
|
84
|
+
}
|
|
85
|
+
if (!bucket || typeof bucket !== "string") {
|
|
86
|
+
throw new Error("`bucket` is required.");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.#options = {
|
|
90
|
+
accessKeyId,
|
|
91
|
+
secretAccessKey,
|
|
92
|
+
endpoint,
|
|
93
|
+
region,
|
|
94
|
+
bucket,
|
|
95
|
+
sessionToken,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Creates an S3File instance for the given path.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} path
|
|
103
|
+
* @param {Partial<CreateFileInstanceOptions> | undefined} [options] TODO
|
|
104
|
+
* @returns {S3File}
|
|
105
|
+
* @example
|
|
106
|
+
* ```js
|
|
107
|
+
* const file = client.file("image.jpg");
|
|
108
|
+
* await file.write(imageData);
|
|
109
|
+
*
|
|
110
|
+
* const configFile = client.file("config.json", {
|
|
111
|
+
* type: "application/json",
|
|
112
|
+
* acl: "private"
|
|
113
|
+
* });
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
file(path, options) {
|
|
117
|
+
return new S3File(this, path, undefined, undefined, undefined);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Generate a presigned URL for temporary access to a file.
|
|
122
|
+
* Useful for generating upload/download URLs without exposing credentials.
|
|
123
|
+
* @param {string} path
|
|
124
|
+
* @param {Partial<S3FilePresignOptions & OverridableS3ClientOptions>} [signOptions]
|
|
125
|
+
* @returns {string} The operation on {@link S3Client#presign.path} as a pre-signed URL.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```js
|
|
129
|
+
* const downloadUrl = client.presign("file.pdf", {
|
|
130
|
+
* expiresIn: 3600 // 1 hour
|
|
131
|
+
* });
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
presign(
|
|
135
|
+
path,
|
|
136
|
+
{
|
|
137
|
+
method = "GET",
|
|
138
|
+
expiresIn = 3600, // TODO: Maybe rename this to expiresInSeconds
|
|
139
|
+
storageClass,
|
|
140
|
+
acl,
|
|
141
|
+
region: regionOverride,
|
|
142
|
+
bucket: bucketOverride,
|
|
143
|
+
endpoint: endpointOverride,
|
|
144
|
+
} = {},
|
|
145
|
+
) {
|
|
146
|
+
const now = new Date();
|
|
147
|
+
const date = amzDate.getAmzDate(now);
|
|
148
|
+
const options = this.#options;
|
|
149
|
+
|
|
150
|
+
const region = regionOverride ?? options.region;
|
|
151
|
+
const bucket = bucketOverride ?? options.bucket;
|
|
152
|
+
const endpoint = endpointOverride ?? options.endpoint;
|
|
153
|
+
|
|
154
|
+
const res = buildRequestUrl(endpoint, bucket, region, path);
|
|
155
|
+
|
|
156
|
+
const query = buildSearchParams(
|
|
157
|
+
`${options.accessKeyId}/${date.date}/${region}/s3/aws4_request`,
|
|
158
|
+
date,
|
|
159
|
+
expiresIn,
|
|
160
|
+
"host",
|
|
161
|
+
undefined,
|
|
162
|
+
storageClass,
|
|
163
|
+
options.sessionToken,
|
|
164
|
+
acl,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const dataDigest = sign.createCanonicalDataDigestHostOnly(
|
|
168
|
+
method,
|
|
169
|
+
res.pathname,
|
|
170
|
+
query.toString(),
|
|
171
|
+
res.host,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const signingKey = this.#keyCache.computeIfAbsent(
|
|
175
|
+
date,
|
|
176
|
+
region,
|
|
177
|
+
options.accessKeyId,
|
|
178
|
+
options.secretAccessKey,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const signature = sign.signCanonicalDataHash(
|
|
182
|
+
signingKey,
|
|
183
|
+
dataDigest,
|
|
184
|
+
date,
|
|
185
|
+
region,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// See `buildSearchParams` for casing on this parameter
|
|
189
|
+
query.set("X-Amz-Signature", signature);
|
|
190
|
+
|
|
191
|
+
res.search = query.toString();
|
|
192
|
+
return res.toString();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @internal
|
|
197
|
+
* @param {string} path
|
|
198
|
+
* @param {import("./index.d.ts").UndiciBodyInit} data TODO
|
|
199
|
+
* @param {string} contentType
|
|
200
|
+
* @param {number | undefined} contentLength
|
|
201
|
+
* @param {Buffer | undefined} contentHash
|
|
202
|
+
* @param {number | undefined} rageStart
|
|
203
|
+
* @param {number | undefined} rangeEndExclusive
|
|
204
|
+
* @param {AbortSignal | undefined} signal
|
|
205
|
+
* @returns {Promise<void>}
|
|
206
|
+
*/
|
|
207
|
+
async [write](
|
|
208
|
+
path,
|
|
209
|
+
data,
|
|
210
|
+
contentType,
|
|
211
|
+
contentLength,
|
|
212
|
+
contentHash,
|
|
213
|
+
rageStart,
|
|
214
|
+
rangeEndExclusive,
|
|
215
|
+
signal,
|
|
216
|
+
) {
|
|
217
|
+
const bucket = this.#options.bucket;
|
|
218
|
+
const endpoint = this.#options.endpoint;
|
|
219
|
+
const region = this.#options.region;
|
|
220
|
+
|
|
221
|
+
const url = buildRequestUrl(endpoint, bucket, region, path);
|
|
222
|
+
|
|
223
|
+
const now = amzDate.now();
|
|
224
|
+
|
|
225
|
+
// Signed headers have to be sorted
|
|
226
|
+
// To enhance sorting, we're adding all possible values somehow pre-ordered
|
|
227
|
+
const headersToBeSigned = prepareHeadersForSigning({
|
|
228
|
+
"amz-sdk-invocation-id": crypto.randomUUID(),
|
|
229
|
+
"content-length": contentLength?.toString() ?? undefined,
|
|
230
|
+
"content-type": contentType,
|
|
231
|
+
host: url.host,
|
|
232
|
+
range: getRangeHeader(rageStart, rangeEndExclusive),
|
|
233
|
+
"x-amz-content-sha256": contentHash?.toString("hex") ?? undefined,
|
|
234
|
+
"x-amz-date": now.dateTime,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/** @type {import("undici").Dispatcher.ResponseData<unknown> | undefined} */
|
|
238
|
+
let response = undefined;
|
|
239
|
+
try {
|
|
240
|
+
response = await request(url, {
|
|
241
|
+
method: "PUT",
|
|
242
|
+
signal,
|
|
243
|
+
dispatcher: this.#dispatcher,
|
|
244
|
+
headers: {
|
|
245
|
+
...headersToBeSigned,
|
|
246
|
+
authorization: this.#getAuthorizationHeader(
|
|
247
|
+
"PUT",
|
|
248
|
+
url.pathname,
|
|
249
|
+
url.search,
|
|
250
|
+
now,
|
|
251
|
+
headersToBeSigned,
|
|
252
|
+
region,
|
|
253
|
+
contentHash,
|
|
254
|
+
this.#options.accessKeyId,
|
|
255
|
+
this.#options.secretAccessKey,
|
|
256
|
+
),
|
|
257
|
+
accept: "application/json", // So that we can parse errors as JSON instead of XML, if the server supports that
|
|
258
|
+
"user-agent": "lean-s3",
|
|
259
|
+
},
|
|
260
|
+
body: data,
|
|
261
|
+
});
|
|
262
|
+
} catch (cause) {
|
|
263
|
+
signal?.throwIfAborted();
|
|
264
|
+
throw new S3Error("Unknown", path, {
|
|
265
|
+
message: "Unknown error during S3 request.",
|
|
266
|
+
cause,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const status = response.statusCode;
|
|
271
|
+
if (200 <= status && status < 300) {
|
|
272
|
+
// everything seemed to work, no need to process response body
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ct = response.headers["content-type"];
|
|
277
|
+
if (ct === "application/json") {
|
|
278
|
+
/** @type {any} */
|
|
279
|
+
let error = undefined;
|
|
280
|
+
try {
|
|
281
|
+
error = await response.body.json();
|
|
282
|
+
} catch (cause) {
|
|
283
|
+
throw new S3Error("Unknown", path, {
|
|
284
|
+
message: "Could not read response body.",
|
|
285
|
+
cause,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
throw new S3Error(error?.Code ?? "Unknown", path, {
|
|
289
|
+
message: error?.Message || undefined, // Message might be "",
|
|
290
|
+
requestId: error?.RequestId || undefined, // RequestId might be ""
|
|
291
|
+
hostId: error?.HostId || undefined, // HostId might be ""
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let body = undefined;
|
|
296
|
+
try {
|
|
297
|
+
body = await response.body.text();
|
|
298
|
+
} catch (cause) {
|
|
299
|
+
throw new S3Error("Unknown", path, {
|
|
300
|
+
message: "Could not read response body.",
|
|
301
|
+
cause,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
if (ct === "application/xml") {
|
|
305
|
+
const error = tryProcessXMLError(body);
|
|
306
|
+
throw new S3Error(error.code ?? "Unknown", path, {
|
|
307
|
+
message: error.message || undefined, // Message might be "",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
throw new S3Error("Unknown", path, {
|
|
311
|
+
message: "Unknown error during S3 request.",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// TODO: Support abortSignal
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* @internal
|
|
319
|
+
* @param {string} path
|
|
320
|
+
* @param {Buffer | undefined} contentHash
|
|
321
|
+
* @param {number | undefined} rageStart
|
|
322
|
+
* @param {number | undefined} rangeEndExclusive
|
|
323
|
+
* @returns
|
|
324
|
+
*/
|
|
325
|
+
[stream](path, contentHash, rageStart, rangeEndExclusive) {
|
|
326
|
+
const bucket = this.#options.bucket;
|
|
327
|
+
const endpoint = this.#options.endpoint;
|
|
328
|
+
const region = this.#options.region;
|
|
329
|
+
const now = amzDate.now();
|
|
330
|
+
const url = buildRequestUrl(endpoint, bucket, region, path);
|
|
331
|
+
|
|
332
|
+
const range = getRangeHeader(rageStart, rangeEndExclusive);
|
|
333
|
+
|
|
334
|
+
const headersToBeSigned = prepareHeadersForSigning({
|
|
335
|
+
"amz-sdk-invocation-id": crypto.randomUUID(),
|
|
336
|
+
// TODO: Maybe support retries and do "amz-sdk-request": attempt=1; max=3
|
|
337
|
+
host: url.host,
|
|
338
|
+
range,
|
|
339
|
+
// Hetzner doesnt care if the x-amz-content-sha256 header is missing, R2 requires it to be present
|
|
340
|
+
"x-amz-content-sha256":
|
|
341
|
+
contentHash?.toString("hex") ?? "UNSIGNED-PAYLOAD",
|
|
342
|
+
"x-amz-date": now.dateTime,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const ac = new AbortController();
|
|
346
|
+
|
|
347
|
+
return new ReadableStream({
|
|
348
|
+
type: "bytes",
|
|
349
|
+
start: controller => {
|
|
350
|
+
const onNetworkError = (/** @type {unknown} */ cause) => {
|
|
351
|
+
controller.error(
|
|
352
|
+
new S3Error("Unknown", path, {
|
|
353
|
+
message: undefined,
|
|
354
|
+
cause,
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
request(url, {
|
|
360
|
+
method: "GET",
|
|
361
|
+
signal: ac.signal,
|
|
362
|
+
dispatcher: this.#dispatcher,
|
|
363
|
+
headers: {
|
|
364
|
+
...headersToBeSigned,
|
|
365
|
+
authorization: this.#getAuthorizationHeader(
|
|
366
|
+
"GET",
|
|
367
|
+
url.pathname,
|
|
368
|
+
url.search,
|
|
369
|
+
now,
|
|
370
|
+
headersToBeSigned,
|
|
371
|
+
region,
|
|
372
|
+
contentHash,
|
|
373
|
+
this.#options.accessKeyId,
|
|
374
|
+
this.#options.secretAccessKey,
|
|
375
|
+
),
|
|
376
|
+
"user-agent": "lean-s3",
|
|
377
|
+
},
|
|
378
|
+
}).then(response => {
|
|
379
|
+
const onData = controller.enqueue.bind(controller);
|
|
380
|
+
const onClose = controller.close.bind(controller);
|
|
381
|
+
|
|
382
|
+
const expectPartialResponse = range !== undefined;
|
|
383
|
+
const status = response.statusCode;
|
|
384
|
+
if (status === 200) {
|
|
385
|
+
if (expectPartialResponse) {
|
|
386
|
+
return controller.error(
|
|
387
|
+
new S3Error("Unknown", path, {
|
|
388
|
+
message: "Expected partial response to range request.",
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
response.body.on("data", onData);
|
|
394
|
+
response.body.once("error", onNetworkError);
|
|
395
|
+
response.body.once("end", onClose);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (status === 206) {
|
|
400
|
+
if (!expectPartialResponse) {
|
|
401
|
+
return controller.error(
|
|
402
|
+
new S3Error("Unknown", path, {
|
|
403
|
+
message:
|
|
404
|
+
"Received partial response but expected a full response.",
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
response.body.on("data", onData);
|
|
410
|
+
response.body.once("error", onNetworkError);
|
|
411
|
+
response.body.once("end", onClose);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (400 <= status && status < 500) {
|
|
416
|
+
// Some providers actually support JSON via "accept: application/json", but we cant rely on it
|
|
417
|
+
const responseText = undefined;
|
|
418
|
+
const ct = response.headers["content-type"];
|
|
419
|
+
|
|
420
|
+
if (ct === "application/xml") {
|
|
421
|
+
return response.body.text().then(body => {
|
|
422
|
+
const error = tryProcessXMLError(body);
|
|
423
|
+
const code = error?.code || "Unknown"; // || instead of ??, so we coerce empty strings
|
|
424
|
+
return controller.error(
|
|
425
|
+
new S3Error(code, path, {
|
|
426
|
+
message: error?.message || undefined, // || instead of ??, so we coerce empty strings
|
|
427
|
+
cause: responseText,
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
}, onNetworkError);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (typeof ct === "string" && ct.startsWith("application/json")) {
|
|
434
|
+
return response.body.json().then((/** @type {any} */ error) => {
|
|
435
|
+
const code = error?.code || "Unknown"; // || instead of ??, so we coerce empty strings
|
|
436
|
+
return controller.error(
|
|
437
|
+
new S3Error(code, path, {
|
|
438
|
+
message: error?.message || undefined, // || instead of ??, so we coerce empty strings
|
|
439
|
+
cause: responseText,
|
|
440
|
+
}),
|
|
441
|
+
);
|
|
442
|
+
}, onNetworkError);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return controller.error(
|
|
446
|
+
new S3Error("Unknown", path, {
|
|
447
|
+
message: undefined,
|
|
448
|
+
cause: responseText,
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// TODO: Support other status codes
|
|
454
|
+
return controller.error(
|
|
455
|
+
new Error(
|
|
456
|
+
`Handling for status code ${status} not implemented yet. You might want to open an issue and describe your situation.`,
|
|
457
|
+
),
|
|
458
|
+
);
|
|
459
|
+
}, onNetworkError);
|
|
460
|
+
},
|
|
461
|
+
cancel(reason) {
|
|
462
|
+
ac.abort(reason);
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* @param {PresignableHttpMethod} method
|
|
469
|
+
* @param {string} path
|
|
470
|
+
* @param {string} query
|
|
471
|
+
* @param {amzDate.AmzDate} date
|
|
472
|
+
* @param {Record<string, string>} sortedSignedHeaders
|
|
473
|
+
* @param {string} region
|
|
474
|
+
* @param {Buffer | undefined} contentHash
|
|
475
|
+
* @param {string} accessKeyId
|
|
476
|
+
* @param {string} secretAccessKey
|
|
477
|
+
*/
|
|
478
|
+
#getAuthorizationHeader(
|
|
479
|
+
method,
|
|
480
|
+
path,
|
|
481
|
+
query,
|
|
482
|
+
date,
|
|
483
|
+
sortedSignedHeaders,
|
|
484
|
+
region,
|
|
485
|
+
contentHash,
|
|
486
|
+
accessKeyId,
|
|
487
|
+
secretAccessKey,
|
|
488
|
+
) {
|
|
489
|
+
const dataDigest = sign.createCanonicalDataDigest(
|
|
490
|
+
method,
|
|
491
|
+
path,
|
|
492
|
+
query,
|
|
493
|
+
sortedSignedHeaders,
|
|
494
|
+
contentHash?.toString("hex") ?? sign.unsignedPayload,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const signingKey = this.#keyCache.computeIfAbsent(
|
|
498
|
+
date,
|
|
499
|
+
region,
|
|
500
|
+
accessKeyId,
|
|
501
|
+
secretAccessKey,
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const signature = sign.signCanonicalDataHash(
|
|
505
|
+
signingKey,
|
|
506
|
+
dataDigest,
|
|
507
|
+
date,
|
|
508
|
+
region,
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
// no encodeURIComponent because because we assume that all headers don't need escaping
|
|
512
|
+
const signedHeadersSpec = Object.keys(sortedSignedHeaders).join(";");
|
|
513
|
+
const credentialSpec = `${accessKeyId}/${date.date}/${region}/s3/aws4_request`;
|
|
514
|
+
return `AWS4-HMAC-SHA256 Credential=${credentialSpec}, SignedHeaders=${signedHeadersSpec}, Signature=${signature}`;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* @param {string} amzCredential
|
|
520
|
+
* @param {import("./AmzDate.js").AmzDate} date
|
|
521
|
+
* @param {number} expiresIn
|
|
522
|
+
* @param {string} headerList
|
|
523
|
+
* @param {StorageClass | null | undefined} storageClass
|
|
524
|
+
* @param {string | null | undefined} sessionToken
|
|
525
|
+
* @param {Acl | null | undefined} acl
|
|
526
|
+
* @param {string | null | undefined} contentHashStr
|
|
527
|
+
* @returns
|
|
528
|
+
*/
|
|
529
|
+
export function buildSearchParams(
|
|
530
|
+
amzCredential,
|
|
531
|
+
date,
|
|
532
|
+
expiresIn,
|
|
533
|
+
headerList,
|
|
534
|
+
contentHashStr,
|
|
535
|
+
storageClass,
|
|
536
|
+
sessionToken,
|
|
537
|
+
acl,
|
|
538
|
+
) {
|
|
539
|
+
// We tried to make these query params entirely lower-cased, just like the headers
|
|
540
|
+
// but Cloudflare R2 requires them to have this exact casing
|
|
541
|
+
|
|
542
|
+
const q = new URLSearchParams();
|
|
543
|
+
q.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256");
|
|
544
|
+
q.set("X-Amz-Credential", amzCredential);
|
|
545
|
+
q.set("X-Amz-Date", date.dateTime);
|
|
546
|
+
q.set("X-Amz-Expires", expiresIn.toString());
|
|
547
|
+
q.set("X-Amz-SignedHeaders", headerList);
|
|
548
|
+
|
|
549
|
+
if (contentHashStr) {
|
|
550
|
+
q.set("X-Amz-Content-Sha256", contentHashStr);
|
|
551
|
+
}
|
|
552
|
+
if (storageClass) {
|
|
553
|
+
q.set("X-Amz-Storage-Class", storageClass);
|
|
554
|
+
}
|
|
555
|
+
if (sessionToken) {
|
|
556
|
+
q.set("X-Amz-Security-Token", sessionToken);
|
|
557
|
+
}
|
|
558
|
+
if (acl) {
|
|
559
|
+
q.set("X-Amz-Acl", acl);
|
|
560
|
+
}
|
|
561
|
+
return q;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const codePattern = /<Code>([a-zA-Z0-9\s-]+?)<\/Code>/g;
|
|
565
|
+
const messagePattern = /<Message>([a-zA-Z0-9\s-\.]+?)<\/Message>/g;
|
|
566
|
+
/**
|
|
567
|
+
* @param {string} responseText May or may not be XML
|
|
568
|
+
*/
|
|
569
|
+
function tryProcessXMLError(responseText) {
|
|
570
|
+
// We don't have an XML parser in Node.js' std lib and we don't want to reference one for an optional diagnostic
|
|
571
|
+
// So... :hide-the-pain-harold:
|
|
572
|
+
return {
|
|
573
|
+
code: codePattern.exec(responseText)?.[1] ?? undefined,
|
|
574
|
+
message: messagePattern.exec(responseText)?.[1] ?? undefined,
|
|
575
|
+
};
|
|
576
|
+
}
|
package/src/S3Error.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export default class S3Error extends Error {
|
|
2
|
+
/**
|
|
3
|
+
* @type {string}
|
|
4
|
+
* @readonly
|
|
5
|
+
*/
|
|
6
|
+
code;
|
|
7
|
+
/**
|
|
8
|
+
* @type {string}
|
|
9
|
+
* @readonly
|
|
10
|
+
*/
|
|
11
|
+
path;
|
|
12
|
+
/**
|
|
13
|
+
* @type {string | undefined}
|
|
14
|
+
* @readonly
|
|
15
|
+
*/
|
|
16
|
+
message;
|
|
17
|
+
/**
|
|
18
|
+
* @type {string | undefined}
|
|
19
|
+
* @readonly
|
|
20
|
+
*/
|
|
21
|
+
requestId;
|
|
22
|
+
/**
|
|
23
|
+
* @type {string | undefined}
|
|
24
|
+
* @readonly
|
|
25
|
+
*/
|
|
26
|
+
hostId;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} code
|
|
30
|
+
* @param {string} path
|
|
31
|
+
* @param {{
|
|
32
|
+
* message?: string | undefined
|
|
33
|
+
* requestId?: string | undefined
|
|
34
|
+
* hostId?: string | undefined
|
|
35
|
+
* cause?: unknown
|
|
36
|
+
* }} options
|
|
37
|
+
*/
|
|
38
|
+
constructor(
|
|
39
|
+
code,
|
|
40
|
+
path,
|
|
41
|
+
{
|
|
42
|
+
message = undefined,
|
|
43
|
+
requestId = undefined,
|
|
44
|
+
hostId = undefined,
|
|
45
|
+
cause = undefined,
|
|
46
|
+
} = {},
|
|
47
|
+
) {
|
|
48
|
+
super(message, { cause });
|
|
49
|
+
this.code = code;
|
|
50
|
+
this.path = path;
|
|
51
|
+
this.message = message;
|
|
52
|
+
this.requestId = requestId;
|
|
53
|
+
this.hostId = hostId;
|
|
54
|
+
}
|
|
55
|
+
}
|