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/src/S3.ts
CHANGED
|
@@ -14,8 +14,8 @@ import * as U from './utils.js';
|
|
|
14
14
|
* const s3 = new CoreS3({
|
|
15
15
|
* accessKeyId: 'your-access-key',
|
|
16
16
|
* secretAccessKey: 'your-secret-key',
|
|
17
|
-
* endpoint: 'https://your-s3-endpoint.com',
|
|
18
|
-
* region: '
|
|
17
|
+
* endpoint: 'https://your-s3-endpoint.com/bucket-name',
|
|
18
|
+
* region: 'auto' // by default is auto
|
|
19
19
|
* });
|
|
20
20
|
*
|
|
21
21
|
* // Upload a file
|
|
@@ -27,7 +27,7 @@ import * as U from './utils.js';
|
|
|
27
27
|
* // Delete a file
|
|
28
28
|
* await s3.deleteObject('example.txt');
|
|
29
29
|
*/
|
|
30
|
-
class
|
|
30
|
+
class S3mini {
|
|
31
31
|
/**
|
|
32
32
|
* Creates an instance of the S3 class.
|
|
33
33
|
*
|
|
@@ -42,15 +42,16 @@ class s3mini {
|
|
|
42
42
|
* @param {Object} [config.logger=null] - A logger object with methods like info, warn, error.
|
|
43
43
|
* @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
|
|
44
44
|
*/
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
readonly accessKeyId: string;
|
|
46
|
+
readonly secretAccessKey: string;
|
|
47
|
+
readonly endpoint: URL;
|
|
48
|
+
readonly region: string;
|
|
49
|
+
readonly bucketName: string;
|
|
50
|
+
readonly requestSizeInBytes: number;
|
|
51
|
+
readonly requestAbortTimeout?: number;
|
|
52
|
+
readonly logger?: IT.Logger;
|
|
52
53
|
private signingKeyDate?: string;
|
|
53
|
-
private signingKey?:
|
|
54
|
+
private signingKey?: ArrayBuffer;
|
|
54
55
|
|
|
55
56
|
constructor({
|
|
56
57
|
accessKeyId,
|
|
@@ -64,8 +65,9 @@ class s3mini {
|
|
|
64
65
|
this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
|
|
65
66
|
this.accessKeyId = accessKeyId;
|
|
66
67
|
this.secretAccessKey = secretAccessKey;
|
|
67
|
-
this.endpoint = this._ensureValidUrl(endpoint);
|
|
68
|
+
this.endpoint = new URL(this._ensureValidUrl(endpoint));
|
|
68
69
|
this.region = region;
|
|
70
|
+
this.bucketName = this._extractBucketName();
|
|
69
71
|
this.requestSizeInBytes = requestSizeInBytes;
|
|
70
72
|
this.requestAbortTimeout = requestAbortTimeout;
|
|
71
73
|
this.logger = logger;
|
|
@@ -112,7 +114,7 @@ class s3mini {
|
|
|
112
114
|
// Include some general context, but sanitize sensitive parts
|
|
113
115
|
context: this._sanitize({
|
|
114
116
|
region: this.region,
|
|
115
|
-
endpoint: this.endpoint,
|
|
117
|
+
endpoint: this.endpoint.toString(),
|
|
116
118
|
// Only include the first few characters of the access key, if it exists
|
|
117
119
|
accessKeyId: this.accessKeyId ? `${this.accessKeyId.substring(0, 4)}...` : undefined,
|
|
118
120
|
}),
|
|
@@ -215,18 +217,22 @@ class s3mini {
|
|
|
215
217
|
return { filteredOpts, conditionalHeaders };
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
private _validateData(data: unknown): BodyInit {
|
|
221
|
+
if (!((globalThis.Buffer && data instanceof globalThis.Buffer) || typeof data === 'string')) {
|
|
222
|
+
this._log('error', C.ERROR_DATA_BUFFER_REQUIRED);
|
|
223
|
+
throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
|
|
224
|
+
}
|
|
225
|
+
return data;
|
|
226
|
+
}
|
|
227
|
+
|
|
218
228
|
private _validateUploadPartParams(
|
|
219
229
|
key: string,
|
|
220
230
|
uploadId: string,
|
|
221
|
-
data:
|
|
231
|
+
data: IT.MaybeBuffer | string,
|
|
222
232
|
partNumber: number,
|
|
223
233
|
opts: object,
|
|
224
|
-
):
|
|
234
|
+
): BodyInit {
|
|
225
235
|
this._checkKey(key);
|
|
226
|
-
if (!(data instanceof Buffer || typeof data === 'string')) {
|
|
227
|
-
this._log('error', C.ERROR_DATA_BUFFER_REQUIRED);
|
|
228
|
-
throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
|
|
229
|
-
}
|
|
230
236
|
if (typeof uploadId !== 'string' || uploadId.trim().length === 0) {
|
|
231
237
|
this._log('error', C.ERROR_UPLOAD_ID_REQUIRED);
|
|
232
238
|
throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED);
|
|
@@ -236,14 +242,15 @@ class s3mini {
|
|
|
236
242
|
throw new TypeError(`${C.ERROR_PREFIX}partNumber must be a positive integer`);
|
|
237
243
|
}
|
|
238
244
|
this._checkOpts(opts);
|
|
245
|
+
return this._validateData(data);
|
|
239
246
|
}
|
|
240
247
|
|
|
241
|
-
private _sign(
|
|
248
|
+
private async _sign(
|
|
242
249
|
method: IT.HttpMethod,
|
|
243
250
|
keyPath: string,
|
|
244
251
|
query: Record<string, unknown> = {},
|
|
245
252
|
headers: Record<string, string | number> = {},
|
|
246
|
-
): { url: string; headers: Record<string, string | number> } {
|
|
253
|
+
): Promise<{ url: string; headers: Record<string, string | number> }> {
|
|
247
254
|
// Create URL without appending keyPath first
|
|
248
255
|
const url = new URL(this.endpoint);
|
|
249
256
|
|
|
@@ -253,73 +260,45 @@ class s3mini {
|
|
|
253
260
|
url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
|
|
254
261
|
}
|
|
255
262
|
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const
|
|
263
|
+
const d = new Date();
|
|
264
|
+
const year = d.getUTCFullYear();
|
|
265
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
266
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
267
|
+
|
|
268
|
+
const shortDatetime = `${year}${month}${day}`;
|
|
269
|
+
const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
|
|
270
|
+
const credentialScope = `${shortDatetime}/${this.region}/${C.S3_SERVICE}/${C.AWS_REQUEST_TYPE}`;
|
|
259
271
|
|
|
260
|
-
headers[C.HEADER_AMZ_CONTENT_SHA256] = C.UNSIGNED_PAYLOAD;
|
|
272
|
+
headers[C.HEADER_AMZ_CONTENT_SHA256] = C.UNSIGNED_PAYLOAD;
|
|
261
273
|
headers[C.HEADER_AMZ_DATE] = fullDatetime;
|
|
262
274
|
headers[C.HEADER_HOST] = url.host;
|
|
263
|
-
const canonicalHeaders = this._buildCanonicalHeaders(headers);
|
|
264
|
-
const signedHeaders = Object.keys(headers)
|
|
265
|
-
.map(key => key.toLowerCase())
|
|
266
|
-
.sort()
|
|
267
|
-
.join(';');
|
|
268
|
-
|
|
269
|
-
const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders);
|
|
270
|
-
const stringToSign = this._buildStringToSign(fullDatetime, credentialScope, canonicalRequest);
|
|
271
|
-
const signature = this._calculateSignature(shortDatetime, stringToSign);
|
|
272
|
-
const authorizationHeader = this._buildAuthorizationHeader(credentialScope, signedHeaders, signature);
|
|
273
|
-
headers[C.HEADER_AUTHORIZATION] = authorizationHeader;
|
|
274
|
-
return { url: url.toString(), headers };
|
|
275
|
-
}
|
|
276
275
|
|
|
277
|
-
|
|
278
|
-
return Object.entries(headers)
|
|
279
|
-
.map(([key, value]) => `${key.toLowerCase()}:${String(value).trim()}`)
|
|
280
|
-
.sort()
|
|
281
|
-
.join('\n');
|
|
282
|
-
}
|
|
276
|
+
const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);
|
|
283
277
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
url: URL,
|
|
287
|
-
query: Record<string, unknown>,
|
|
288
|
-
canonicalHeaders: string,
|
|
289
|
-
signedHeaders: string,
|
|
290
|
-
): string {
|
|
291
|
-
return [
|
|
292
|
-
method,
|
|
293
|
-
url.pathname,
|
|
294
|
-
this._buildCanonicalQueryString(query),
|
|
295
|
-
`${canonicalHeaders}\n`,
|
|
296
|
-
signedHeaders,
|
|
297
|
-
C.UNSIGNED_PAYLOAD,
|
|
298
|
-
].join('\n');
|
|
299
|
-
}
|
|
278
|
+
let canonicalHeaders = '';
|
|
279
|
+
let signedHeaders = '';
|
|
300
280
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
281
|
+
for (const [key, value] of Object.entries(headers).sort(([a], [b]) => a.localeCompare(b))) {
|
|
282
|
+
const lowerKey = key.toLowerCase();
|
|
283
|
+
if (!ignoredHeaders.has(lowerKey)) {
|
|
284
|
+
if (canonicalHeaders) {
|
|
285
|
+
canonicalHeaders += '\n';
|
|
286
|
+
signedHeaders += ';';
|
|
287
|
+
}
|
|
288
|
+
canonicalHeaders += `${lowerKey}:${String(value).trim()}`;
|
|
289
|
+
signedHeaders += lowerKey;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
|
|
293
|
+
const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${U.hexFromBuffer(await U.sha256(canonicalRequest))}`;
|
|
310
294
|
if (shortDatetime !== this.signingKeyDate) {
|
|
311
295
|
this.signingKeyDate = shortDatetime;
|
|
312
|
-
this.signingKey = this._getSignatureKey(shortDatetime);
|
|
296
|
+
this.signingKey = await this._getSignatureKey(shortDatetime);
|
|
313
297
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return [
|
|
319
|
-
`${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}`,
|
|
320
|
-
`SignedHeaders=${signedHeaders}`,
|
|
321
|
-
`Signature=${signature}`,
|
|
322
|
-
].join(', ');
|
|
298
|
+
const signature = U.hexFromBuffer(await U.hmac(this.signingKey!, stringToSign));
|
|
299
|
+
headers[C.HEADER_AUTHORIZATION] =
|
|
300
|
+
`${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
301
|
+
return { url: url.toString(), headers };
|
|
323
302
|
}
|
|
324
303
|
|
|
325
304
|
private async _signedRequest(
|
|
@@ -327,30 +306,26 @@ class s3mini {
|
|
|
327
306
|
key: string, // ‘’ allowed for bucket‑level ops
|
|
328
307
|
{
|
|
329
308
|
query = {}, // ?query=string
|
|
330
|
-
body = '', //
|
|
309
|
+
body = '', // BodyInit | undefined
|
|
331
310
|
headers = {}, // extra/override headers
|
|
332
311
|
tolerated = [], // [200, 404] etc.
|
|
333
312
|
withQuery = false, // append query string to signed URL
|
|
334
313
|
}: {
|
|
335
314
|
query?: Record<string, unknown>;
|
|
336
|
-
body?:
|
|
337
|
-
headers?: Record<string, string | number | undefined
|
|
338
|
-
tolerated?: number[]
|
|
339
|
-
withQuery?: boolean
|
|
315
|
+
body?: BodyInit;
|
|
316
|
+
headers?: Record<string, string | number | undefined> | IT.SSECHeaders | IT.AWSHeaders;
|
|
317
|
+
tolerated?: number[];
|
|
318
|
+
withQuery?: boolean;
|
|
340
319
|
} = {},
|
|
341
320
|
): Promise<Response> {
|
|
342
321
|
// Basic validation
|
|
343
|
-
if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
if (key) {
|
|
347
|
-
this._checkKey(key); // allow '' for bucket‑level
|
|
348
|
-
}
|
|
322
|
+
// if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
|
|
323
|
+
// throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
|
|
324
|
+
// }
|
|
349
325
|
|
|
350
326
|
const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
|
|
351
327
|
? this._filterIfHeaders(query)
|
|
352
328
|
: { filteredOpts: query, conditionalHeaders: {} };
|
|
353
|
-
|
|
354
329
|
const baseHeaders: Record<string, string | number> = {
|
|
355
330
|
[C.HEADER_AMZ_CONTENT_SHA256]: C.UNSIGNED_PAYLOAD,
|
|
356
331
|
// ...(['GET', 'HEAD'].includes(method) ? { [C.HEADER_CONTENT_TYPE]: C.JSON_CONTENT_TYPE } : {}),
|
|
@@ -359,7 +334,7 @@ class s3mini {
|
|
|
359
334
|
};
|
|
360
335
|
|
|
361
336
|
const encodedKey = key ? U.uriResourceEscape(key) : '';
|
|
362
|
-
const { url, headers: signedHeaders } = this._sign(method, encodedKey, filteredOpts, baseHeaders);
|
|
337
|
+
const { url, headers: signedHeaders } = await this._sign(method, encodedKey, filteredOpts, baseHeaders);
|
|
363
338
|
if (Object.keys(query).length > 0) {
|
|
364
339
|
withQuery = true; // append query string to signed URL
|
|
365
340
|
}
|
|
@@ -374,55 +349,6 @@ class s3mini {
|
|
|
374
349
|
return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
|
|
375
350
|
}
|
|
376
351
|
|
|
377
|
-
/**
|
|
378
|
-
* Gets the current configuration properties of the S3 instance.
|
|
379
|
-
* @returns {IT.S3Config} The current S3 configuration object containing all settings.
|
|
380
|
-
* @example
|
|
381
|
-
* const config = s3.getProps();
|
|
382
|
-
* console.log(config.endpoint); // 'https://s3.amazonaws.com/my-bucket'
|
|
383
|
-
*/
|
|
384
|
-
public getProps(): IT.S3Config {
|
|
385
|
-
return {
|
|
386
|
-
accessKeyId: this.accessKeyId,
|
|
387
|
-
secretAccessKey: this.secretAccessKey,
|
|
388
|
-
endpoint: this.endpoint,
|
|
389
|
-
region: this.region,
|
|
390
|
-
requestSizeInBytes: this.requestSizeInBytes,
|
|
391
|
-
requestAbortTimeout: this.requestAbortTimeout,
|
|
392
|
-
logger: this.logger,
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Updates the configuration properties of the S3 instance.
|
|
398
|
-
* @param {IT.S3Config} props - The new configuration object.
|
|
399
|
-
* @param {string} props.accessKeyId - The access key ID for authentication.
|
|
400
|
-
* @param {string} props.secretAccessKey - The secret access key for authentication.
|
|
401
|
-
* @param {string} props.endpoint - The endpoint URL of the S3-compatible service.
|
|
402
|
-
* @param {string} [props.region='auto'] - The region of the S3 service.
|
|
403
|
-
* @param {number} [props.requestSizeInBytes=8388608] - The request size of a single request in bytes.
|
|
404
|
-
* @param {number} [props.requestAbortTimeout] - The timeout in milliseconds after which a request should be aborted.
|
|
405
|
-
* @param {IT.Logger} [props.logger] - A logger object with methods like info, warn, error.
|
|
406
|
-
* @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
|
|
407
|
-
* @example
|
|
408
|
-
* s3.setProps({
|
|
409
|
-
* accessKeyId: 'new-access-key',
|
|
410
|
-
* secretAccessKey: 'new-secret-key',
|
|
411
|
-
* endpoint: 'https://new-endpoint.com/my-bucket',
|
|
412
|
-
* region: 'us-west-2' // by default is auto
|
|
413
|
-
* });
|
|
414
|
-
*/
|
|
415
|
-
public setProps(props: IT.S3Config): void {
|
|
416
|
-
this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
|
|
417
|
-
this.accessKeyId = props.accessKeyId;
|
|
418
|
-
this.secretAccessKey = props.secretAccessKey;
|
|
419
|
-
this.region = props.region || 'auto';
|
|
420
|
-
this.endpoint = props.endpoint;
|
|
421
|
-
this.requestSizeInBytes = props.requestSizeInBytes || C.DEFAULT_REQUEST_SIZE_IN_BYTES;
|
|
422
|
-
this.requestAbortTimeout = props.requestAbortTimeout;
|
|
423
|
-
this.logger = props.logger;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
352
|
/**
|
|
427
353
|
* Sanitizes an ETag value by removing surrounding quotes and whitespace.
|
|
428
354
|
* Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
|
|
@@ -448,7 +374,7 @@ class s3mini {
|
|
|
448
374
|
`;
|
|
449
375
|
const headers = {
|
|
450
376
|
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
451
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
377
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
|
|
452
378
|
};
|
|
453
379
|
const res = await this._signedRequest('PUT', '', {
|
|
454
380
|
body: xmlBody,
|
|
@@ -458,6 +384,42 @@ class s3mini {
|
|
|
458
384
|
return res.status === 200;
|
|
459
385
|
}
|
|
460
386
|
|
|
387
|
+
private _extractBucketName(): string {
|
|
388
|
+
const url = this.endpoint;
|
|
389
|
+
|
|
390
|
+
// First check if bucket is in the pathname (path-style URLs)
|
|
391
|
+
const pathSegments = url.pathname.split('/').filter(p => p);
|
|
392
|
+
if (pathSegments.length > 0) {
|
|
393
|
+
if (typeof pathSegments[0] === 'string') {
|
|
394
|
+
return pathSegments[0];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Otherwise extract from subdomain (virtual-hosted-style URLs)
|
|
399
|
+
const hostParts = url.hostname.split('.');
|
|
400
|
+
|
|
401
|
+
// Common patterns:
|
|
402
|
+
// bucket-name.s3.amazonaws.com
|
|
403
|
+
// bucket-name.s3.region.amazonaws.com
|
|
404
|
+
// bucket-name.region.digitaloceanspaces.com
|
|
405
|
+
// bucket-name.region.cdn.digitaloceanspaces.com
|
|
406
|
+
|
|
407
|
+
if (hostParts.length >= 3) {
|
|
408
|
+
// Check if it's a known S3-compatible service
|
|
409
|
+
const domain = hostParts.slice(-2).join('.');
|
|
410
|
+
const knownDomains = ['amazonaws.com', 'digitaloceanspaces.com', 'cloudflare.com'];
|
|
411
|
+
|
|
412
|
+
if (knownDomains.some(d => domain.includes(d))) {
|
|
413
|
+
if (typeof hostParts[0] === 'string') {
|
|
414
|
+
return hostParts[0];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Fallback: use the first subdomain
|
|
420
|
+
return hostParts[0] || '';
|
|
421
|
+
}
|
|
422
|
+
|
|
461
423
|
/**
|
|
462
424
|
* Checks if a bucket exists.
|
|
463
425
|
* This method sends a request to check if the specified bucket exists in the S3-compatible service.
|
|
@@ -475,7 +437,7 @@ class s3mini {
|
|
|
475
437
|
* @param {string} [prefix=''] - The prefix to filter objects by.
|
|
476
438
|
* @param {number} [maxKeys] - The maximum number of keys to return. If not provided, all keys will be returned.
|
|
477
439
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
478
|
-
* @returns {Promise<
|
|
440
|
+
* @returns {Promise<IT.ListObject[] | null>} A promise that resolves to an array of objects or null if the bucket is empty.
|
|
479
441
|
* @example
|
|
480
442
|
* // List all objects
|
|
481
443
|
* const objects = await s3.listObjects();
|
|
@@ -487,77 +449,138 @@ class s3mini {
|
|
|
487
449
|
delimiter: string = '/',
|
|
488
450
|
prefix: string = '',
|
|
489
451
|
maxKeys?: number,
|
|
490
|
-
// method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
|
|
491
452
|
opts: Record<string, unknown> = {},
|
|
492
|
-
): Promise<
|
|
453
|
+
): Promise<IT.ListObject[] | null> {
|
|
493
454
|
this._checkDelimiter(delimiter);
|
|
494
455
|
this._checkPrefix(prefix);
|
|
495
456
|
this._checkOpts(opts);
|
|
496
457
|
|
|
497
458
|
const keyPath = delimiter === '/' ? delimiter : U.uriEscape(delimiter);
|
|
498
|
-
|
|
499
459
|
const unlimited = !(maxKeys && maxKeys > 0);
|
|
500
460
|
let remaining = unlimited ? Infinity : maxKeys;
|
|
501
461
|
let token: string | undefined;
|
|
502
|
-
const all:
|
|
462
|
+
const all: IT.ListObject[] = [];
|
|
503
463
|
|
|
504
464
|
do {
|
|
505
|
-
const
|
|
506
|
-
const query: Record<string, unknown> = {
|
|
507
|
-
'list-type': C.LIST_TYPE, // =2 for V2
|
|
508
|
-
'max-keys': String(batchSize),
|
|
509
|
-
...(prefix ? { prefix } : {}),
|
|
510
|
-
...(token ? { 'continuation-token': token } : {}),
|
|
511
|
-
...opts,
|
|
512
|
-
};
|
|
513
|
-
|
|
514
|
-
const res = await this._signedRequest('GET', keyPath, {
|
|
515
|
-
query,
|
|
516
|
-
withQuery: true,
|
|
517
|
-
tolerated: [200, 404],
|
|
518
|
-
});
|
|
465
|
+
const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
|
|
519
466
|
|
|
520
|
-
if (
|
|
521
|
-
return null;
|
|
522
|
-
}
|
|
523
|
-
if (res.status !== 200) {
|
|
524
|
-
const errorBody = await res.text();
|
|
525
|
-
const errorCode = res.headers.get('x-amz-error-code') || 'Unknown';
|
|
526
|
-
const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
|
|
527
|
-
this._log(
|
|
528
|
-
'error',
|
|
529
|
-
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
|
|
530
|
-
);
|
|
531
|
-
throw new Error(
|
|
532
|
-
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
|
|
533
|
-
);
|
|
467
|
+
if (batchResult === null) {
|
|
468
|
+
return null; // 404 - bucket not found
|
|
534
469
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const out = (raw.ListBucketResult || raw.listBucketResult || raw) as Record<string, unknown>;
|
|
541
|
-
/* accumulate Contents */
|
|
542
|
-
const contents = out.Contents || out.contents; // S3 v2 vs v1
|
|
543
|
-
if (contents) {
|
|
544
|
-
const batch = Array.isArray(contents) ? contents : [contents];
|
|
545
|
-
all.push(...(batch as object[]));
|
|
546
|
-
if (!unlimited) {
|
|
547
|
-
remaining -= batch.length;
|
|
548
|
-
}
|
|
470
|
+
|
|
471
|
+
all.push(...batchResult.objects);
|
|
472
|
+
|
|
473
|
+
if (!unlimited) {
|
|
474
|
+
remaining -= batchResult.objects.length;
|
|
549
475
|
}
|
|
550
|
-
|
|
551
|
-
token =
|
|
552
|
-
? ((out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker) as
|
|
553
|
-
| string
|
|
554
|
-
| undefined)
|
|
555
|
-
: undefined;
|
|
476
|
+
|
|
477
|
+
token = batchResult.continuationToken;
|
|
556
478
|
} while (token && remaining > 0);
|
|
557
479
|
|
|
558
480
|
return all;
|
|
559
481
|
}
|
|
560
482
|
|
|
483
|
+
private async _fetchObjectBatch(
|
|
484
|
+
keyPath: string,
|
|
485
|
+
prefix: string,
|
|
486
|
+
remaining: number,
|
|
487
|
+
token: string | undefined,
|
|
488
|
+
opts: Record<string, unknown>,
|
|
489
|
+
): Promise<{ objects: IT.ListObject[]; continuationToken?: string } | null> {
|
|
490
|
+
const query = this._buildListObjectsQuery(prefix, remaining, token, opts);
|
|
491
|
+
|
|
492
|
+
const res = await this._signedRequest('GET', keyPath, {
|
|
493
|
+
query,
|
|
494
|
+
withQuery: true,
|
|
495
|
+
tolerated: [200, 404],
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
if (res.status === 404) {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (res.status !== 200) {
|
|
503
|
+
await this._handleListObjectsError(res);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const xmlText = await res.text();
|
|
507
|
+
return this._parseListObjectsResponse(xmlText);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private _buildListObjectsQuery(
|
|
511
|
+
prefix: string,
|
|
512
|
+
remaining: number,
|
|
513
|
+
token: string | undefined,
|
|
514
|
+
opts: Record<string, unknown>,
|
|
515
|
+
): Record<string, unknown> {
|
|
516
|
+
const batchSize = Math.min(remaining, 1000); // S3 ceiling
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
'list-type': C.LIST_TYPE, // =2 for V2
|
|
520
|
+
'max-keys': String(batchSize),
|
|
521
|
+
...(prefix ? { prefix } : {}),
|
|
522
|
+
...(token ? { 'continuation-token': token } : {}),
|
|
523
|
+
...opts,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private async _handleListObjectsError(res: Response): Promise<never> {
|
|
528
|
+
const errorBody = await res.text();
|
|
529
|
+
const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
|
|
530
|
+
const errorCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
|
|
531
|
+
const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;
|
|
532
|
+
|
|
533
|
+
this._log(
|
|
534
|
+
'error',
|
|
535
|
+
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
throw new Error(
|
|
539
|
+
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private _parseListObjectsResponse(xmlText: string): {
|
|
544
|
+
objects: IT.ListObject[];
|
|
545
|
+
continuationToken?: string;
|
|
546
|
+
} {
|
|
547
|
+
const raw = U.parseXml(xmlText) as Record<string, unknown>;
|
|
548
|
+
|
|
549
|
+
if (typeof raw !== 'object' || !raw || 'error' in raw) {
|
|
550
|
+
this._log('error', `${C.ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
|
|
551
|
+
throw new Error(`${C.ERROR_PREFIX}Unexpected listObjects response shape`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const out = (raw.ListBucketResult || raw.listBucketResult || raw) as Record<string, unknown>;
|
|
555
|
+
const objects = this._extractObjectsFromResponse(out);
|
|
556
|
+
const continuationToken = this._extractContinuationToken(out);
|
|
557
|
+
|
|
558
|
+
return { objects, continuationToken };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private _extractObjectsFromResponse(response: Record<string, unknown>): IT.ListObject[] {
|
|
562
|
+
const contents = response.Contents || response.contents; // S3 v2 vs v1
|
|
563
|
+
|
|
564
|
+
if (!contents) {
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return Array.isArray(contents) ? (contents as IT.ListObject[]) : [contents as IT.ListObject];
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private _extractContinuationToken(response: Record<string, unknown>): string | undefined {
|
|
572
|
+
const truncated = response.IsTruncated === 'true' || response.isTruncated === 'true' || false;
|
|
573
|
+
|
|
574
|
+
if (!truncated) {
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return (response.NextContinuationToken ||
|
|
579
|
+
response.nextContinuationToken ||
|
|
580
|
+
response.NextMarker ||
|
|
581
|
+
response.nextMarker) as string | undefined;
|
|
582
|
+
}
|
|
583
|
+
|
|
561
584
|
/**
|
|
562
585
|
* Lists multipart uploads in the bucket.
|
|
563
586
|
* This method sends a request to list multipart uploads in the specified bucket.
|
|
@@ -607,11 +630,21 @@ class s3mini {
|
|
|
607
630
|
* Get an object from the S3-compatible service.
|
|
608
631
|
* This method sends a request to retrieve the specified object from the S3-compatible service.
|
|
609
632
|
* @param {string} key - The key of the object to retrieve.
|
|
610
|
-
* @param {Record<string, unknown>} [opts
|
|
633
|
+
* @param {Record<string, unknown>} [opts] - Additional options for the request.
|
|
634
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
611
635
|
* @returns A promise that resolves to the object data (string) or null if not found.
|
|
612
636
|
*/
|
|
613
|
-
public async getObject(
|
|
614
|
-
|
|
637
|
+
public async getObject(
|
|
638
|
+
key: string,
|
|
639
|
+
opts: Record<string, unknown> = {},
|
|
640
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
641
|
+
): Promise<string | null> {
|
|
642
|
+
// if ssecHeaders is set, add it to headers
|
|
643
|
+
const res = await this._signedRequest('GET', key, {
|
|
644
|
+
query: opts, // use opts.query if it exists, otherwise use an empty object
|
|
645
|
+
tolerated: [200, 404, 412, 304],
|
|
646
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
647
|
+
});
|
|
615
648
|
if ([404, 412, 304].includes(res.status)) {
|
|
616
649
|
return null;
|
|
617
650
|
}
|
|
@@ -623,10 +656,19 @@ class s3mini {
|
|
|
623
656
|
* This method sends a request to retrieve the specified object and returns the full response.
|
|
624
657
|
* @param {string} key - The key of the object to retrieve.
|
|
625
658
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
659
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
626
660
|
* @returns A promise that resolves to the Response object or null if not found.
|
|
627
661
|
*/
|
|
628
|
-
public async getObjectResponse(
|
|
629
|
-
|
|
662
|
+
public async getObjectResponse(
|
|
663
|
+
key: string,
|
|
664
|
+
opts: Record<string, unknown> = {},
|
|
665
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
666
|
+
): Promise<Response | null> {
|
|
667
|
+
const res = await this._signedRequest('GET', key, {
|
|
668
|
+
query: opts,
|
|
669
|
+
tolerated: [200, 404, 412, 304],
|
|
670
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
671
|
+
});
|
|
630
672
|
if ([404, 412, 304].includes(res.status)) {
|
|
631
673
|
return null;
|
|
632
674
|
}
|
|
@@ -638,10 +680,19 @@ class s3mini {
|
|
|
638
680
|
* This method sends a request to retrieve the specified object and returns it as an ArrayBuffer.
|
|
639
681
|
* @param {string} key - The key of the object to retrieve.
|
|
640
682
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
683
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
641
684
|
* @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
|
|
642
685
|
*/
|
|
643
|
-
public async getObjectArrayBuffer(
|
|
644
|
-
|
|
686
|
+
public async getObjectArrayBuffer(
|
|
687
|
+
key: string,
|
|
688
|
+
opts: Record<string, unknown> = {},
|
|
689
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
690
|
+
): Promise<ArrayBuffer | null> {
|
|
691
|
+
const res = await this._signedRequest('GET', key, {
|
|
692
|
+
query: opts,
|
|
693
|
+
tolerated: [200, 404, 412, 304],
|
|
694
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
695
|
+
});
|
|
645
696
|
if ([404, 412, 304].includes(res.status)) {
|
|
646
697
|
return null;
|
|
647
698
|
}
|
|
@@ -653,10 +704,19 @@ class s3mini {
|
|
|
653
704
|
* This method sends a request to retrieve the specified object and returns it as JSON.
|
|
654
705
|
* @param {string} key - The key of the object to retrieve.
|
|
655
706
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
707
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
656
708
|
* @returns A promise that resolves to the object data as JSON or null if not found.
|
|
657
709
|
*/
|
|
658
|
-
public async getObjectJSON<T = unknown>(
|
|
659
|
-
|
|
710
|
+
public async getObjectJSON<T = unknown>(
|
|
711
|
+
key: string,
|
|
712
|
+
opts: Record<string, unknown> = {},
|
|
713
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
714
|
+
): Promise<T | null> {
|
|
715
|
+
const res = await this._signedRequest('GET', key, {
|
|
716
|
+
query: opts,
|
|
717
|
+
tolerated: [200, 404, 412, 304],
|
|
718
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
719
|
+
});
|
|
660
720
|
if ([404, 412, 304].includes(res.status)) {
|
|
661
721
|
return null;
|
|
662
722
|
}
|
|
@@ -668,14 +728,20 @@ class s3mini {
|
|
|
668
728
|
* This method sends a request to retrieve the specified object and its ETag.
|
|
669
729
|
* @param {string} key - The key of the object to retrieve.
|
|
670
730
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
731
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
671
732
|
* @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
|
|
672
733
|
*/
|
|
673
734
|
public async getObjectWithETag(
|
|
674
735
|
key: string,
|
|
675
736
|
opts: Record<string, unknown> = {},
|
|
737
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
676
738
|
): Promise<{ etag: string | null; data: ArrayBuffer | null }> {
|
|
677
739
|
try {
|
|
678
|
-
const res = await this._signedRequest('GET', key, {
|
|
740
|
+
const res = await this._signedRequest('GET', key, {
|
|
741
|
+
query: opts,
|
|
742
|
+
tolerated: [200, 404, 412, 304],
|
|
743
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
744
|
+
});
|
|
679
745
|
|
|
680
746
|
if ([404, 412, 304].includes(res.status)) {
|
|
681
747
|
return { etag: null, data: null };
|
|
@@ -700,6 +766,7 @@ class s3mini {
|
|
|
700
766
|
* @param {number} [rangeFrom=0] - The starting byte for the range (if not whole file).
|
|
701
767
|
* @param {number} [rangeTo=this.requestSizeInBytes] - The ending byte for the range (if not whole file).
|
|
702
768
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
769
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
703
770
|
* @returns A promise that resolves to the Response object.
|
|
704
771
|
*/
|
|
705
772
|
public async getObjectRaw(
|
|
@@ -708,12 +775,13 @@ class s3mini {
|
|
|
708
775
|
rangeFrom = 0,
|
|
709
776
|
rangeTo = this.requestSizeInBytes,
|
|
710
777
|
opts: Record<string, unknown> = {},
|
|
778
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
711
779
|
): Promise<Response> {
|
|
712
780
|
const rangeHdr: Record<string, string | number> = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
|
|
713
781
|
|
|
714
782
|
return this._signedRequest('GET', key, {
|
|
715
783
|
query: { ...opts },
|
|
716
|
-
headers: rangeHdr,
|
|
784
|
+
headers: { ...rangeHdr, ...ssecHeaders },
|
|
717
785
|
withQuery: true, // keep ?query=string behaviour
|
|
718
786
|
});
|
|
719
787
|
}
|
|
@@ -725,9 +793,11 @@ class s3mini {
|
|
|
725
793
|
* @returns A promise that resolves to the content length of the object in bytes, or 0 if not found.
|
|
726
794
|
* @throws {Error} If the content length header is not found in the response.
|
|
727
795
|
*/
|
|
728
|
-
public async getContentLength(key: string): Promise<number> {
|
|
796
|
+
public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> {
|
|
729
797
|
try {
|
|
730
|
-
const res = await this._signedRequest('HEAD', key
|
|
798
|
+
const res = await this._signedRequest('HEAD', key, {
|
|
799
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
800
|
+
});
|
|
731
801
|
const len = res.headers.get(C.HEADER_CONTENT_LENGTH);
|
|
732
802
|
return len ? +len : 0;
|
|
733
803
|
} catch (err) {
|
|
@@ -762,6 +832,7 @@ class s3mini {
|
|
|
762
832
|
* Retrieves the ETag of an object without downloading its content.
|
|
763
833
|
* @param {string} key - The key of the object to retrieve the ETag for.
|
|
764
834
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
835
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
765
836
|
* @returns {Promise<string | null>} A promise that resolves to the ETag value or null if the object is not found.
|
|
766
837
|
* @throws {Error} If the ETag header is not found in the response.
|
|
767
838
|
* @example
|
|
@@ -770,16 +841,25 @@ class s3mini {
|
|
|
770
841
|
* console.log(`File ETag: ${etag}`);
|
|
771
842
|
* }
|
|
772
843
|
*/
|
|
773
|
-
public async getEtag(
|
|
844
|
+
public async getEtag(
|
|
845
|
+
key: string,
|
|
846
|
+
opts: Record<string, unknown> = {},
|
|
847
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
848
|
+
): Promise<string | null> {
|
|
774
849
|
const res = await this._signedRequest('HEAD', key, {
|
|
775
850
|
query: opts,
|
|
776
|
-
tolerated: [200, 404],
|
|
851
|
+
tolerated: [200, 304, 404, 412],
|
|
852
|
+
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
777
853
|
});
|
|
778
854
|
|
|
779
855
|
if (res.status === 404) {
|
|
780
856
|
return null;
|
|
781
857
|
}
|
|
782
858
|
|
|
859
|
+
if (res.status === 412 || res.status === 304) {
|
|
860
|
+
return null; // ETag mismatch
|
|
861
|
+
}
|
|
862
|
+
|
|
783
863
|
const etag = res.headers.get(C.HEADER_ETAG);
|
|
784
864
|
if (!etag) {
|
|
785
865
|
throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
|
|
@@ -793,6 +873,8 @@ class s3mini {
|
|
|
793
873
|
* @param {string} key - The key/path where the object will be stored.
|
|
794
874
|
* @param {string | Buffer} data - The data to upload (string or Buffer).
|
|
795
875
|
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
876
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
877
|
+
* @param {IT.AWSHeaders} [additionalHeaders] - Additional x-amz-* headers specific to this request, if any.
|
|
796
878
|
* @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
|
|
797
879
|
* @throws {TypeError} If data is not a string or Buffer.
|
|
798
880
|
* @example
|
|
@@ -805,17 +887,18 @@ class s3mini {
|
|
|
805
887
|
*/
|
|
806
888
|
public async putObject(
|
|
807
889
|
key: string,
|
|
808
|
-
data: string |
|
|
890
|
+
data: string | IT.MaybeBuffer,
|
|
809
891
|
fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
|
|
892
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
893
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
810
894
|
): Promise<Response> {
|
|
811
|
-
if (!(data instanceof Buffer || typeof data === 'string')) {
|
|
812
|
-
throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
|
|
813
|
-
}
|
|
814
895
|
return this._signedRequest('PUT', key, {
|
|
815
|
-
body: data,
|
|
896
|
+
body: this._validateData(data),
|
|
816
897
|
headers: {
|
|
817
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
898
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(data),
|
|
818
899
|
[C.HEADER_CONTENT_TYPE]: fileType,
|
|
900
|
+
...additionalHeaders,
|
|
901
|
+
...ssecHeaders,
|
|
819
902
|
},
|
|
820
903
|
tolerated: [200],
|
|
821
904
|
});
|
|
@@ -825,6 +908,7 @@ class s3mini {
|
|
|
825
908
|
* Initiates a multipart upload and returns the upload ID.
|
|
826
909
|
* @param {string} key - The key/path where the object will be stored.
|
|
827
910
|
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
911
|
+
* @param {IT.SSECHeaders?} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
828
912
|
* @returns {Promise<string>} A promise that resolves to the upload ID for the multipart upload.
|
|
829
913
|
* @throws {TypeError} If key is invalid or fileType is not a string.
|
|
830
914
|
* @throws {Error} If the multipart upload fails to initialize.
|
|
@@ -832,13 +916,17 @@ class s3mini {
|
|
|
832
916
|
* const uploadId = await s3.getMultipartUploadId('large-file.zip', 'application/zip');
|
|
833
917
|
* console.log(`Started multipart upload: ${uploadId}`);
|
|
834
918
|
*/
|
|
835
|
-
public async getMultipartUploadId(
|
|
919
|
+
public async getMultipartUploadId(
|
|
920
|
+
key: string,
|
|
921
|
+
fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
|
|
922
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
923
|
+
): Promise<string> {
|
|
836
924
|
this._checkKey(key);
|
|
837
925
|
if (typeof fileType !== 'string') {
|
|
838
926
|
throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`);
|
|
839
927
|
}
|
|
840
928
|
const query = { uploads: '' };
|
|
841
|
-
const headers = { [C.HEADER_CONTENT_TYPE]: fileType };
|
|
929
|
+
const headers = { [C.HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders };
|
|
842
930
|
|
|
843
931
|
const res = await this._signedRequest('POST', key, {
|
|
844
932
|
query,
|
|
@@ -847,16 +935,6 @@ class s3mini {
|
|
|
847
935
|
});
|
|
848
936
|
const parsed = U.parseXml(await res.text()) as Record<string, unknown>;
|
|
849
937
|
|
|
850
|
-
// if (
|
|
851
|
-
// parsed &&
|
|
852
|
-
// typeof parsed === 'object' &&
|
|
853
|
-
// 'initiateMultipartUploadResult' in parsed &&
|
|
854
|
-
// parsed.initiateMultipartUploadResult &&
|
|
855
|
-
// 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string })
|
|
856
|
-
// ) {
|
|
857
|
-
// return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId;
|
|
858
|
-
// }
|
|
859
|
-
|
|
860
938
|
if (parsed && typeof parsed === 'object') {
|
|
861
939
|
// Check for both cases of InitiateMultipartUploadResult
|
|
862
940
|
const uploadResult =
|
|
@@ -883,6 +961,7 @@ class s3mini {
|
|
|
883
961
|
* @param {Buffer | string} data - The data for this part.
|
|
884
962
|
* @param {number} partNumber - The part number (must be between 1 and 10,000).
|
|
885
963
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
964
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
886
965
|
* @returns {Promise<IT.UploadPart>} A promise that resolves to an object containing the partNumber and etag.
|
|
887
966
|
* @throws {TypeError} If any parameter is invalid.
|
|
888
967
|
* @example
|
|
@@ -897,17 +976,21 @@ class s3mini {
|
|
|
897
976
|
public async uploadPart(
|
|
898
977
|
key: string,
|
|
899
978
|
uploadId: string,
|
|
900
|
-
data:
|
|
979
|
+
data: IT.MaybeBuffer | string,
|
|
901
980
|
partNumber: number,
|
|
902
981
|
opts: Record<string, unknown> = {},
|
|
982
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
903
983
|
): Promise<IT.UploadPart> {
|
|
904
|
-
this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
|
|
984
|
+
const body = this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
|
|
905
985
|
|
|
906
986
|
const query = { uploadId, partNumber, ...opts };
|
|
907
987
|
const res = await this._signedRequest('PUT', key, {
|
|
908
988
|
query,
|
|
909
|
-
body
|
|
910
|
-
headers: {
|
|
989
|
+
body,
|
|
990
|
+
headers: {
|
|
991
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(data),
|
|
992
|
+
...ssecHeaders,
|
|
993
|
+
},
|
|
911
994
|
});
|
|
912
995
|
|
|
913
996
|
return { partNumber, etag: U.sanitizeETag(res.headers.get('etag') || '') };
|
|
@@ -940,7 +1023,7 @@ class s3mini {
|
|
|
940
1023
|
const xmlBody = this._buildCompleteMultipartUploadXml(parts);
|
|
941
1024
|
const headers = {
|
|
942
1025
|
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
943
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
1026
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
|
|
944
1027
|
};
|
|
945
1028
|
|
|
946
1029
|
const res = await this._signedRequest('POST', key, {
|
|
@@ -963,7 +1046,7 @@ class s3mini {
|
|
|
963
1046
|
if (etag && typeof etag === 'string') {
|
|
964
1047
|
return {
|
|
965
1048
|
...resultObj,
|
|
966
|
-
etag:
|
|
1049
|
+
etag: U.sanitizeETag(etag),
|
|
967
1050
|
} as IT.CompleteMultipartUploadResult;
|
|
968
1051
|
}
|
|
969
1052
|
|
|
@@ -978,6 +1061,7 @@ class s3mini {
|
|
|
978
1061
|
* Aborts a multipart upload and removes all uploaded parts.
|
|
979
1062
|
* @param {string} key - The key of the object being uploaded.
|
|
980
1063
|
* @param {string} uploadId - The upload ID to abort.
|
|
1064
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
981
1065
|
* @returns {Promise<object>} A promise that resolves to an object containing the abort status and details.
|
|
982
1066
|
* @throws {TypeError} If key or uploadId is invalid.
|
|
983
1067
|
* @throws {Error} If the abort operation fails.
|
|
@@ -989,14 +1073,14 @@ class s3mini {
|
|
|
989
1073
|
* console.error('Failed to abort upload:', error);
|
|
990
1074
|
* }
|
|
991
1075
|
*/
|
|
992
|
-
public async abortMultipartUpload(key: string, uploadId: string): Promise<object> {
|
|
1076
|
+
public async abortMultipartUpload(key: string, uploadId: string, ssecHeaders?: IT.SSECHeaders): Promise<object> {
|
|
993
1077
|
this._checkKey(key);
|
|
994
1078
|
if (!uploadId) {
|
|
995
1079
|
throw new TypeError(C.ERROR_UPLOAD_ID_REQUIRED);
|
|
996
1080
|
}
|
|
997
1081
|
|
|
998
1082
|
const query = { uploadId };
|
|
999
|
-
const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE };
|
|
1083
|
+
const headers = { [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE, ...(ssecHeaders ? { ...ssecHeaders } : {}) };
|
|
1000
1084
|
|
|
1001
1085
|
const res = await this._signedRequest('DELETE', key, {
|
|
1002
1086
|
query,
|
|
@@ -1018,20 +1102,228 @@ class s3mini {
|
|
|
1018
1102
|
}
|
|
1019
1103
|
|
|
1020
1104
|
private _buildCompleteMultipartUploadXml(parts: Array<IT.UploadPart>): string {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1105
|
+
let xml = '<CompleteMultipartUpload>';
|
|
1106
|
+
for (const part of parts) {
|
|
1107
|
+
xml += `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`;
|
|
1108
|
+
}
|
|
1109
|
+
xml += '</CompleteMultipartUpload>';
|
|
1110
|
+
return xml;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Executes the copy operation for local copying (same bucket/endpoint).
|
|
1115
|
+
* @private
|
|
1116
|
+
*/
|
|
1117
|
+
private async _executeCopyOperation(
|
|
1118
|
+
destinationKey: string,
|
|
1119
|
+
copySource: string,
|
|
1120
|
+
options: IT.CopyObjectOptions,
|
|
1121
|
+
): Promise<IT.CopyObjectResult> {
|
|
1122
|
+
const {
|
|
1123
|
+
metadataDirective = 'COPY',
|
|
1124
|
+
metadata = {},
|
|
1125
|
+
contentType,
|
|
1126
|
+
storageClass,
|
|
1127
|
+
taggingDirective,
|
|
1128
|
+
websiteRedirectLocation,
|
|
1129
|
+
sourceSSECHeaders = {},
|
|
1130
|
+
destinationSSECHeaders = {},
|
|
1131
|
+
additionalHeaders = {},
|
|
1132
|
+
} = options;
|
|
1133
|
+
|
|
1134
|
+
const headers: Record<string, string | number> = {
|
|
1135
|
+
'x-amz-copy-source': copySource,
|
|
1136
|
+
'x-amz-metadata-directive': metadataDirective,
|
|
1137
|
+
...additionalHeaders,
|
|
1138
|
+
...(contentType && { [C.HEADER_CONTENT_TYPE]: contentType }),
|
|
1139
|
+
...(storageClass && { 'x-amz-storage-class': storageClass }),
|
|
1140
|
+
...(taggingDirective && { 'x-amz-tagging-directive': taggingDirective }),
|
|
1141
|
+
...(websiteRedirectLocation && { 'x-amz-website-redirect-location': websiteRedirectLocation }),
|
|
1142
|
+
...this._buildSSECHeaders(sourceSSECHeaders, destinationSSECHeaders),
|
|
1143
|
+
...(metadataDirective === 'REPLACE' ? this._buildMetadataHeaders(metadata) : {}),
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
try {
|
|
1147
|
+
const res = await this._signedRequest('PUT', destinationKey, {
|
|
1148
|
+
headers,
|
|
1149
|
+
tolerated: [200],
|
|
1150
|
+
});
|
|
1151
|
+
return this._parseCopyObjectResponse(await res.text());
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
this._log('error', `Error in copy operation to ${destinationKey}`, {
|
|
1154
|
+
error: String(err),
|
|
1155
|
+
});
|
|
1156
|
+
throw err;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Copies an object within the same bucket.
|
|
1162
|
+
*
|
|
1163
|
+
* @param {string} sourceKey - The key of the source object to copy
|
|
1164
|
+
* @param {string} destinationKey - The key where the object will be copied to
|
|
1165
|
+
* @param {IT.CopyObjectOptions} [options={}] - Copy operation options
|
|
1166
|
+
* @param {string} [options.metadataDirective='COPY'] - How to handle metadata ('COPY' | 'REPLACE')
|
|
1167
|
+
* @param {Record<string,string>} [options.metadata={}] - New metadata (only used if metadataDirective='REPLACE')
|
|
1168
|
+
* @param {string} [options.contentType] - New content type for the destination object
|
|
1169
|
+
* @param {string} [options.storageClass] - Storage class for the destination object
|
|
1170
|
+
* @param {string} [options.taggingDirective] - How to handle object tags ('COPY' | 'REPLACE')
|
|
1171
|
+
* @param {string} [options.websiteRedirectLocation] - Website redirect location for the destination
|
|
1172
|
+
* @param {IT.SSECHeaders} [options.sourceSSECHeaders={}] - Encryption headers for reading source (if encrypted)
|
|
1173
|
+
* @param {IT.SSECHeaders} [options.destinationSSECHeaders={}] - Encryption headers for destination
|
|
1174
|
+
* @param {IT.AWSHeaders} [options.additionalHeaders={}] - Extra x-amz-* headers
|
|
1175
|
+
*
|
|
1176
|
+
* @returns {Promise<IT.CopyObjectResult>} Copy result with etag and lastModified date
|
|
1177
|
+
* @throws {TypeError} If sourceKey or destinationKey is invalid
|
|
1178
|
+
* @throws {Error} If copy operation fails or S3 returns an error
|
|
1179
|
+
*
|
|
1180
|
+
* @example
|
|
1181
|
+
* // Simple copy
|
|
1182
|
+
* const result = await s3.copyObject('report-2024.pdf', 'archive/report-2024.pdf');
|
|
1183
|
+
* console.log(`Copied with ETag: ${result.etag}`);
|
|
1184
|
+
*
|
|
1185
|
+
* @example
|
|
1186
|
+
* // Copy with new metadata and content type
|
|
1187
|
+
* const result = await s3.copyObject('data.csv', 'processed/data.csv', {
|
|
1188
|
+
* metadataDirective: 'REPLACE',
|
|
1189
|
+
* metadata: {
|
|
1190
|
+
* 'processed-date': new Date().toISOString(),
|
|
1191
|
+
* 'original-name': 'data.csv'
|
|
1192
|
+
* },
|
|
1193
|
+
* contentType: 'text/csv; charset=utf-8'
|
|
1194
|
+
* });
|
|
1195
|
+
*
|
|
1196
|
+
* @example
|
|
1197
|
+
* // Copy encrypted object (Cloudflare R2 SSE-C)
|
|
1198
|
+
* const ssecKey = 'n1TKiTaVHlYLMX9n0zHXyooMr026vOiTEFfT+719Hho=';
|
|
1199
|
+
* await s3.copyObject('sensitive.json', 'backup/sensitive.json', {
|
|
1200
|
+
* sourceSSECHeaders: {
|
|
1201
|
+
* 'x-amz-copy-source-server-side-encryption-customer-algorithm': 'AES256',
|
|
1202
|
+
* 'x-amz-copy-source-server-side-encryption-customer-key': ssecKey,
|
|
1203
|
+
* 'x-amz-copy-source-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
|
|
1204
|
+
* },
|
|
1205
|
+
* destinationSSECHeaders: {
|
|
1206
|
+
* 'x-amz-server-side-encryption-customer-algorithm': 'AES256',
|
|
1207
|
+
* 'x-amz-server-side-encryption-customer-key': ssecKey,
|
|
1208
|
+
* 'x-amz-server-side-encryption-customer-key-md5': 'gepZmzgR7Be/1+K1Aw+6ow=='
|
|
1209
|
+
* }
|
|
1210
|
+
* });
|
|
1211
|
+
*/
|
|
1212
|
+
public copyObject(
|
|
1213
|
+
sourceKey: string,
|
|
1214
|
+
destinationKey: string,
|
|
1215
|
+
options: IT.CopyObjectOptions = {},
|
|
1216
|
+
): Promise<IT.CopyObjectResult> {
|
|
1217
|
+
// Validate parameters
|
|
1218
|
+
this._checkKey(sourceKey);
|
|
1219
|
+
this._checkKey(destinationKey);
|
|
1220
|
+
|
|
1221
|
+
const copySource = `/${this.bucketName}/${U.uriEscape(sourceKey)}`;
|
|
1222
|
+
|
|
1223
|
+
return this._executeCopyOperation(destinationKey, copySource, options);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
private _buildSSECHeaders(
|
|
1227
|
+
sourceHeaders: Record<string, string | number>,
|
|
1228
|
+
destHeaders: Record<string, string | number>,
|
|
1229
|
+
): Record<string, string | number> {
|
|
1230
|
+
const headers: Record<string, string | number> = {};
|
|
1231
|
+
Object.entries({ ...sourceHeaders, ...destHeaders }).forEach(([k, v]) => {
|
|
1232
|
+
if (v !== undefined) {
|
|
1233
|
+
headers[k] = v;
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
return headers;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Moves an object within the same bucket (copy + delete atomic-like operation).
|
|
1241
|
+
*
|
|
1242
|
+
* WARNING: Not truly atomic - if delete fails after successful copy, the object
|
|
1243
|
+
* will exist in both locations. Consider your use case carefully.
|
|
1244
|
+
*
|
|
1245
|
+
* @param {string} sourceKey - The key of the source object to move
|
|
1246
|
+
* @param {string} destinationKey - The key where the object will be moved to
|
|
1247
|
+
* @param {IT.CopyObjectOptions} [options={}] - Options passed to the copy operation
|
|
1248
|
+
*
|
|
1249
|
+
* @returns {Promise<IT.CopyObjectResult>} Result from the copy operation
|
|
1250
|
+
* @throws {TypeError} If sourceKey or destinationKey is invalid
|
|
1251
|
+
* @throws {Error} If copy succeeds but delete fails (includes copy result in error)
|
|
1252
|
+
*
|
|
1253
|
+
* @example
|
|
1254
|
+
* // Simple move
|
|
1255
|
+
* await s3.moveObject('temp/upload.tmp', 'files/document.pdf');
|
|
1256
|
+
*
|
|
1257
|
+
* @example
|
|
1258
|
+
* // Move with metadata update
|
|
1259
|
+
* await s3.moveObject('unprocessed/image.jpg', 'processed/image.jpg', {
|
|
1260
|
+
* metadataDirective: 'REPLACE',
|
|
1261
|
+
* metadata: {
|
|
1262
|
+
* 'status': 'processed',
|
|
1263
|
+
* 'processed-at': Date.now().toString()
|
|
1264
|
+
* },
|
|
1265
|
+
* contentType: 'image/jpeg'
|
|
1266
|
+
* });
|
|
1267
|
+
*
|
|
1268
|
+
* @example
|
|
1269
|
+
* // Safe move with error handling
|
|
1270
|
+
* try {
|
|
1271
|
+
* const result = await s3.moveObject('inbox/file.dat', 'archive/file.dat');
|
|
1272
|
+
* console.log(`Moved successfully: ${result.etag}`);
|
|
1273
|
+
* } catch (error) {
|
|
1274
|
+
* // Check if copy succeeded but delete failed
|
|
1275
|
+
* if (error.message.includes('delete source object after successful copy')) {
|
|
1276
|
+
* console.warn('File copied but not deleted from source - manual cleanup needed');
|
|
1277
|
+
* }
|
|
1278
|
+
* }
|
|
1279
|
+
*/
|
|
1280
|
+
public async moveObject(
|
|
1281
|
+
sourceKey: string,
|
|
1282
|
+
destinationKey: string,
|
|
1283
|
+
options: IT.CopyObjectOptions = {},
|
|
1284
|
+
): Promise<IT.CopyObjectResult> {
|
|
1285
|
+
try {
|
|
1286
|
+
// First copy the object
|
|
1287
|
+
const copyResult = await this.copyObject(sourceKey, destinationKey, options);
|
|
1288
|
+
|
|
1289
|
+
// Then delete the source
|
|
1290
|
+
const deleteSuccess = await this.deleteObject(sourceKey);
|
|
1291
|
+
if (!deleteSuccess) {
|
|
1292
|
+
throw new Error(`${C.ERROR_PREFIX}Failed to delete source object after successful copy`);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return copyResult;
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
this._log('error', `Error moving object from ${sourceKey} to ${destinationKey}`, {
|
|
1298
|
+
error: String(err),
|
|
1299
|
+
});
|
|
1300
|
+
throw err;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private _buildMetadataHeaders(metadata: Record<string, string>): Record<string, string> {
|
|
1305
|
+
const headers: Record<string, string> = {};
|
|
1306
|
+
Object.entries(metadata).forEach(([k, v]) => {
|
|
1307
|
+
headers[k.startsWith('x-amz-meta-') ? k : `x-amz-meta-${k}`] = v;
|
|
1308
|
+
});
|
|
1309
|
+
return headers;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
private _parseCopyObjectResponse(xmlText: string): IT.CopyObjectResult {
|
|
1313
|
+
const parsed = U.parseXml(xmlText) as Record<string, unknown>;
|
|
1314
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
1315
|
+
throw new Error(`${C.ERROR_PREFIX}Unexpected copyObject response format`);
|
|
1316
|
+
}
|
|
1317
|
+
const result = (parsed.CopyObjectResult || parsed.copyObjectResult || parsed) as Record<string, unknown>;
|
|
1318
|
+
const etag = result.ETag || result.eTag || result.etag;
|
|
1319
|
+
const lastModified = result.LastModified || result.lastModified;
|
|
1320
|
+
if (!etag || typeof etag !== 'string') {
|
|
1321
|
+
throw new Error(`${C.ERROR_PREFIX}ETag not found in copyObject response`);
|
|
1322
|
+
}
|
|
1323
|
+
return {
|
|
1324
|
+
etag: U.sanitizeETag(etag),
|
|
1325
|
+
lastModified: lastModified ? new Date(lastModified as string) : undefined,
|
|
1326
|
+
};
|
|
1035
1327
|
}
|
|
1036
1328
|
|
|
1037
1329
|
/**
|
|
@@ -1046,13 +1338,14 @@ class s3mini {
|
|
|
1046
1338
|
}
|
|
1047
1339
|
|
|
1048
1340
|
private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
|
|
1049
|
-
const
|
|
1341
|
+
const objectsXml = keys.map(key => `<Object><Key>${U.escapeXml(key)}</Key></Object>`).join('');
|
|
1342
|
+
const xmlBody = '<Delete>' + objectsXml + '</Delete>';
|
|
1050
1343
|
const query = { delete: '' };
|
|
1051
|
-
const
|
|
1344
|
+
const sha256base64 = U.base64FromBuffer(await U.sha256(xmlBody));
|
|
1052
1345
|
const headers = {
|
|
1053
1346
|
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1054
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
1055
|
-
|
|
1347
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
|
|
1348
|
+
[C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1056
1349
|
};
|
|
1057
1350
|
|
|
1058
1351
|
const res = await this._signedRequest('POST', '', {
|
|
@@ -1139,7 +1432,7 @@ class s3mini {
|
|
|
1139
1432
|
url: string,
|
|
1140
1433
|
method: IT.HttpMethod,
|
|
1141
1434
|
headers: Record<string, string>,
|
|
1142
|
-
body?:
|
|
1435
|
+
body?: BodyInit,
|
|
1143
1436
|
toleratedStatusCodes: number[] = [],
|
|
1144
1437
|
): Promise<Response> {
|
|
1145
1438
|
this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
|
|
@@ -1147,13 +1440,14 @@ class s3mini {
|
|
|
1147
1440
|
const res = await fetch(url, {
|
|
1148
1441
|
method,
|
|
1149
1442
|
headers,
|
|
1150
|
-
body: ['GET', 'HEAD'].includes(method) ? undefined :
|
|
1443
|
+
body: ['GET', 'HEAD'].includes(method) ? undefined : body,
|
|
1151
1444
|
signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
|
|
1152
1445
|
});
|
|
1153
1446
|
this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
|
|
1154
|
-
if (
|
|
1155
|
-
|
|
1447
|
+
if (res.ok || toleratedStatusCodes.includes(res.status)) {
|
|
1448
|
+
return res;
|
|
1156
1449
|
}
|
|
1450
|
+
await this._handleErrorResponse(res);
|
|
1157
1451
|
return res;
|
|
1158
1452
|
} catch (err: unknown) {
|
|
1159
1453
|
const code = U.extractErrCode(err);
|
|
@@ -1164,10 +1458,32 @@ class s3mini {
|
|
|
1164
1458
|
}
|
|
1165
1459
|
}
|
|
1166
1460
|
|
|
1461
|
+
private _parseErrorXml(headers: Headers, body: string): { svcCode?: string; errorMessage?: string } {
|
|
1462
|
+
if (headers.get('content-type') !== 'application/xml') {
|
|
1463
|
+
return {};
|
|
1464
|
+
}
|
|
1465
|
+
const parsedBody = U.parseXml(body);
|
|
1466
|
+
if (
|
|
1467
|
+
!parsedBody ||
|
|
1468
|
+
typeof parsedBody !== 'object' ||
|
|
1469
|
+
!('Error' in parsedBody) ||
|
|
1470
|
+
!parsedBody.Error ||
|
|
1471
|
+
typeof parsedBody.Error !== 'object'
|
|
1472
|
+
) {
|
|
1473
|
+
return {};
|
|
1474
|
+
}
|
|
1475
|
+
const error = parsedBody.Error;
|
|
1476
|
+
return {
|
|
1477
|
+
svcCode: 'Code' in error && typeof error.Code === 'string' ? error.Code : undefined,
|
|
1478
|
+
errorMessage: 'Message' in error && typeof error.Message === 'string' ? error.Message : undefined,
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1167
1482
|
private async _handleErrorResponse(res: Response): Promise<void> {
|
|
1168
1483
|
const errorBody = await res.text();
|
|
1169
|
-
const
|
|
1170
|
-
const
|
|
1484
|
+
const parsedErrorBody = this._parseErrorXml(res.headers, errorBody);
|
|
1485
|
+
const svcCode = res.headers.get('x-amz-error-code') ?? parsedErrorBody.svcCode ?? 'Unknown';
|
|
1486
|
+
const errorMessage = res.headers.get('x-amz-error-message') ?? parsedErrorBody.errorMessage ?? res.statusText;
|
|
1171
1487
|
this._log(
|
|
1172
1488
|
'error',
|
|
1173
1489
|
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`,
|
|
@@ -1181,16 +1497,16 @@ class s3mini {
|
|
|
1181
1497
|
}
|
|
1182
1498
|
return Object.keys(queryParams)
|
|
1183
1499
|
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] as string)}`)
|
|
1184
|
-
.sort()
|
|
1500
|
+
.sort((a, b) => a.localeCompare(b))
|
|
1185
1501
|
.join('&');
|
|
1186
1502
|
}
|
|
1187
|
-
private _getSignatureKey(dateStamp: string):
|
|
1188
|
-
const kDate = U.hmac(`AWS4${this.secretAccessKey}`, dateStamp)
|
|
1189
|
-
const kRegion = U.hmac(kDate, this.region)
|
|
1190
|
-
const kService = U.hmac(kRegion, C.S3_SERVICE)
|
|
1191
|
-
return U.hmac(kService, C.AWS_REQUEST_TYPE)
|
|
1503
|
+
private async _getSignatureKey(dateStamp: string): Promise<ArrayBuffer> {
|
|
1504
|
+
const kDate = await U.hmac(`AWS4${this.secretAccessKey}`, dateStamp);
|
|
1505
|
+
const kRegion = await U.hmac(kDate, this.region);
|
|
1506
|
+
const kService = await U.hmac(kRegion, C.S3_SERVICE);
|
|
1507
|
+
return await U.hmac(kService, C.AWS_REQUEST_TYPE);
|
|
1192
1508
|
}
|
|
1193
1509
|
}
|
|
1194
1510
|
|
|
1195
|
-
export {
|
|
1196
|
-
export default
|
|
1511
|
+
export { S3mini };
|
|
1512
|
+
export default S3mini;
|