s3mini 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -3
- package/dist/s3mini.d.ts +217 -61
- package/dist/s3mini.js +507 -250
- package/dist/s3mini.js.map +1 -1
- package/dist/s3mini.min.js +1 -1
- package/dist/s3mini.min.js.map +1 -1
- package/package.json +24 -21
- package/src/S3.ts +584 -268
- package/src/consts.ts +4 -4
- package/src/index.ts +3 -3
- package/src/types.ts +76 -13
- package/src/utils.ts +66 -22
package/dist/s3mini.js
CHANGED
|
@@ -11,11 +11,12 @@ const SENSITIVE_KEYS_REDACTED = ['accessKeyId', 'secretAccessKey', 'sessionToken
|
|
|
11
11
|
const DEFAULT_REQUEST_SIZE_IN_BYTES = 8 * 1024 * 1024;
|
|
12
12
|
// Headers
|
|
13
13
|
const HEADER_AMZ_CONTENT_SHA256 = 'x-amz-content-sha256';
|
|
14
|
+
const HEADER_AMZ_CHECKSUM_SHA256 = 'x-amz-checksum-sha256';
|
|
14
15
|
const HEADER_AMZ_DATE = 'x-amz-date';
|
|
15
16
|
const HEADER_HOST = 'host';
|
|
16
|
-
const HEADER_AUTHORIZATION = '
|
|
17
|
-
const HEADER_CONTENT_TYPE = '
|
|
18
|
-
const HEADER_CONTENT_LENGTH = '
|
|
17
|
+
const HEADER_AUTHORIZATION = 'authorization';
|
|
18
|
+
const HEADER_CONTENT_TYPE = 'content-type';
|
|
19
|
+
const HEADER_CONTENT_LENGTH = 'content-length';
|
|
19
20
|
const HEADER_ETAG = 'etag';
|
|
20
21
|
// Error messages
|
|
21
22
|
const ERROR_PREFIX = '[s3mini] ';
|
|
@@ -26,35 +27,70 @@ const ERROR_ENDPOINT_FORMAT = `${ERROR_PREFIX}endpoint must be a valid URL. Expe
|
|
|
26
27
|
const ERROR_KEY_REQUIRED = `${ERROR_PREFIX}key must be a non-empty string`;
|
|
27
28
|
const ERROR_UPLOAD_ID_REQUIRED = `${ERROR_PREFIX}uploadId must be a non-empty string`;
|
|
28
29
|
const ERROR_DATA_BUFFER_REQUIRED = `${ERROR_PREFIX}data must be a Buffer or string`;
|
|
29
|
-
// const ERROR_PATH_REQUIRED = `${ERROR_PREFIX}path must be a string`;
|
|
30
30
|
const ERROR_PREFIX_TYPE = `${ERROR_PREFIX}prefix must be a string`;
|
|
31
31
|
const ERROR_DELIMITER_REQUIRED = `${ERROR_PREFIX}delimiter must be a string`;
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
33
|
+
const ENCODR = new TextEncoder();
|
|
34
|
+
const chunkSize = 0x8000; // 32KB chunks
|
|
35
|
+
const HEXS = '0123456789abcdef';
|
|
36
|
+
const getByteSize = (data) => {
|
|
37
|
+
if (typeof data === 'string') {
|
|
38
|
+
return ENCODR.encode(data).byteLength;
|
|
39
|
+
}
|
|
40
|
+
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
|
41
|
+
return data.byteLength;
|
|
42
|
+
}
|
|
43
|
+
if (data instanceof Blob) {
|
|
44
|
+
return data.size;
|
|
45
|
+
}
|
|
46
|
+
throw new Error('Unsupported data type');
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Turn a raw ArrayBuffer into its hexadecimal representation.
|
|
50
|
+
* @param {ArrayBuffer} buffer The raw bytes.
|
|
51
|
+
* @returns {string} Hexadecimal string
|
|
52
|
+
*/
|
|
53
|
+
const hexFromBuffer = (buffer) => {
|
|
54
|
+
const bytes = new Uint8Array(buffer);
|
|
55
|
+
let hex = '';
|
|
56
|
+
for (const byte of bytes) {
|
|
57
|
+
hex += HEXS[byte >> 4] + HEXS[byte & 0x0f];
|
|
58
|
+
}
|
|
59
|
+
return hex;
|
|
60
|
+
};
|
|
37
61
|
/**
|
|
38
|
-
*
|
|
39
|
-
* @param {
|
|
40
|
-
* @returns {string}
|
|
62
|
+
* Turn a raw ArrayBuffer into its base64 representation.
|
|
63
|
+
* @param {ArrayBuffer} buffer The raw bytes.
|
|
64
|
+
* @returns {string} Base64 string
|
|
41
65
|
*/
|
|
42
|
-
const
|
|
43
|
-
|
|
66
|
+
const base64FromBuffer = (buffer) => {
|
|
67
|
+
const bytes = new Uint8Array(buffer);
|
|
68
|
+
let result = '';
|
|
69
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
70
|
+
const chunk = bytes.subarray(i, i + chunkSize);
|
|
71
|
+
result += btoa(String.fromCharCode.apply(null, chunk));
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
44
74
|
};
|
|
45
|
-
|
|
46
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Compute SHA-256 hash of arbitrary string data.
|
|
77
|
+
* @param {string} content The content to be hashed.
|
|
78
|
+
* @returns {ArrayBuffer} The raw hash
|
|
79
|
+
*/
|
|
80
|
+
const sha256 = async (content) => {
|
|
81
|
+
const data = ENCODR.encode(content);
|
|
82
|
+
return await globalThis.crypto.subtle.digest('SHA-256', data);
|
|
47
83
|
};
|
|
48
84
|
/**
|
|
49
|
-
* Compute HMAC-SHA-256 of arbitrary data
|
|
50
|
-
* @param {string|
|
|
51
|
-
* @param {string
|
|
52
|
-
* @
|
|
53
|
-
* @returns {string | Buffer} hex encoded HMAC
|
|
85
|
+
* Compute HMAC-SHA-256 of arbitrary data.
|
|
86
|
+
* @param {string|ArrayBuffer} key The key used to sign the content.
|
|
87
|
+
* @param {string} content The content to be signed.
|
|
88
|
+
* @returns {ArrayBuffer} The raw signature
|
|
54
89
|
*/
|
|
55
|
-
const hmac = (key, content
|
|
56
|
-
const
|
|
57
|
-
|
|
90
|
+
const hmac = async (key, content) => {
|
|
91
|
+
const secret = await globalThis.crypto.subtle.importKey('raw', typeof key === 'string' ? ENCODR.encode(key) : key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
92
|
+
const data = ENCODR.encode(content);
|
|
93
|
+
return await globalThis.crypto.subtle.sign('HMAC', secret, data);
|
|
58
94
|
};
|
|
59
95
|
/**
|
|
60
96
|
* Sanitize ETag value by removing quotes and XML entities
|
|
@@ -69,7 +105,7 @@ const sanitizeETag = (etag) => {
|
|
|
69
105
|
'"': '',
|
|
70
106
|
'"': '',
|
|
71
107
|
};
|
|
72
|
-
return etag.replace(
|
|
108
|
+
return etag.replace(/(^("|"|"))|(("|"|")$)/g, m => replaceChars[m]);
|
|
73
109
|
};
|
|
74
110
|
const entityMap = {
|
|
75
111
|
'"': '"',
|
|
@@ -228,8 +264,8 @@ const runInBatches = async (tasks, batchSize = 30, minIntervalMs = 0) => {
|
|
|
228
264
|
* const s3 = new CoreS3({
|
|
229
265
|
* accessKeyId: 'your-access-key',
|
|
230
266
|
* secretAccessKey: 'your-secret-key',
|
|
231
|
-
* endpoint: 'https://your-s3-endpoint.com',
|
|
232
|
-
* region: '
|
|
267
|
+
* endpoint: 'https://your-s3-endpoint.com/bucket-name',
|
|
268
|
+
* region: 'auto' // by default is auto
|
|
233
269
|
* });
|
|
234
270
|
*
|
|
235
271
|
* // Upload a file
|
|
@@ -241,7 +277,7 @@ const runInBatches = async (tasks, batchSize = 30, minIntervalMs = 0) => {
|
|
|
241
277
|
* // Delete a file
|
|
242
278
|
* await s3.deleteObject('example.txt');
|
|
243
279
|
*/
|
|
244
|
-
class
|
|
280
|
+
class S3mini {
|
|
245
281
|
/**
|
|
246
282
|
* Creates an instance of the S3 class.
|
|
247
283
|
*
|
|
@@ -260,6 +296,7 @@ class s3mini {
|
|
|
260
296
|
secretAccessKey;
|
|
261
297
|
endpoint;
|
|
262
298
|
region;
|
|
299
|
+
bucketName;
|
|
263
300
|
requestSizeInBytes;
|
|
264
301
|
requestAbortTimeout;
|
|
265
302
|
logger;
|
|
@@ -269,8 +306,9 @@ class s3mini {
|
|
|
269
306
|
this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
|
|
270
307
|
this.accessKeyId = accessKeyId;
|
|
271
308
|
this.secretAccessKey = secretAccessKey;
|
|
272
|
-
this.endpoint = this._ensureValidUrl(endpoint);
|
|
309
|
+
this.endpoint = new URL(this._ensureValidUrl(endpoint));
|
|
273
310
|
this.region = region;
|
|
311
|
+
this.bucketName = this._extractBucketName();
|
|
274
312
|
this.requestSizeInBytes = requestSizeInBytes;
|
|
275
313
|
this.requestAbortTimeout = requestAbortTimeout;
|
|
276
314
|
this.logger = logger;
|
|
@@ -307,7 +345,7 @@ class s3mini {
|
|
|
307
345
|
// Include some general context, but sanitize sensitive parts
|
|
308
346
|
context: this._sanitize({
|
|
309
347
|
region: this.region,
|
|
310
|
-
endpoint: this.endpoint,
|
|
348
|
+
endpoint: this.endpoint.toString(),
|
|
311
349
|
// Only include the first few characters of the access key, if it exists
|
|
312
350
|
accessKeyId: this.accessKeyId ? `${this.accessKeyId.substring(0, 4)}...` : undefined,
|
|
313
351
|
}),
|
|
@@ -395,12 +433,15 @@ class s3mini {
|
|
|
395
433
|
}
|
|
396
434
|
return { filteredOpts, conditionalHeaders };
|
|
397
435
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (!(data instanceof Buffer || typeof data === 'string')) {
|
|
436
|
+
_validateData(data) {
|
|
437
|
+
if (!((globalThis.Buffer && data instanceof globalThis.Buffer) || typeof data === 'string')) {
|
|
401
438
|
this._log('error', ERROR_DATA_BUFFER_REQUIRED);
|
|
402
439
|
throw new TypeError(ERROR_DATA_BUFFER_REQUIRED);
|
|
403
440
|
}
|
|
441
|
+
return data;
|
|
442
|
+
}
|
|
443
|
+
_validateUploadPartParams(key, uploadId, data, partNumber, opts) {
|
|
444
|
+
this._checkKey(key);
|
|
404
445
|
if (typeof uploadId !== 'string' || uploadId.trim().length === 0) {
|
|
405
446
|
this._log('error', ERROR_UPLOAD_ID_REQUIRED);
|
|
406
447
|
throw new TypeError(ERROR_UPLOAD_ID_REQUIRED);
|
|
@@ -410,8 +451,9 @@ class s3mini {
|
|
|
410
451
|
throw new TypeError(`${ERROR_PREFIX}partNumber must be a positive integer`);
|
|
411
452
|
}
|
|
412
453
|
this._checkOpts(opts);
|
|
454
|
+
return this._validateData(data);
|
|
413
455
|
}
|
|
414
|
-
_sign(method, keyPath, query = {}, headers = {}) {
|
|
456
|
+
async _sign(method, keyPath, query = {}, headers = {}) {
|
|
415
457
|
// Create URL without appending keyPath first
|
|
416
458
|
const url = new URL(this.endpoint);
|
|
417
459
|
// Properly format the pathname to avoid double slashes
|
|
@@ -419,75 +461,53 @@ class s3mini {
|
|
|
419
461
|
url.pathname =
|
|
420
462
|
url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
|
|
421
463
|
}
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
464
|
+
const d = new Date();
|
|
465
|
+
const year = d.getUTCFullYear();
|
|
466
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
467
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
468
|
+
const shortDatetime = `${year}${month}${day}`;
|
|
469
|
+
const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
|
|
470
|
+
const credentialScope = `${shortDatetime}/${this.region}/${S3_SERVICE}/${AWS_REQUEST_TYPE}`;
|
|
471
|
+
headers[HEADER_AMZ_CONTENT_SHA256] = UNSIGNED_PAYLOAD;
|
|
426
472
|
headers[HEADER_AMZ_DATE] = fullDatetime;
|
|
427
473
|
headers[HEADER_HOST] = url.host;
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
.join('\n');
|
|
445
|
-
}
|
|
446
|
-
_buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders) {
|
|
447
|
-
return [
|
|
448
|
-
method,
|
|
449
|
-
url.pathname,
|
|
450
|
-
this._buildCanonicalQueryString(query),
|
|
451
|
-
`${canonicalHeaders}\n`,
|
|
452
|
-
signedHeaders,
|
|
453
|
-
UNSIGNED_PAYLOAD,
|
|
454
|
-
].join('\n');
|
|
455
|
-
}
|
|
456
|
-
_buildCredentialScope(shortDatetime) {
|
|
457
|
-
return [shortDatetime, this.region, S3_SERVICE, AWS_REQUEST_TYPE].join('/');
|
|
458
|
-
}
|
|
459
|
-
_buildStringToSign(fullDatetime, credentialScope, canonicalRequest) {
|
|
460
|
-
return [AWS_ALGORITHM, fullDatetime, credentialScope, hash(canonicalRequest)].join('\n');
|
|
461
|
-
}
|
|
462
|
-
_calculateSignature(shortDatetime, stringToSign) {
|
|
474
|
+
const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);
|
|
475
|
+
let canonicalHeaders = '';
|
|
476
|
+
let signedHeaders = '';
|
|
477
|
+
for (const [key, value] of Object.entries(headers).sort(([a], [b]) => a.localeCompare(b))) {
|
|
478
|
+
const lowerKey = key.toLowerCase();
|
|
479
|
+
if (!ignoredHeaders.has(lowerKey)) {
|
|
480
|
+
if (canonicalHeaders) {
|
|
481
|
+
canonicalHeaders += '\n';
|
|
482
|
+
signedHeaders += ';';
|
|
483
|
+
}
|
|
484
|
+
canonicalHeaders += `${lowerKey}:${String(value).trim()}`;
|
|
485
|
+
signedHeaders += lowerKey;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${UNSIGNED_PAYLOAD}`;
|
|
489
|
+
const stringToSign = `${AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
|
|
463
490
|
if (shortDatetime !== this.signingKeyDate) {
|
|
464
491
|
this.signingKeyDate = shortDatetime;
|
|
465
|
-
this.signingKey = this._getSignatureKey(shortDatetime);
|
|
492
|
+
this.signingKey = await this._getSignatureKey(shortDatetime);
|
|
466
493
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
return
|
|
471
|
-
`${AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}`,
|
|
472
|
-
`SignedHeaders=${signedHeaders}`,
|
|
473
|
-
`Signature=${signature}`,
|
|
474
|
-
].join(', ');
|
|
494
|
+
const signature = hexFromBuffer(await hmac(this.signingKey, stringToSign));
|
|
495
|
+
headers[HEADER_AUTHORIZATION] =
|
|
496
|
+
`${AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
497
|
+
return { url: url.toString(), headers };
|
|
475
498
|
}
|
|
476
499
|
async _signedRequest(method, // 'GET' | 'HEAD' | 'PUT' | 'POST' | 'DELETE'
|
|
477
500
|
key, // ‘’ allowed for bucket‑level ops
|
|
478
501
|
{ query = {}, // ?query=string
|
|
479
|
-
body = '', //
|
|
502
|
+
body = '', // BodyInit | undefined
|
|
480
503
|
headers = {}, // extra/override headers
|
|
481
504
|
tolerated = [], // [200, 404] etc.
|
|
482
505
|
withQuery = false, // append query string to signed URL
|
|
483
506
|
} = {}) {
|
|
484
507
|
// Basic validation
|
|
485
|
-
if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
if (key) {
|
|
489
|
-
this._checkKey(key); // allow '' for bucket‑level
|
|
490
|
-
}
|
|
508
|
+
// if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
|
|
509
|
+
// throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
|
|
510
|
+
// }
|
|
491
511
|
const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
|
|
492
512
|
? this._filterIfHeaders(query)
|
|
493
513
|
: { filteredOpts: query, conditionalHeaders: {} };
|
|
@@ -498,7 +518,7 @@ class s3mini {
|
|
|
498
518
|
...conditionalHeaders,
|
|
499
519
|
};
|
|
500
520
|
const encodedKey = key ? uriResourceEscape(key) : '';
|
|
501
|
-
const { url, headers: signedHeaders } = this._sign(method, encodedKey, filteredOpts, baseHeaders);
|
|
521
|
+
const { url, headers: signedHeaders } = await this._sign(method, encodedKey, filteredOpts, baseHeaders);
|
|
502
522
|
if (Object.keys(query).length > 0) {
|
|
503
523
|
withQuery = true; // append query string to signed URL
|
|
504
524
|
}
|
|
@@ -507,53 +527,6 @@ class s3mini {
|
|
|
507
527
|
const signedHeadersString = Object.fromEntries(Object.entries(signedHeaders).map(([k, v]) => [k, String(v)]));
|
|
508
528
|
return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
|
|
509
529
|
}
|
|
510
|
-
/**
|
|
511
|
-
* Gets the current configuration properties of the S3 instance.
|
|
512
|
-
* @returns {IT.S3Config} The current S3 configuration object containing all settings.
|
|
513
|
-
* @example
|
|
514
|
-
* const config = s3.getProps();
|
|
515
|
-
* console.log(config.endpoint); // 'https://s3.amazonaws.com/my-bucket'
|
|
516
|
-
*/
|
|
517
|
-
getProps() {
|
|
518
|
-
return {
|
|
519
|
-
accessKeyId: this.accessKeyId,
|
|
520
|
-
secretAccessKey: this.secretAccessKey,
|
|
521
|
-
endpoint: this.endpoint,
|
|
522
|
-
region: this.region,
|
|
523
|
-
requestSizeInBytes: this.requestSizeInBytes,
|
|
524
|
-
requestAbortTimeout: this.requestAbortTimeout,
|
|
525
|
-
logger: this.logger,
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
/**
|
|
529
|
-
* Updates the configuration properties of the S3 instance.
|
|
530
|
-
* @param {IT.S3Config} props - The new configuration object.
|
|
531
|
-
* @param {string} props.accessKeyId - The access key ID for authentication.
|
|
532
|
-
* @param {string} props.secretAccessKey - The secret access key for authentication.
|
|
533
|
-
* @param {string} props.endpoint - The endpoint URL of the S3-compatible service.
|
|
534
|
-
* @param {string} [props.region='auto'] - The region of the S3 service.
|
|
535
|
-
* @param {number} [props.requestSizeInBytes=8388608] - The request size of a single request in bytes.
|
|
536
|
-
* @param {number} [props.requestAbortTimeout] - The timeout in milliseconds after which a request should be aborted.
|
|
537
|
-
* @param {IT.Logger} [props.logger] - A logger object with methods like info, warn, error.
|
|
538
|
-
* @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
|
|
539
|
-
* @example
|
|
540
|
-
* s3.setProps({
|
|
541
|
-
* accessKeyId: 'new-access-key',
|
|
542
|
-
* secretAccessKey: 'new-secret-key',
|
|
543
|
-
* endpoint: 'https://new-endpoint.com/my-bucket',
|
|
544
|
-
* region: 'us-west-2' // by default is auto
|
|
545
|
-
* });
|
|
546
|
-
*/
|
|
547
|
-
setProps(props) {
|
|
548
|
-
this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
|
|
549
|
-
this.accessKeyId = props.accessKeyId;
|
|
550
|
-
this.secretAccessKey = props.secretAccessKey;
|
|
551
|
-
this.region = props.region || 'auto';
|
|
552
|
-
this.endpoint = props.endpoint;
|
|
553
|
-
this.requestSizeInBytes = props.requestSizeInBytes || DEFAULT_REQUEST_SIZE_IN_BYTES;
|
|
554
|
-
this.requestAbortTimeout = props.requestAbortTimeout;
|
|
555
|
-
this.logger = props.logger;
|
|
556
|
-
}
|
|
557
530
|
/**
|
|
558
531
|
* Sanitizes an ETag value by removing surrounding quotes and whitespace.
|
|
559
532
|
* Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
|
|
@@ -578,7 +551,7 @@ class s3mini {
|
|
|
578
551
|
`;
|
|
579
552
|
const headers = {
|
|
580
553
|
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
581
|
-
[HEADER_CONTENT_LENGTH]:
|
|
554
|
+
[HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
582
555
|
};
|
|
583
556
|
const res = await this._signedRequest('PUT', '', {
|
|
584
557
|
body: xmlBody,
|
|
@@ -587,6 +560,35 @@ class s3mini {
|
|
|
587
560
|
});
|
|
588
561
|
return res.status === 200;
|
|
589
562
|
}
|
|
563
|
+
_extractBucketName() {
|
|
564
|
+
const url = this.endpoint;
|
|
565
|
+
// First check if bucket is in the pathname (path-style URLs)
|
|
566
|
+
const pathSegments = url.pathname.split('/').filter(p => p);
|
|
567
|
+
if (pathSegments.length > 0) {
|
|
568
|
+
if (typeof pathSegments[0] === 'string') {
|
|
569
|
+
return pathSegments[0];
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Otherwise extract from subdomain (virtual-hosted-style URLs)
|
|
573
|
+
const hostParts = url.hostname.split('.');
|
|
574
|
+
// Common patterns:
|
|
575
|
+
// bucket-name.s3.amazonaws.com
|
|
576
|
+
// bucket-name.s3.region.amazonaws.com
|
|
577
|
+
// bucket-name.region.digitaloceanspaces.com
|
|
578
|
+
// bucket-name.region.cdn.digitaloceanspaces.com
|
|
579
|
+
if (hostParts.length >= 3) {
|
|
580
|
+
// Check if it's a known S3-compatible service
|
|
581
|
+
const domain = hostParts.slice(-2).join('.');
|
|
582
|
+
const knownDomains = ['amazonaws.com', 'digitaloceanspaces.com', 'cloudflare.com'];
|
|
583
|
+
if (knownDomains.some(d => domain.includes(d))) {
|
|
584
|
+
if (typeof hostParts[0] === 'string') {
|
|
585
|
+
return hostParts[0];
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Fallback: use the first subdomain
|
|
590
|
+
return hostParts[0] || '';
|
|
591
|
+
}
|
|
590
592
|
/**
|
|
591
593
|
* Checks if a bucket exists.
|
|
592
594
|
* This method sends a request to check if the specified bucket exists in the S3-compatible service.
|
|
@@ -603,7 +605,7 @@ class s3mini {
|
|
|
603
605
|
* @param {string} [prefix=''] - The prefix to filter objects by.
|
|
604
606
|
* @param {number} [maxKeys] - The maximum number of keys to return. If not provided, all keys will be returned.
|
|
605
607
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
606
|
-
* @returns {Promise<
|
|
608
|
+
* @returns {Promise<IT.ListObject[] | null>} A promise that resolves to an array of objects or null if the bucket is empty.
|
|
607
609
|
* @example
|
|
608
610
|
* // List all objects
|
|
609
611
|
* const objects = await s3.listObjects();
|
|
@@ -611,9 +613,7 @@ class s3mini {
|
|
|
611
613
|
* // List objects with prefix
|
|
612
614
|
* const photos = await s3.listObjects('/', 'photos/', 100);
|
|
613
615
|
*/
|
|
614
|
-
async listObjects(delimiter = '/', prefix = '', maxKeys,
|
|
615
|
-
// method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
|
|
616
|
-
opts = {}) {
|
|
616
|
+
async listObjects(delimiter = '/', prefix = '', maxKeys, opts = {}) {
|
|
617
617
|
this._checkDelimiter(delimiter);
|
|
618
618
|
this._checkPrefix(prefix);
|
|
619
619
|
this._checkOpts(opts);
|
|
@@ -623,51 +623,80 @@ class s3mini {
|
|
|
623
623
|
let token;
|
|
624
624
|
const all = [];
|
|
625
625
|
do {
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
'max-keys': String(batchSize),
|
|
630
|
-
...(prefix ? { prefix } : {}),
|
|
631
|
-
...(token ? { 'continuation-token': token } : {}),
|
|
632
|
-
...opts,
|
|
633
|
-
};
|
|
634
|
-
const res = await this._signedRequest('GET', keyPath, {
|
|
635
|
-
query,
|
|
636
|
-
withQuery: true,
|
|
637
|
-
tolerated: [200, 404],
|
|
638
|
-
});
|
|
639
|
-
if (res.status === 404) {
|
|
640
|
-
return null;
|
|
641
|
-
}
|
|
642
|
-
if (res.status !== 200) {
|
|
643
|
-
const errorBody = await res.text();
|
|
644
|
-
const errorCode = res.headers.get('x-amz-error-code') || 'Unknown';
|
|
645
|
-
const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
|
|
646
|
-
this._log('error', `${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
|
|
647
|
-
throw new Error(`${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
|
|
626
|
+
const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
|
|
627
|
+
if (batchResult === null) {
|
|
628
|
+
return null; // 404 - bucket not found
|
|
648
629
|
}
|
|
649
|
-
|
|
650
|
-
if (
|
|
651
|
-
|
|
652
|
-
throw new Error(`${ERROR_PREFIX}Unexpected listObjects response shape`);
|
|
630
|
+
all.push(...batchResult.objects);
|
|
631
|
+
if (!unlimited) {
|
|
632
|
+
remaining -= batchResult.objects.length;
|
|
653
633
|
}
|
|
654
|
-
|
|
655
|
-
/* accumulate Contents */
|
|
656
|
-
const contents = out.Contents || out.contents; // S3 v2 vs v1
|
|
657
|
-
if (contents) {
|
|
658
|
-
const batch = Array.isArray(contents) ? contents : [contents];
|
|
659
|
-
all.push(...batch);
|
|
660
|
-
if (!unlimited) {
|
|
661
|
-
remaining -= batch.length;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
const truncated = out.IsTruncated === 'true' || out.isTruncated === 'true' || false;
|
|
665
|
-
token = truncated
|
|
666
|
-
? (out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker)
|
|
667
|
-
: undefined;
|
|
634
|
+
token = batchResult.continuationToken;
|
|
668
635
|
} while (token && remaining > 0);
|
|
669
636
|
return all;
|
|
670
637
|
}
|
|
638
|
+
async _fetchObjectBatch(keyPath, prefix, remaining, token, opts) {
|
|
639
|
+
const query = this._buildListObjectsQuery(prefix, remaining, token, opts);
|
|
640
|
+
const res = await this._signedRequest('GET', keyPath, {
|
|
641
|
+
query,
|
|
642
|
+
withQuery: true,
|
|
643
|
+
tolerated: [200, 404],
|
|
644
|
+
});
|
|
645
|
+
if (res.status === 404) {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
if (res.status !== 200) {
|
|
649
|
+
await this._handleListObjectsError(res);
|
|
650
|
+
}
|
|
651
|
+
const xmlText = await res.text();
|
|
652
|
+
return this._parseListObjectsResponse(xmlText);
|
|
653
|
+
}
|
|
654
|
+
_buildListObjectsQuery(prefix, remaining, token, opts) {
|
|
655
|
+
const batchSize = Math.min(remaining, 1000); // S3 ceiling
|
|
656
|
+
return {
|
|
657
|
+
'list-type': LIST_TYPE, // =2 for V2
|
|
658
|
+
'max-keys': String(batchSize),
|
|
659
|
+
...(prefix ? { prefix } : {}),
|
|
660
|
+
...(token ? { 'continuation-token': token } : {}),
|
|
661
|
+
...opts,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
async _handleListObjectsError(res) {
|
|
665
|
+
const errorBody = await res.text();
|
|
666
|
+
const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
|
|
667
|
+
const errorCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
|
|
668
|
+
const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;
|
|
669
|
+
this._log('error', `${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
|
|
670
|
+
throw new Error(`${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
|
|
671
|
+
}
|
|
672
|
+
_parseListObjectsResponse(xmlText) {
|
|
673
|
+
const raw = parseXml(xmlText);
|
|
674
|
+
if (typeof raw !== 'object' || !raw || 'error' in raw) {
|
|
675
|
+
this._log('error', `${ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
|
|
676
|
+
throw new Error(`${ERROR_PREFIX}Unexpected listObjects response shape`);
|
|
677
|
+
}
|
|
678
|
+
const out = (raw.ListBucketResult || raw.listBucketResult || raw);
|
|
679
|
+
const objects = this._extractObjectsFromResponse(out);
|
|
680
|
+
const continuationToken = this._extractContinuationToken(out);
|
|
681
|
+
return { objects, continuationToken };
|
|
682
|
+
}
|
|
683
|
+
_extractObjectsFromResponse(response) {
|
|
684
|
+
const contents = response.Contents || response.contents; // S3 v2 vs v1
|
|
685
|
+
if (!contents) {
|
|
686
|
+
return [];
|
|
687
|
+
}
|
|
688
|
+
return Array.isArray(contents) ? contents : [contents];
|
|
689
|
+
}
|
|
690
|
+
_extractContinuationToken(response) {
|
|
691
|
+
const truncated = response.IsTruncated === 'true' || response.isTruncated === 'true' || false;
|
|
692
|
+
if (!truncated) {
|
|
693
|
+
return undefined;
|
|
694
|
+
}
|
|
695
|
+
return (response.NextContinuationToken ||
|
|
696
|
+
response.nextContinuationToken ||
|
|
697
|
+
response.NextMarker ||
|
|
698
|
+
response.nextMarker);
|
|
699
|
+
}
|
|
671
700
|
/**
|
|
672
701
|
* Lists multipart uploads in the bucket.
|
|
673
702
|
* This method sends a request to list multipart uploads in the specified bucket.
|
|
@@ -709,11 +738,17 @@ class s3mini {
|
|
|
709
738
|
* Get an object from the S3-compatible service.
|
|
710
739
|
* This method sends a request to retrieve the specified object from the S3-compatible service.
|
|
711
740
|
* @param {string} key - The key of the object to retrieve.
|
|
712
|
-
* @param {Record<string, unknown>} [opts
|
|
741
|
+
* @param {Record<string, unknown>} [opts] - Additional options for the request.
|
|
742
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
713
743
|
* @returns A promise that resolves to the object data (string) or null if not found.
|
|
714
744
|
*/
|
|
715
|
-
async getObject(key, opts = {}) {
|
|
716
|
-
|
|
745
|
+
async getObject(key, opts = {}, ssecHeaders) {
|
|
746
|
+
// if ssecHeaders is set, add it to headers
|
|
747
|
+
const res = await this._signedRequest('GET', key, {
|
|
748
|
+
query: opts, // use opts.query if it exists, otherwise use an empty object
|
|
749
|
+
tolerated: [200, 404, 412, 304],
|
|
750
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
751
|
+
});
|
|
717
752
|
if ([404, 412, 304].includes(res.status)) {
|
|
718
753
|
return null;
|
|
719
754
|
}
|
|
@@ -724,10 +759,15 @@ class s3mini {
|
|
|
724
759
|
* This method sends a request to retrieve the specified object and returns the full response.
|
|
725
760
|
* @param {string} key - The key of the object to retrieve.
|
|
726
761
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
762
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
727
763
|
* @returns A promise that resolves to the Response object or null if not found.
|
|
728
764
|
*/
|
|
729
|
-
async getObjectResponse(key, opts = {}) {
|
|
730
|
-
const res = await this._signedRequest('GET', key, {
|
|
765
|
+
async getObjectResponse(key, opts = {}, ssecHeaders) {
|
|
766
|
+
const res = await this._signedRequest('GET', key, {
|
|
767
|
+
query: opts,
|
|
768
|
+
tolerated: [200, 404, 412, 304],
|
|
769
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
770
|
+
});
|
|
731
771
|
if ([404, 412, 304].includes(res.status)) {
|
|
732
772
|
return null;
|
|
733
773
|
}
|
|
@@ -738,10 +778,15 @@ class s3mini {
|
|
|
738
778
|
* This method sends a request to retrieve the specified object and returns it as an ArrayBuffer.
|
|
739
779
|
* @param {string} key - The key of the object to retrieve.
|
|
740
780
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
781
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
741
782
|
* @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
|
|
742
783
|
*/
|
|
743
|
-
async getObjectArrayBuffer(key, opts = {}) {
|
|
744
|
-
const res = await this._signedRequest('GET', key, {
|
|
784
|
+
async getObjectArrayBuffer(key, opts = {}, ssecHeaders) {
|
|
785
|
+
const res = await this._signedRequest('GET', key, {
|
|
786
|
+
query: opts,
|
|
787
|
+
tolerated: [200, 404, 412, 304],
|
|
788
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
789
|
+
});
|
|
745
790
|
if ([404, 412, 304].includes(res.status)) {
|
|
746
791
|
return null;
|
|
747
792
|
}
|
|
@@ -752,10 +797,15 @@ class s3mini {
|
|
|
752
797
|
* This method sends a request to retrieve the specified object and returns it as JSON.
|
|
753
798
|
* @param {string} key - The key of the object to retrieve.
|
|
754
799
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
800
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
755
801
|
* @returns A promise that resolves to the object data as JSON or null if not found.
|
|
756
802
|
*/
|
|
757
|
-
async getObjectJSON(key, opts = {}) {
|
|
758
|
-
const res = await this._signedRequest('GET', key, {
|
|
803
|
+
async getObjectJSON(key, opts = {}, ssecHeaders) {
|
|
804
|
+
const res = await this._signedRequest('GET', key, {
|
|
805
|
+
query: opts,
|
|
806
|
+
tolerated: [200, 404, 412, 304],
|
|
807
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
808
|
+
});
|
|
759
809
|
if ([404, 412, 304].includes(res.status)) {
|
|
760
810
|
return null;
|
|
761
811
|
}
|
|
@@ -766,11 +816,16 @@ class s3mini {
|
|
|
766
816
|
* This method sends a request to retrieve the specified object and its ETag.
|
|
767
817
|
* @param {string} key - The key of the object to retrieve.
|
|
768
818
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
819
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
769
820
|
* @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
|
|
770
821
|
*/
|
|
771
|
-
async getObjectWithETag(key, opts = {}) {
|
|
822
|
+
async getObjectWithETag(key, opts = {}, ssecHeaders) {
|
|
772
823
|
try {
|
|
773
|
-
const res = await this._signedRequest('GET', key, {
|
|
824
|
+
const res = await this._signedRequest('GET', key, {
|
|
825
|
+
query: opts,
|
|
826
|
+
tolerated: [200, 404, 412, 304],
|
|
827
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
828
|
+
});
|
|
774
829
|
if ([404, 412, 304].includes(res.status)) {
|
|
775
830
|
return { etag: null, data: null };
|
|
776
831
|
}
|
|
@@ -793,13 +848,14 @@ class s3mini {
|
|
|
793
848
|
* @param {number} [rangeFrom=0] - The starting byte for the range (if not whole file).
|
|
794
849
|
* @param {number} [rangeTo=this.requestSizeInBytes] - The ending byte for the range (if not whole file).
|
|
795
850
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
851
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
796
852
|
* @returns A promise that resolves to the Response object.
|
|
797
853
|
*/
|
|
798
|
-
async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts = {}) {
|
|
854
|
+
async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts = {}, ssecHeaders) {
|
|
799
855
|
const rangeHdr = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
|
|
800
856
|
return this._signedRequest('GET', key, {
|
|
801
857
|
query: { ...opts },
|
|
802
|
-
headers: rangeHdr,
|
|
858
|
+
headers: { ...rangeHdr, ...ssecHeaders },
|
|
803
859
|
withQuery: true, // keep ?query=string behaviour
|
|
804
860
|
});
|
|
805
861
|
}
|
|
@@ -810,9 +866,11 @@ class s3mini {
|
|
|
810
866
|
* @returns A promise that resolves to the content length of the object in bytes, or 0 if not found.
|
|
811
867
|
* @throws {Error} If the content length header is not found in the response.
|
|
812
868
|
*/
|
|
813
|
-
async getContentLength(key) {
|
|
869
|
+
async getContentLength(key, ssecHeaders) {
|
|
814
870
|
try {
|
|
815
|
-
const res = await this._signedRequest('HEAD', key
|
|
871
|
+
const res = await this._signedRequest('HEAD', key, {
|
|
872
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
873
|
+
});
|
|
816
874
|
const len = res.headers.get(HEADER_CONTENT_LENGTH);
|
|
817
875
|
return len ? +len : 0;
|
|
818
876
|
}
|
|
@@ -845,6 +903,7 @@ class s3mini {
|
|
|
845
903
|
* Retrieves the ETag of an object without downloading its content.
|
|
846
904
|
* @param {string} key - The key of the object to retrieve the ETag for.
|
|
847
905
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
906
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
848
907
|
* @returns {Promise<string | null>} A promise that resolves to the ETag value or null if the object is not found.
|
|
849
908
|
* @throws {Error} If the ETag header is not found in the response.
|
|
850
909
|
* @example
|
|
@@ -853,14 +912,18 @@ class s3mini {
|
|
|
853
912
|
* console.log(`File ETag: ${etag}`);
|
|
854
913
|
* }
|
|
855
914
|
*/
|
|
856
|
-
async getEtag(key, opts = {}) {
|
|
915
|
+
async getEtag(key, opts = {}, ssecHeaders) {
|
|
857
916
|
const res = await this._signedRequest('HEAD', key, {
|
|
858
917
|
query: opts,
|
|
859
|
-
tolerated: [200, 404],
|
|
918
|
+
tolerated: [200, 304, 404, 412],
|
|
919
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
860
920
|
});
|
|
861
921
|
if (res.status === 404) {
|
|
862
922
|
return null;
|
|
863
923
|
}
|
|
924
|
+
if (res.status === 412 || res.status === 304) {
|
|
925
|
+
return null; // ETag mismatch
|
|
926
|
+
}
|
|
864
927
|
const etag = res.headers.get(HEADER_ETAG);
|
|
865
928
|
if (!etag) {
|
|
866
929
|
throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
|
|
@@ -872,6 +935,8 @@ class s3mini {
|
|
|
872
935
|
* @param {string} key - The key/path where the object will be stored.
|
|
873
936
|
* @param {string | Buffer} data - The data to upload (string or Buffer).
|
|
874
937
|
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
938
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
939
|
+
* @param {IT.AWSHeaders} [additionalHeaders] - Additional x-amz-* headers specific to this request, if any.
|
|
875
940
|
* @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
|
|
876
941
|
* @throws {TypeError} If data is not a string or Buffer.
|
|
877
942
|
* @example
|
|
@@ -882,15 +947,14 @@ class s3mini {
|
|
|
882
947
|
* const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
883
948
|
* await s3.putObject('image.png', buffer, 'image/png');
|
|
884
949
|
*/
|
|
885
|
-
async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
|
|
886
|
-
if (!(data instanceof Buffer || typeof data === 'string')) {
|
|
887
|
-
throw new TypeError(ERROR_DATA_BUFFER_REQUIRED);
|
|
888
|
-
}
|
|
950
|
+
async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders, additionalHeaders) {
|
|
889
951
|
return this._signedRequest('PUT', key, {
|
|
890
|
-
body: data,
|
|
952
|
+
body: this._validateData(data),
|
|
891
953
|
headers: {
|
|
892
|
-
[HEADER_CONTENT_LENGTH]:
|
|
954
|
+
[HEADER_CONTENT_LENGTH]: getByteSize(data),
|
|
893
955
|
[HEADER_CONTENT_TYPE]: fileType,
|
|
956
|
+
...additionalHeaders,
|
|
957
|
+
...ssecHeaders,
|
|
894
958
|
},
|
|
895
959
|
tolerated: [200],
|
|
896
960
|
});
|
|
@@ -899,6 +963,7 @@ class s3mini {
|
|
|
899
963
|
* Initiates a multipart upload and returns the upload ID.
|
|
900
964
|
* @param {string} key - The key/path where the object will be stored.
|
|
901
965
|
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
966
|
+
* @param {IT.SSECHeaders?} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
902
967
|
* @returns {Promise<string>} A promise that resolves to the upload ID for the multipart upload.
|
|
903
968
|
* @throws {TypeError} If key is invalid or fileType is not a string.
|
|
904
969
|
* @throws {Error} If the multipart upload fails to initialize.
|
|
@@ -906,28 +971,19 @@ class s3mini {
|
|
|
906
971
|
* const uploadId = await s3.getMultipartUploadId('large-file.zip', 'application/zip');
|
|
907
972
|
* console.log(`Started multipart upload: ${uploadId}`);
|
|
908
973
|
*/
|
|
909
|
-
async getMultipartUploadId(key, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
|
|
974
|
+
async getMultipartUploadId(key, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders) {
|
|
910
975
|
this._checkKey(key);
|
|
911
976
|
if (typeof fileType !== 'string') {
|
|
912
977
|
throw new TypeError(`${ERROR_PREFIX}fileType must be a string`);
|
|
913
978
|
}
|
|
914
979
|
const query = { uploads: '' };
|
|
915
|
-
const headers = { [HEADER_CONTENT_TYPE]: fileType };
|
|
980
|
+
const headers = { [HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders };
|
|
916
981
|
const res = await this._signedRequest('POST', key, {
|
|
917
982
|
query,
|
|
918
983
|
headers,
|
|
919
984
|
withQuery: true,
|
|
920
985
|
});
|
|
921
986
|
const parsed = parseXml(await res.text());
|
|
922
|
-
// if (
|
|
923
|
-
// parsed &&
|
|
924
|
-
// typeof parsed === 'object' &&
|
|
925
|
-
// 'initiateMultipartUploadResult' in parsed &&
|
|
926
|
-
// parsed.initiateMultipartUploadResult &&
|
|
927
|
-
// 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string })
|
|
928
|
-
// ) {
|
|
929
|
-
// return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId;
|
|
930
|
-
// }
|
|
931
987
|
if (parsed && typeof parsed === 'object') {
|
|
932
988
|
// Check for both cases of InitiateMultipartUploadResult
|
|
933
989
|
const uploadResult = parsed.initiateMultipartUploadResult ||
|
|
@@ -949,6 +1005,7 @@ class s3mini {
|
|
|
949
1005
|
* @param {Buffer | string} data - The data for this part.
|
|
950
1006
|
* @param {number} partNumber - The part number (must be between 1 and 10,000).
|
|
951
1007
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
1008
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
952
1009
|
* @returns {Promise<IT.UploadPart>} A promise that resolves to an object containing the partNumber and etag.
|
|
953
1010
|
* @throws {TypeError} If any parameter is invalid.
|
|
954
1011
|
* @example
|
|
@@ -960,13 +1017,16 @@ class s3mini {
|
|
|
960
1017
|
* );
|
|
961
1018
|
* console.log(`Part ${part.partNumber} uploaded with ETag: ${part.etag}`);
|
|
962
1019
|
*/
|
|
963
|
-
async uploadPart(key, uploadId, data, partNumber, opts = {}) {
|
|
964
|
-
this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
|
|
1020
|
+
async uploadPart(key, uploadId, data, partNumber, opts = {}, ssecHeaders) {
|
|
1021
|
+
const body = this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
|
|
965
1022
|
const query = { uploadId, partNumber, ...opts };
|
|
966
1023
|
const res = await this._signedRequest('PUT', key, {
|
|
967
1024
|
query,
|
|
968
|
-
body
|
|
969
|
-
headers: {
|
|
1025
|
+
body,
|
|
1026
|
+
headers: {
|
|
1027
|
+
[HEADER_CONTENT_LENGTH]: getByteSize(data),
|
|
1028
|
+
...ssecHeaders,
|
|
1029
|
+
},
|
|
970
1030
|
});
|
|
971
1031
|
return { partNumber, etag: sanitizeETag(res.headers.get('etag') || '') };
|
|
972
1032
|
}
|
|
@@ -993,7 +1053,7 @@ class s3mini {
|
|
|
993
1053
|
const xmlBody = this._buildCompleteMultipartUploadXml(parts);
|
|
994
1054
|
const headers = {
|
|
995
1055
|
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
996
|
-
[HEADER_CONTENT_LENGTH]:
|
|
1056
|
+
[HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
997
1057
|
};
|
|
998
1058
|
const res = await this._signedRequest('POST', key, {
|
|
999
1059
|
query,
|
|
@@ -1012,7 +1072,7 @@ class s3mini {
|
|
|
1012
1072
|
if (etag && typeof etag === 'string') {
|
|
1013
1073
|
return {
|
|
1014
1074
|
...resultObj,
|
|
1015
|
-
etag:
|
|
1075
|
+
etag: sanitizeETag(etag),
|
|
1016
1076
|
};
|
|
1017
1077
|
}
|
|
1018
1078
|
return result;
|
|
@@ -1024,6 +1084,7 @@ class s3mini {
|
|
|
1024
1084
|
* Aborts a multipart upload and removes all uploaded parts.
|
|
1025
1085
|
* @param {string} key - The key of the object being uploaded.
|
|
1026
1086
|
* @param {string} uploadId - The upload ID to abort.
|
|
1087
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
1027
1088
|
* @returns {Promise<object>} A promise that resolves to an object containing the abort status and details.
|
|
1028
1089
|
* @throws {TypeError} If key or uploadId is invalid.
|
|
1029
1090
|
* @throws {Error} If the abort operation fails.
|
|
@@ -1035,13 +1096,13 @@ class s3mini {
|
|
|
1035
1096
|
* console.error('Failed to abort upload:', error);
|
|
1036
1097
|
* }
|
|
1037
1098
|
*/
|
|
1038
|
-
async abortMultipartUpload(key, uploadId) {
|
|
1099
|
+
async abortMultipartUpload(key, uploadId, ssecHeaders) {
|
|
1039
1100
|
this._checkKey(key);
|
|
1040
1101
|
if (!uploadId) {
|
|
1041
1102
|
throw new TypeError(ERROR_UPLOAD_ID_REQUIRED);
|
|
1042
1103
|
}
|
|
1043
1104
|
const query = { uploadId };
|
|
1044
|
-
const headers = { [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE };
|
|
1105
|
+
const headers = { [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE, ...(ssecHeaders ? { ...ssecHeaders } : {}) };
|
|
1045
1106
|
const res = await this._signedRequest('DELETE', key, {
|
|
1046
1107
|
query,
|
|
1047
1108
|
headers,
|
|
@@ -1059,18 +1120,193 @@ class s3mini {
|
|
|
1059
1120
|
return { status: 'Aborted', key, uploadId, response: parsed };
|
|
1060
1121
|
}
|
|
1061
1122
|
_buildCompleteMultipartUploadXml(parts) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1123
|
+
let xml = '<CompleteMultipartUpload>';
|
|
1124
|
+
for (const part of parts) {
|
|
1125
|
+
xml += `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`;
|
|
1126
|
+
}
|
|
1127
|
+
xml += '</CompleteMultipartUpload>';
|
|
1128
|
+
return xml;
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Executes the copy operation for local copying (same bucket/endpoint).
|
|
1132
|
+
* @private
|
|
1133
|
+
*/
|
|
1134
|
+
async _executeCopyOperation(destinationKey, copySource, options) {
|
|
1135
|
+
const { metadataDirective = 'COPY', metadata = {}, contentType, storageClass, taggingDirective, websiteRedirectLocation, sourceSSECHeaders = {}, destinationSSECHeaders = {}, additionalHeaders = {}, } = options;
|
|
1136
|
+
const headers = {
|
|
1137
|
+
'x-amz-copy-source': copySource,
|
|
1138
|
+
'x-amz-metadata-directive': metadataDirective,
|
|
1139
|
+
...additionalHeaders,
|
|
1140
|
+
...(contentType && { [HEADER_CONTENT_TYPE]: contentType }),
|
|
1141
|
+
...(storageClass && { 'x-amz-storage-class': storageClass }),
|
|
1142
|
+
...(taggingDirective && { 'x-amz-tagging-directive': taggingDirective }),
|
|
1143
|
+
...(websiteRedirectLocation && { 'x-amz-website-redirect-location': websiteRedirectLocation }),
|
|
1144
|
+
...this._buildSSECHeaders(sourceSSECHeaders, destinationSSECHeaders),
|
|
1145
|
+
...(metadataDirective === 'REPLACE' ? this._buildMetadataHeaders(metadata) : {}),
|
|
1146
|
+
};
|
|
1147
|
+
try {
|
|
1148
|
+
const res = await this._signedRequest('PUT', destinationKey, {
|
|
1149
|
+
headers,
|
|
1150
|
+
tolerated: [200],
|
|
1151
|
+
});
|
|
1152
|
+
return this._parseCopyObjectResponse(await res.text());
|
|
1153
|
+
}
|
|
1154
|
+
catch (err) {
|
|
1155
|
+
this._log('error', `Error in copy operation to ${destinationKey}`, {
|
|
1156
|
+
error: String(err),
|
|
1157
|
+
});
|
|
1158
|
+
throw err;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Copies an object within the same bucket.
|
|
1163
|
+
*
|
|
1164
|
+
* @param {string} sourceKey - The key of the source object to copy
|
|
1165
|
+
* @param {string} destinationKey - The key where the object will be copied to
|
|
1166
|
+
* @param {IT.CopyObjectOptions} [options={}] - Copy operation options
|
|
1167
|
+
* @param {string} [options.metadataDirective='COPY'] - How to handle metadata ('COPY' | 'REPLACE')
|
|
1168
|
+
* @param {Record<string,string>} [options.metadata={}] - New metadata (only used if metadataDirective='REPLACE')
|
|
1169
|
+
* @param {string} [options.contentType] - New content type for the destination object
|
|
1170
|
+
* @param {string} [options.storageClass] - Storage class for the destination object
|
|
1171
|
+
* @param {string} [options.taggingDirective] - How to handle object tags ('COPY' | 'REPLACE')
|
|
1172
|
+
* @param {string} [options.websiteRedirectLocation] - Website redirect location for the destination
|
|
1173
|
+
* @param {IT.SSECHeaders} [options.sourceSSECHeaders={}] - Encryption headers for reading source (if encrypted)
|
|
1174
|
+
* @param {IT.SSECHeaders} [options.destinationSSECHeaders={}] - Encryption headers for destination
|
|
1175
|
+
* @param {IT.AWSHeaders} [options.additionalHeaders={}] - Extra x-amz-* headers
|
|
1176
|
+
*
|
|
1177
|
+
* @returns {Promise<IT.CopyObjectResult>} Copy result with etag and lastModified date
|
|
1178
|
+
* @throws {TypeError} If sourceKey or destinationKey is invalid
|
|
1179
|
+
* @throws {Error} If copy operation fails or S3 returns an error
|
|
1180
|
+
*
|
|
1181
|
+
* @example
|
|
1182
|
+
* // Simple copy
|
|
1183
|
+
* const result = await s3.copyObject('report-2024.pdf', 'archive/report-2024.pdf');
|
|
1184
|
+
* console.log(`Copied with ETag: ${result.etag}`);
|
|
1185
|
+
*
|
|
1186
|
+
* @example
|
|
1187
|
+
* // Copy with new metadata and content type
|
|
1188
|
+
* const result = await s3.copyObject('data.csv', 'processed/data.csv', {
|
|
1189
|
+
* metadataDirective: 'REPLACE',
|
|
1190
|
+
* metadata: {
|
|
1191
|
+
* 'processed-date': new Date().toISOString(),
|
|
1192
|
+
* 'original-name': 'data.csv'
|
|
1193
|
+
* },
|
|
1194
|
+
* contentType: 'text/csv; charset=utf-8'
|
|
1195
|
+
* });
|
|
1196
|
+
*
|
|
1197
|
+
* @example
|
|
1198
|
+
* // Copy encrypted object (Cloudflare R2 SSE-C)
|
|
1199
|
+
* const ssecKey = 'n1TKiTaVHlYLMX9n0zHXyooMr026vOiTEFfT+719Hho=';
|
|
1200
|
+
* await s3.copyObject('sensitive.json', 'backup/sensitive.json', {
|
|
1201
|
+
* sourceSSECHeaders: {
|
|
1202
|
+
* 'x-amz-copy-source-server-side-encryption-customer-algorithm': 'AES256',
|
|
1203
|
+
* 'x-amz-copy-source-server-side-encryption-customer-key': ssecKey,
|
|
1204
|
+
* 'x-amz-copy-source-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
|
|
1205
|
+
* },
|
|
1206
|
+
* destinationSSECHeaders: {
|
|
1207
|
+
* 'x-amz-server-side-encryption-customer-algorithm': 'AES256',
|
|
1208
|
+
* 'x-amz-server-side-encryption-customer-key': ssecKey,
|
|
1209
|
+
* 'x-amz-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
|
|
1210
|
+
* }
|
|
1211
|
+
* });
|
|
1212
|
+
*/
|
|
1213
|
+
copyObject(sourceKey, destinationKey, options = {}) {
|
|
1214
|
+
// Validate parameters
|
|
1215
|
+
this._checkKey(sourceKey);
|
|
1216
|
+
this._checkKey(destinationKey);
|
|
1217
|
+
const copySource = `/${this.bucketName}/${uriEscape(sourceKey)}`;
|
|
1218
|
+
return this._executeCopyOperation(destinationKey, copySource, options);
|
|
1219
|
+
}
|
|
1220
|
+
_buildSSECHeaders(sourceHeaders, destHeaders) {
|
|
1221
|
+
const headers = {};
|
|
1222
|
+
Object.entries({ ...sourceHeaders, ...destHeaders }).forEach(([k, v]) => {
|
|
1223
|
+
if (v !== undefined) {
|
|
1224
|
+
headers[k] = v;
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
return headers;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Moves an object within the same bucket (copy + delete atomic-like operation).
|
|
1231
|
+
*
|
|
1232
|
+
* WARNING: Not truly atomic - if delete fails after successful copy, the object
|
|
1233
|
+
* will exist in both locations. Consider your use case carefully.
|
|
1234
|
+
*
|
|
1235
|
+
* @param {string} sourceKey - The key of the source object to move
|
|
1236
|
+
* @param {string} destinationKey - The key where the object will be moved to
|
|
1237
|
+
* @param {IT.CopyObjectOptions} [options={}] - Options passed to the copy operation
|
|
1238
|
+
*
|
|
1239
|
+
* @returns {Promise<IT.CopyObjectResult>} Result from the copy operation
|
|
1240
|
+
* @throws {TypeError} If sourceKey or destinationKey is invalid
|
|
1241
|
+
* @throws {Error} If copy succeeds but delete fails (includes copy result in error)
|
|
1242
|
+
*
|
|
1243
|
+
* @example
|
|
1244
|
+
* // Simple move
|
|
1245
|
+
* await s3.moveObject('temp/upload.tmp', 'files/document.pdf');
|
|
1246
|
+
*
|
|
1247
|
+
* @example
|
|
1248
|
+
* // Move with metadata update
|
|
1249
|
+
* await s3.moveObject('unprocessed/image.jpg', 'processed/image.jpg', {
|
|
1250
|
+
* metadataDirective: 'REPLACE',
|
|
1251
|
+
* metadata: {
|
|
1252
|
+
* 'status': 'processed',
|
|
1253
|
+
* 'processed-at': Date.now().toString()
|
|
1254
|
+
* },
|
|
1255
|
+
* contentType: 'image/jpeg'
|
|
1256
|
+
* });
|
|
1257
|
+
*
|
|
1258
|
+
* @example
|
|
1259
|
+
* // Safe move with error handling
|
|
1260
|
+
* try {
|
|
1261
|
+
* const result = await s3.moveObject('inbox/file.dat', 'archive/file.dat');
|
|
1262
|
+
* console.log(`Moved successfully: ${result.etag}`);
|
|
1263
|
+
* } catch (error) {
|
|
1264
|
+
* // Check if copy succeeded but delete failed
|
|
1265
|
+
* if (error.message.includes('delete source object after successful copy')) {
|
|
1266
|
+
* console.warn('File copied but not deleted from source - manual cleanup needed');
|
|
1267
|
+
* }
|
|
1268
|
+
* }
|
|
1269
|
+
*/
|
|
1270
|
+
async moveObject(sourceKey, destinationKey, options = {}) {
|
|
1271
|
+
try {
|
|
1272
|
+
// First copy the object
|
|
1273
|
+
const copyResult = await this.copyObject(sourceKey, destinationKey, options);
|
|
1274
|
+
// Then delete the source
|
|
1275
|
+
const deleteSuccess = await this.deleteObject(sourceKey);
|
|
1276
|
+
if (!deleteSuccess) {
|
|
1277
|
+
throw new Error(`${ERROR_PREFIX}Failed to delete source object after successful copy`);
|
|
1278
|
+
}
|
|
1279
|
+
return copyResult;
|
|
1280
|
+
}
|
|
1281
|
+
catch (err) {
|
|
1282
|
+
this._log('error', `Error moving object from ${sourceKey} to ${destinationKey}`, {
|
|
1283
|
+
error: String(err),
|
|
1284
|
+
});
|
|
1285
|
+
throw err;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
_buildMetadataHeaders(metadata) {
|
|
1289
|
+
const headers = {};
|
|
1290
|
+
Object.entries(metadata).forEach(([k, v]) => {
|
|
1291
|
+
headers[k.startsWith('x-amz-meta-') ? k : `x-amz-meta-${k}`] = v;
|
|
1292
|
+
});
|
|
1293
|
+
return headers;
|
|
1294
|
+
}
|
|
1295
|
+
_parseCopyObjectResponse(xmlText) {
|
|
1296
|
+
const parsed = parseXml(xmlText);
|
|
1297
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
1298
|
+
throw new Error(`${ERROR_PREFIX}Unexpected copyObject response format`);
|
|
1299
|
+
}
|
|
1300
|
+
const result = (parsed.CopyObjectResult || parsed.copyObjectResult || parsed);
|
|
1301
|
+
const etag = result.ETag || result.eTag || result.etag;
|
|
1302
|
+
const lastModified = result.LastModified || result.lastModified;
|
|
1303
|
+
if (!etag || typeof etag !== 'string') {
|
|
1304
|
+
throw new Error(`${ERROR_PREFIX}ETag not found in copyObject response`);
|
|
1305
|
+
}
|
|
1306
|
+
return {
|
|
1307
|
+
etag: sanitizeETag(etag),
|
|
1308
|
+
lastModified: lastModified ? new Date(lastModified) : undefined,
|
|
1309
|
+
};
|
|
1074
1310
|
}
|
|
1075
1311
|
/**
|
|
1076
1312
|
* Deletes an object from the bucket.
|
|
@@ -1083,13 +1319,14 @@ class s3mini {
|
|
|
1083
1319
|
return res.status === 200 || res.status === 204;
|
|
1084
1320
|
}
|
|
1085
1321
|
async _deleteObjectsProcess(keys) {
|
|
1086
|
-
const
|
|
1322
|
+
const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
|
|
1323
|
+
const xmlBody = '<Delete>' + objectsXml + '</Delete>';
|
|
1087
1324
|
const query = { delete: '' };
|
|
1088
|
-
const
|
|
1325
|
+
const sha256base64 = base64FromBuffer(await sha256(xmlBody));
|
|
1089
1326
|
const headers = {
|
|
1090
1327
|
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
1091
|
-
[HEADER_CONTENT_LENGTH]:
|
|
1092
|
-
|
|
1328
|
+
[HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1329
|
+
[HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1093
1330
|
};
|
|
1094
1331
|
const res = await this._signedRequest('POST', '', {
|
|
1095
1332
|
query,
|
|
@@ -1177,9 +1414,10 @@ class s3mini {
|
|
|
1177
1414
|
signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
|
|
1178
1415
|
});
|
|
1179
1416
|
this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
|
|
1180
|
-
if (
|
|
1181
|
-
|
|
1417
|
+
if (res.ok || toleratedStatusCodes.includes(res.status)) {
|
|
1418
|
+
return res;
|
|
1182
1419
|
}
|
|
1420
|
+
await this._handleErrorResponse(res);
|
|
1183
1421
|
return res;
|
|
1184
1422
|
}
|
|
1185
1423
|
catch (err) {
|
|
@@ -1190,10 +1428,29 @@ class s3mini {
|
|
|
1190
1428
|
throw err;
|
|
1191
1429
|
}
|
|
1192
1430
|
}
|
|
1431
|
+
_parseErrorXml(headers, body) {
|
|
1432
|
+
if (headers.get('content-type') !== 'application/xml') {
|
|
1433
|
+
return {};
|
|
1434
|
+
}
|
|
1435
|
+
const parsedBody = parseXml(body);
|
|
1436
|
+
if (!parsedBody ||
|
|
1437
|
+
typeof parsedBody !== 'object' ||
|
|
1438
|
+
!('Error' in parsedBody) ||
|
|
1439
|
+
!parsedBody.Error ||
|
|
1440
|
+
typeof parsedBody.Error !== 'object') {
|
|
1441
|
+
return {};
|
|
1442
|
+
}
|
|
1443
|
+
const error = parsedBody.Error;
|
|
1444
|
+
return {
|
|
1445
|
+
svcCode: 'Code' in error && typeof error.Code === 'string' ? error.Code : undefined,
|
|
1446
|
+
errorMessage: 'Message' in error && typeof error.Message === 'string' ? error.Message : undefined,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1193
1449
|
async _handleErrorResponse(res) {
|
|
1194
1450
|
const errorBody = await res.text();
|
|
1195
|
-
const
|
|
1196
|
-
const
|
|
1451
|
+
const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
|
|
1452
|
+
const svcCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
|
|
1453
|
+
const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;
|
|
1197
1454
|
this._log('error', `${ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`);
|
|
1198
1455
|
throw new S3ServiceError(`S3 returned ${res.status} – ${svcCode}`, res.status, svcCode, errorBody);
|
|
1199
1456
|
}
|
|
@@ -1203,16 +1460,16 @@ class s3mini {
|
|
|
1203
1460
|
}
|
|
1204
1461
|
return Object.keys(queryParams)
|
|
1205
1462
|
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
|
|
1206
|
-
.sort()
|
|
1463
|
+
.sort((a, b) => a.localeCompare(b))
|
|
1207
1464
|
.join('&');
|
|
1208
1465
|
}
|
|
1209
|
-
_getSignatureKey(dateStamp) {
|
|
1210
|
-
const kDate = hmac(`AWS4${this.secretAccessKey}`, dateStamp);
|
|
1211
|
-
const kRegion = hmac(kDate, this.region);
|
|
1212
|
-
const kService = hmac(kRegion, S3_SERVICE);
|
|
1213
|
-
return hmac(kService, AWS_REQUEST_TYPE);
|
|
1466
|
+
async _getSignatureKey(dateStamp) {
|
|
1467
|
+
const kDate = await hmac(`AWS4${this.secretAccessKey}`, dateStamp);
|
|
1468
|
+
const kRegion = await hmac(kDate, this.region);
|
|
1469
|
+
const kService = await hmac(kRegion, S3_SERVICE);
|
|
1470
|
+
return await hmac(kService, AWS_REQUEST_TYPE);
|
|
1214
1471
|
}
|
|
1215
1472
|
}
|
|
1216
1473
|
|
|
1217
|
-
export {
|
|
1474
|
+
export { S3mini, S3mini as default, runInBatches, sanitizeETag };
|
|
1218
1475
|
//# sourceMappingURL=s3mini.js.map
|