s3mini 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/s3mini.d.ts +21 -17
- package/dist/s3mini.js +95 -33
- 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 +10 -11
- package/src/S3.ts +146 -61
- package/src/utils.ts +9 -7
package/src/S3.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import * as C from './consts.js';
|
|
4
|
+
import {
|
|
5
|
+
hexFromBuffer,
|
|
6
|
+
sha256,
|
|
7
|
+
hmac,
|
|
8
|
+
uriResourceEscape,
|
|
9
|
+
getByteSize,
|
|
10
|
+
sanitizeETag,
|
|
11
|
+
uriEscape,
|
|
12
|
+
parseXml,
|
|
13
|
+
escapeXml,
|
|
14
|
+
base64FromBuffer,
|
|
15
|
+
extractErrCode,
|
|
16
|
+
S3NetworkError,
|
|
17
|
+
S3ServiceError,
|
|
18
|
+
} from './utils.js';
|
|
4
19
|
import type * as IT from './types.js';
|
|
5
|
-
import * as U from './utils.js';
|
|
6
20
|
|
|
7
21
|
/**
|
|
8
22
|
* S3 class for interacting with S3-compatible object storage services.
|
|
@@ -43,8 +57,8 @@ class S3mini {
|
|
|
43
57
|
* @param {typeof fetch} [config.fetch=globalThis.fetch] - Custom fetch implementation to use for HTTP requests.
|
|
44
58
|
* @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
|
|
45
59
|
*/
|
|
46
|
-
readonly accessKeyId: string;
|
|
47
|
-
readonly secretAccessKey: string;
|
|
60
|
+
readonly #accessKeyId: string;
|
|
61
|
+
readonly #secretAccessKey: string;
|
|
48
62
|
readonly endpoint: URL;
|
|
49
63
|
readonly region: string;
|
|
50
64
|
readonly bucketName: string;
|
|
@@ -66,8 +80,8 @@ class S3mini {
|
|
|
66
80
|
fetch = globalThis.fetch,
|
|
67
81
|
}: IT.S3Config) {
|
|
68
82
|
this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
|
|
69
|
-
this
|
|
70
|
-
this
|
|
83
|
+
this.#accessKeyId = accessKeyId;
|
|
84
|
+
this.#secretAccessKey = secretAccessKey;
|
|
71
85
|
this.endpoint = new URL(this._ensureValidUrl(endpoint));
|
|
72
86
|
this.region = region;
|
|
73
87
|
this.bucketName = this._extractBucketName();
|
|
@@ -120,7 +134,7 @@ class S3mini {
|
|
|
120
134
|
region: this.region,
|
|
121
135
|
endpoint: this.endpoint.toString(),
|
|
122
136
|
// Only include the first few characters of the access key, if it exists
|
|
123
|
-
accessKeyId: this
|
|
137
|
+
accessKeyId: this.#accessKeyId ? `${this.#accessKeyId.substring(0, 4)}...` : undefined,
|
|
124
138
|
}),
|
|
125
139
|
};
|
|
126
140
|
|
|
@@ -292,14 +306,14 @@ class S3mini {
|
|
|
292
306
|
}
|
|
293
307
|
}
|
|
294
308
|
const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
|
|
295
|
-
const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${
|
|
309
|
+
const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
|
|
296
310
|
if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
|
|
297
311
|
this.signingKeyDate = shortDatetime;
|
|
298
312
|
this.signingKey = await this._getSignatureKey(shortDatetime);
|
|
299
313
|
}
|
|
300
|
-
const signature =
|
|
314
|
+
const signature = hexFromBuffer(await hmac(this.signingKey, stringToSign));
|
|
301
315
|
headers[C.HEADER_AUTHORIZATION] =
|
|
302
|
-
`${C.AWS_ALGORITHM} Credential=${this
|
|
316
|
+
`${C.AWS_ALGORITHM} Credential=${this.#accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
303
317
|
return { url: url.toString(), headers };
|
|
304
318
|
}
|
|
305
319
|
|
|
@@ -335,7 +349,7 @@ class S3mini {
|
|
|
335
349
|
...conditionalHeaders,
|
|
336
350
|
};
|
|
337
351
|
|
|
338
|
-
const encodedKey = key ?
|
|
352
|
+
const encodedKey = key ? uriResourceEscape(key) : '';
|
|
339
353
|
const { url, headers: signedHeaders } = await this._sign(method, encodedKey, filteredOpts, baseHeaders);
|
|
340
354
|
if (Object.keys(query).length > 0) {
|
|
341
355
|
withQuery = true; // append query string to signed URL
|
|
@@ -360,7 +374,7 @@ class S3mini {
|
|
|
360
374
|
* const cleanEtag = s3.sanitizeETag('"abc123"'); // Returns: 'abc123'
|
|
361
375
|
*/
|
|
362
376
|
public sanitizeETag(etag: string): string {
|
|
363
|
-
return
|
|
377
|
+
return sanitizeETag(etag);
|
|
364
378
|
}
|
|
365
379
|
|
|
366
380
|
/**
|
|
@@ -376,7 +390,7 @@ class S3mini {
|
|
|
376
390
|
`;
|
|
377
391
|
const headers = {
|
|
378
392
|
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
379
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
393
|
+
[C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
380
394
|
};
|
|
381
395
|
const res = await this._signedRequest('PUT', '', {
|
|
382
396
|
body: xmlBody,
|
|
@@ -457,7 +471,7 @@ class S3mini {
|
|
|
457
471
|
this._checkPrefix(prefix);
|
|
458
472
|
this._checkOpts(opts);
|
|
459
473
|
|
|
460
|
-
const keyPath = delimiter === '/' ? delimiter :
|
|
474
|
+
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
461
475
|
const unlimited = !(maxKeys && maxKeys > 0);
|
|
462
476
|
let remaining = unlimited ? Infinity : maxKeys;
|
|
463
477
|
let token: string | undefined;
|
|
@@ -482,6 +496,47 @@ class S3mini {
|
|
|
482
496
|
return all;
|
|
483
497
|
}
|
|
484
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Lists objects in the bucket with optional filtering and pagination using a continuation token.
|
|
501
|
+
* This method retrieves objects matching the criteria (paginated like listObjectsV2).
|
|
502
|
+
* @param {string} [delimiter='/'] - The delimiter to use for grouping objects.
|
|
503
|
+
* @param {string} [prefix=''] - The prefix to filter objects by.
|
|
504
|
+
* @param {number} [maxKeys] - The maximum number of keys to return. Uses a default value of 100.
|
|
505
|
+
* @param {string} [nextContinuationToken] - The nextContinuationToken to continue previous results. If not provided, starts from the beginning.
|
|
506
|
+
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
507
|
+
* @returns {Promise<{objects: IT.ListObject[] | null; nextContinuationToken?: string } | undefined | null>} A promise that resolves to an array of objects or null if the bucket is empty, along with nextContinuationToken if there are more reccords.
|
|
508
|
+
* @example
|
|
509
|
+
* // List all objects
|
|
510
|
+
* const { objects, nextContinuationToken } = await s3.listObjectsPaged();
|
|
511
|
+
*
|
|
512
|
+
* // List 200 objects with prefix
|
|
513
|
+
* const photos = await s3.listObjectsPaged('/', 'photos/', 200, "token...");
|
|
514
|
+
*/
|
|
515
|
+
public async listObjectsPaged(
|
|
516
|
+
delimiter: string = '/',
|
|
517
|
+
prefix: string = '',
|
|
518
|
+
maxKeys: number = 100,
|
|
519
|
+
nextContinuationToken?: string,
|
|
520
|
+
opts: Record<string, unknown> = {},
|
|
521
|
+
): Promise<{ objects: IT.ListObject[] | null; nextContinuationToken?: string } | undefined | null> {
|
|
522
|
+
this._checkDelimiter(delimiter);
|
|
523
|
+
this._checkPrefix(prefix);
|
|
524
|
+
this._checkOpts(opts);
|
|
525
|
+
|
|
526
|
+
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
527
|
+
let token: string | undefined = nextContinuationToken;
|
|
528
|
+
const all: IT.ListObject[] = [];
|
|
529
|
+
|
|
530
|
+
const batchResult = await this._fetchObjectBatch(keyPath, prefix, maxKeys, token, opts);
|
|
531
|
+
if (batchResult === null) {
|
|
532
|
+
return null; // 404 - bucket not found
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
all.push(...batchResult.objects);
|
|
536
|
+
token = batchResult.continuationToken;
|
|
537
|
+
return { objects: all, nextContinuationToken: token };
|
|
538
|
+
}
|
|
539
|
+
|
|
485
540
|
private async _fetchObjectBatch(
|
|
486
541
|
keyPath: string,
|
|
487
542
|
prefix: string,
|
|
@@ -546,7 +601,7 @@ class S3mini {
|
|
|
546
601
|
objects: IT.ListObject[];
|
|
547
602
|
continuationToken?: string;
|
|
548
603
|
} {
|
|
549
|
-
const raw =
|
|
604
|
+
const raw = parseXml(xmlText) as Record<string, unknown>;
|
|
550
605
|
|
|
551
606
|
if (typeof raw !== 'object' || !raw || 'error' in raw) {
|
|
552
607
|
this._log('error', `${C.ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
|
|
@@ -562,12 +617,37 @@ class S3mini {
|
|
|
562
617
|
|
|
563
618
|
private _extractObjectsFromResponse(response: Record<string, unknown>): IT.ListObject[] {
|
|
564
619
|
const contents = response.Contents || response.contents; // S3 v2 vs v1
|
|
620
|
+
const commonPrefixes = response.CommonPrefixes || response.commonPrefixes;
|
|
565
621
|
|
|
566
|
-
|
|
567
|
-
|
|
622
|
+
const objects: IT.ListObject[] = [];
|
|
623
|
+
|
|
624
|
+
// Extract regular objects from Contents
|
|
625
|
+
if (contents) {
|
|
626
|
+
if (Array.isArray(contents)) {
|
|
627
|
+
objects.push(...(contents as IT.ListObject[]));
|
|
628
|
+
} else {
|
|
629
|
+
objects.push(contents as IT.ListObject);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Extract directory prefixes from CommonPrefixes
|
|
634
|
+
if (commonPrefixes) {
|
|
635
|
+
const prefixList = Array.isArray(commonPrefixes) ? commonPrefixes : [commonPrefixes];
|
|
636
|
+
for (const item of prefixList) {
|
|
637
|
+
const prefix = (item as Record<string, unknown>).Prefix || (item as Record<string, unknown>).prefix;
|
|
638
|
+
if (typeof prefix === 'string') {
|
|
639
|
+
objects.push({
|
|
640
|
+
Key: prefix,
|
|
641
|
+
Size: 0,
|
|
642
|
+
LastModified: new Date(0),
|
|
643
|
+
ETag: '',
|
|
644
|
+
StorageClass: '',
|
|
645
|
+
} as IT.ListObject);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
568
648
|
}
|
|
569
649
|
|
|
570
|
-
return
|
|
650
|
+
return objects;
|
|
571
651
|
}
|
|
572
652
|
|
|
573
653
|
private _extractContinuationToken(response: Record<string, unknown>): string | undefined {
|
|
@@ -604,7 +684,7 @@ class S3mini {
|
|
|
604
684
|
this._checkOpts(opts);
|
|
605
685
|
|
|
606
686
|
const query = { uploads: '', ...opts };
|
|
607
|
-
const keyPath = delimiter === '/' ? delimiter :
|
|
687
|
+
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
608
688
|
|
|
609
689
|
const res = await this._signedRequest(method, keyPath, {
|
|
610
690
|
query,
|
|
@@ -618,7 +698,7 @@ class S3mini {
|
|
|
618
698
|
// etag: res.headers.get(C.HEADER_ETAG) ?? '',
|
|
619
699
|
// };
|
|
620
700
|
// }
|
|
621
|
-
const raw =
|
|
701
|
+
const raw = parseXml(await res.text()) as unknown;
|
|
622
702
|
if (typeof raw !== 'object' || raw === null) {
|
|
623
703
|
throw new Error(`${C.ERROR_PREFIX}Unexpected listMultipartUploads response shape`);
|
|
624
704
|
}
|
|
@@ -647,10 +727,11 @@ class S3mini {
|
|
|
647
727
|
tolerated: [200, 404, 412, 304],
|
|
648
728
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
649
729
|
});
|
|
650
|
-
|
|
651
|
-
|
|
730
|
+
const s = res.status;
|
|
731
|
+
if (s === 200) {
|
|
732
|
+
return res.text();
|
|
652
733
|
}
|
|
653
|
-
return
|
|
734
|
+
return null;
|
|
654
735
|
}
|
|
655
736
|
|
|
656
737
|
/**
|
|
@@ -671,10 +752,10 @@ class S3mini {
|
|
|
671
752
|
tolerated: [200, 404, 412, 304],
|
|
672
753
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
673
754
|
});
|
|
674
|
-
if (
|
|
675
|
-
return
|
|
755
|
+
if (res.status === 200) {
|
|
756
|
+
return res;
|
|
676
757
|
}
|
|
677
|
-
return
|
|
758
|
+
return null;
|
|
678
759
|
}
|
|
679
760
|
|
|
680
761
|
/**
|
|
@@ -695,10 +776,10 @@ class S3mini {
|
|
|
695
776
|
tolerated: [200, 404, 412, 304],
|
|
696
777
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
697
778
|
});
|
|
698
|
-
if (
|
|
699
|
-
return
|
|
779
|
+
if (res.status === 200) {
|
|
780
|
+
return res.arrayBuffer();
|
|
700
781
|
}
|
|
701
|
-
return
|
|
782
|
+
return null;
|
|
702
783
|
}
|
|
703
784
|
|
|
704
785
|
/**
|
|
@@ -719,10 +800,10 @@ class S3mini {
|
|
|
719
800
|
tolerated: [200, 404, 412, 304],
|
|
720
801
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
721
802
|
});
|
|
722
|
-
if (
|
|
723
|
-
return
|
|
803
|
+
if (res.status === 200) {
|
|
804
|
+
return res.json() as Promise<T>;
|
|
724
805
|
}
|
|
725
|
-
return
|
|
806
|
+
return null;
|
|
726
807
|
}
|
|
727
808
|
|
|
728
809
|
/**
|
|
@@ -744,8 +825,8 @@ class S3mini {
|
|
|
744
825
|
tolerated: [200, 404, 412, 304],
|
|
745
826
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
746
827
|
});
|
|
747
|
-
|
|
748
|
-
if (
|
|
828
|
+
const s = res.status;
|
|
829
|
+
if (s === 404 || s === 412 || s === 304) {
|
|
749
830
|
return { etag: null, data: null };
|
|
750
831
|
}
|
|
751
832
|
|
|
@@ -753,7 +834,7 @@ class S3mini {
|
|
|
753
834
|
if (!etag) {
|
|
754
835
|
throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
|
|
755
836
|
}
|
|
756
|
-
return { etag:
|
|
837
|
+
return { etag: sanitizeETag(etag), data: await res.arrayBuffer() };
|
|
757
838
|
} catch (err) {
|
|
758
839
|
this._log('error', `Error getting object ${key} with ETag: ${String(err)}`);
|
|
759
840
|
throw err;
|
|
@@ -775,12 +856,16 @@ class S3mini {
|
|
|
775
856
|
key: string,
|
|
776
857
|
wholeFile = true,
|
|
777
858
|
rangeFrom = 0,
|
|
778
|
-
rangeTo
|
|
859
|
+
rangeTo?: number,
|
|
779
860
|
opts: Record<string, unknown> = {},
|
|
780
861
|
ssecHeaders?: IT.SSECHeaders,
|
|
781
862
|
): Promise<Response> {
|
|
782
|
-
|
|
863
|
+
let rangeHdr: Record<string, string | number> = {};
|
|
783
864
|
|
|
865
|
+
if (!wholeFile) {
|
|
866
|
+
rangeHdr =
|
|
867
|
+
rangeTo === undefined ? { range: `bytes=${rangeFrom}-` } : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
|
|
868
|
+
}
|
|
784
869
|
return this._signedRequest('GET', key, {
|
|
785
870
|
query: { ...opts },
|
|
786
871
|
headers: { ...rangeHdr, ...ssecHeaders },
|
|
@@ -867,7 +952,7 @@ class S3mini {
|
|
|
867
952
|
throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
|
|
868
953
|
}
|
|
869
954
|
|
|
870
|
-
return
|
|
955
|
+
return sanitizeETag(etag);
|
|
871
956
|
}
|
|
872
957
|
|
|
873
958
|
/**
|
|
@@ -897,7 +982,7 @@ class S3mini {
|
|
|
897
982
|
return this._signedRequest('PUT', key, {
|
|
898
983
|
body: this._validateData(data),
|
|
899
984
|
headers: {
|
|
900
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
985
|
+
[C.HEADER_CONTENT_LENGTH]: getByteSize(data),
|
|
901
986
|
[C.HEADER_CONTENT_TYPE]: fileType,
|
|
902
987
|
...additionalHeaders,
|
|
903
988
|
...ssecHeaders,
|
|
@@ -935,7 +1020,7 @@ class S3mini {
|
|
|
935
1020
|
headers,
|
|
936
1021
|
withQuery: true,
|
|
937
1022
|
});
|
|
938
|
-
const parsed =
|
|
1023
|
+
const parsed = parseXml(await res.text()) as Record<string, unknown>;
|
|
939
1024
|
|
|
940
1025
|
if (parsed && typeof parsed === 'object') {
|
|
941
1026
|
// Check for both cases of InitiateMultipartUploadResult
|
|
@@ -990,12 +1075,12 @@ class S3mini {
|
|
|
990
1075
|
query,
|
|
991
1076
|
body,
|
|
992
1077
|
headers: {
|
|
993
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
1078
|
+
[C.HEADER_CONTENT_LENGTH]: getByteSize(data),
|
|
994
1079
|
...ssecHeaders,
|
|
995
1080
|
},
|
|
996
1081
|
});
|
|
997
1082
|
|
|
998
|
-
return { partNumber, etag:
|
|
1083
|
+
return { partNumber, etag: sanitizeETag(res.headers.get('etag') || '') };
|
|
999
1084
|
}
|
|
1000
1085
|
|
|
1001
1086
|
/**
|
|
@@ -1025,7 +1110,7 @@ class S3mini {
|
|
|
1025
1110
|
const xmlBody = this._buildCompleteMultipartUploadXml(parts);
|
|
1026
1111
|
const headers = {
|
|
1027
1112
|
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1028
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
1113
|
+
[C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1029
1114
|
};
|
|
1030
1115
|
|
|
1031
1116
|
const res = await this._signedRequest('POST', key, {
|
|
@@ -1035,7 +1120,7 @@ class S3mini {
|
|
|
1035
1120
|
withQuery: true,
|
|
1036
1121
|
});
|
|
1037
1122
|
|
|
1038
|
-
const parsed =
|
|
1123
|
+
const parsed = parseXml(await res.text()) as Record<string, unknown>;
|
|
1039
1124
|
if (parsed && typeof parsed === 'object') {
|
|
1040
1125
|
// Check for both cases
|
|
1041
1126
|
const result = parsed.completeMultipartUploadResult || parsed.CompleteMultipartUploadResult || parsed;
|
|
@@ -1048,7 +1133,7 @@ class S3mini {
|
|
|
1048
1133
|
if (etag && typeof etag === 'string') {
|
|
1049
1134
|
return {
|
|
1050
1135
|
...resultObj,
|
|
1051
|
-
etag:
|
|
1136
|
+
etag: sanitizeETag(etag),
|
|
1052
1137
|
} as IT.CompleteMultipartUploadResult;
|
|
1053
1138
|
}
|
|
1054
1139
|
|
|
@@ -1089,7 +1174,7 @@ class S3mini {
|
|
|
1089
1174
|
headers,
|
|
1090
1175
|
withQuery: true,
|
|
1091
1176
|
});
|
|
1092
|
-
const parsed =
|
|
1177
|
+
const parsed = parseXml(await res.text()) as Record<string, unknown>;
|
|
1093
1178
|
if (
|
|
1094
1179
|
parsed &&
|
|
1095
1180
|
'error' in parsed &&
|
|
@@ -1220,7 +1305,7 @@ class S3mini {
|
|
|
1220
1305
|
this._checkKey(sourceKey);
|
|
1221
1306
|
this._checkKey(destinationKey);
|
|
1222
1307
|
|
|
1223
|
-
const copySource = `/${this.bucketName}/${
|
|
1308
|
+
const copySource = `/${this.bucketName}/${uriEscape(sourceKey)}`;
|
|
1224
1309
|
|
|
1225
1310
|
return this._executeCopyOperation(destinationKey, copySource, options);
|
|
1226
1311
|
}
|
|
@@ -1312,7 +1397,7 @@ class S3mini {
|
|
|
1312
1397
|
}
|
|
1313
1398
|
|
|
1314
1399
|
private _parseCopyObjectResponse(xmlText: string): IT.CopyObjectResult {
|
|
1315
|
-
const parsed =
|
|
1400
|
+
const parsed = parseXml(xmlText) as Record<string, unknown>;
|
|
1316
1401
|
if (!parsed || typeof parsed !== 'object') {
|
|
1317
1402
|
throw new Error(`${C.ERROR_PREFIX}Unexpected copyObject response format`);
|
|
1318
1403
|
}
|
|
@@ -1323,7 +1408,7 @@ class S3mini {
|
|
|
1323
1408
|
throw new Error(`${C.ERROR_PREFIX}ETag not found in copyObject response`);
|
|
1324
1409
|
}
|
|
1325
1410
|
return {
|
|
1326
|
-
etag:
|
|
1411
|
+
etag: sanitizeETag(etag),
|
|
1327
1412
|
lastModified: lastModified ? new Date(lastModified as string) : undefined,
|
|
1328
1413
|
};
|
|
1329
1414
|
}
|
|
@@ -1340,13 +1425,13 @@ class S3mini {
|
|
|
1340
1425
|
}
|
|
1341
1426
|
|
|
1342
1427
|
private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
|
|
1343
|
-
const objectsXml = keys.map(key => `<Object><Key>${
|
|
1428
|
+
const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
|
|
1344
1429
|
const xmlBody = '<Delete>' + objectsXml + '</Delete>';
|
|
1345
1430
|
const query = { delete: '' };
|
|
1346
|
-
const sha256base64 =
|
|
1431
|
+
const sha256base64 = base64FromBuffer(await sha256(xmlBody));
|
|
1347
1432
|
const headers = {
|
|
1348
1433
|
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1349
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
1434
|
+
[C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1350
1435
|
[C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1351
1436
|
};
|
|
1352
1437
|
|
|
@@ -1356,7 +1441,7 @@ class S3mini {
|
|
|
1356
1441
|
headers,
|
|
1357
1442
|
withQuery: true,
|
|
1358
1443
|
});
|
|
1359
|
-
const parsed =
|
|
1444
|
+
const parsed = parseXml(await res.text()) as Record<string, unknown>;
|
|
1360
1445
|
if (!parsed || typeof parsed !== 'object') {
|
|
1361
1446
|
throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
|
|
1362
1447
|
}
|
|
@@ -1444,7 +1529,7 @@ class S3mini {
|
|
|
1444
1529
|
const res = await this._fetch(url, {
|
|
1445
1530
|
method,
|
|
1446
1531
|
headers,
|
|
1447
|
-
body:
|
|
1532
|
+
body: method === 'GET' || method === 'HEAD' ? undefined : body,
|
|
1448
1533
|
signal: this.requestAbortTimeout ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
|
|
1449
1534
|
});
|
|
1450
1535
|
this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
|
|
@@ -1454,9 +1539,9 @@ class S3mini {
|
|
|
1454
1539
|
await this._handleErrorResponse(res);
|
|
1455
1540
|
return res;
|
|
1456
1541
|
} catch (err: unknown) {
|
|
1457
|
-
const code =
|
|
1542
|
+
const code = extractErrCode(err);
|
|
1458
1543
|
if (code && ['ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'ECONNREFUSED'].includes(code)) {
|
|
1459
|
-
throw new
|
|
1544
|
+
throw new S3NetworkError(`S3 network error: ${code}`, code, err);
|
|
1460
1545
|
}
|
|
1461
1546
|
throw err;
|
|
1462
1547
|
}
|
|
@@ -1466,7 +1551,7 @@ class S3mini {
|
|
|
1466
1551
|
if (headers.get('content-type') !== 'application/xml') {
|
|
1467
1552
|
return {};
|
|
1468
1553
|
}
|
|
1469
|
-
const parsedBody =
|
|
1554
|
+
const parsedBody = parseXml(body);
|
|
1470
1555
|
if (
|
|
1471
1556
|
!parsedBody ||
|
|
1472
1557
|
typeof parsedBody !== 'object' ||
|
|
@@ -1492,7 +1577,7 @@ class S3mini {
|
|
|
1492
1577
|
'error',
|
|
1493
1578
|
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`,
|
|
1494
1579
|
);
|
|
1495
|
-
throw new
|
|
1580
|
+
throw new S3ServiceError(`S3 returned ${res.status} – ${svcCode}`, res.status, svcCode, errorBody);
|
|
1496
1581
|
}
|
|
1497
1582
|
|
|
1498
1583
|
private _buildCanonicalQueryString(queryParams: Record<string, unknown>): string {
|
|
@@ -1505,10 +1590,10 @@ class S3mini {
|
|
|
1505
1590
|
.join('&');
|
|
1506
1591
|
}
|
|
1507
1592
|
private async _getSignatureKey(dateStamp: string): Promise<ArrayBuffer> {
|
|
1508
|
-
const kDate = await
|
|
1509
|
-
const kRegion = await
|
|
1510
|
-
const kService = await
|
|
1511
|
-
return await
|
|
1593
|
+
const kDate = await hmac(`AWS4${this.#secretAccessKey}`, dateStamp);
|
|
1594
|
+
const kRegion = await hmac(kDate, this.region);
|
|
1595
|
+
const kService = await hmac(kRegion, C.S3_SERVICE);
|
|
1596
|
+
return await hmac(kService, C.AWS_REQUEST_TYPE);
|
|
1512
1597
|
}
|
|
1513
1598
|
}
|
|
1514
1599
|
|
package/src/utils.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { XmlValue, XmlMap, ListBucketResponse, ErrorWithCode } from './type
|
|
|
3
3
|
|
|
4
4
|
const ENCODR = new TextEncoder();
|
|
5
5
|
const chunkSize = 0x8000; // 32KB chunks
|
|
6
|
-
const
|
|
6
|
+
const HEX_CHARS = new Uint8Array([48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102]);
|
|
7
7
|
|
|
8
8
|
export const getByteSize = (data: unknown): number => {
|
|
9
9
|
if (typeof data === 'string') {
|
|
@@ -23,13 +23,15 @@ export const getByteSize = (data: unknown): number => {
|
|
|
23
23
|
* @param {ArrayBuffer} buffer The raw bytes.
|
|
24
24
|
* @returns {string} Hexadecimal string
|
|
25
25
|
*/
|
|
26
|
+
|
|
26
27
|
export const hexFromBuffer = (buffer: ArrayBuffer): string => {
|
|
27
28
|
const bytes = new Uint8Array(buffer);
|
|
28
|
-
|
|
29
|
-
for (
|
|
30
|
-
hex
|
|
29
|
+
const hex = new Uint8Array(bytes.length * 2);
|
|
30
|
+
for (let i = 0, j = 0; i < bytes.length; i++) {
|
|
31
|
+
hex[j++] = HEX_CHARS[bytes[i]! >> 4]!;
|
|
32
|
+
hex[j++] = HEX_CHARS[bytes[i]! & 0x0f]!;
|
|
31
33
|
}
|
|
32
|
-
return hex;
|
|
34
|
+
return String.fromCodePoint(...hex);
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -158,7 +160,7 @@ export const parseXml = (input: string): XmlValue => {
|
|
|
158
160
|
* @param c Character to encode
|
|
159
161
|
* @returns Percent-encoded character
|
|
160
162
|
*/
|
|
161
|
-
const encodeAsHex = (c: string): string => `%${c.
|
|
163
|
+
const encodeAsHex = (c: string): string => `%${(c.codePointAt(0) ?? 0).toString(16).toUpperCase()}`;
|
|
162
164
|
|
|
163
165
|
/**
|
|
164
166
|
* Escape a URI string using percent encoding
|
|
@@ -166,7 +168,7 @@ const encodeAsHex = (c: string): string => `%${c.charCodeAt(0).toString(16).toUp
|
|
|
166
168
|
* @returns Escaped URI string
|
|
167
169
|
*/
|
|
168
170
|
export const uriEscape = (uriStr: string): string => {
|
|
169
|
-
return encodeURIComponent(uriStr).
|
|
171
|
+
return encodeURIComponent(uriStr).replaceAll(/[!'()*]/g, encodeAsHex);
|
|
170
172
|
};
|
|
171
173
|
|
|
172
174
|
/**
|