s3mini 0.1.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/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/s3mini.d.ts +187 -0
- package/dist/s3mini.js +841 -0
- package/dist/s3mini.js.map +1 -0
- package/dist/s3mini.min.js +2 -0
- package/dist/s3mini.min.js.map +1 -0
- package/package.json +112 -0
- package/src/S3.ts +831 -0
- package/src/consts.ts +38 -0
- package/src/index.ts +20 -0
- package/src/types.ts +88 -0
- package/src/utils.ts +197 -0
package/dist/s3mini.js
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
// Constants
|
|
2
|
+
const AWS_ALGORITHM = 'AWS4-HMAC-SHA256';
|
|
3
|
+
const AWS_REQUEST_TYPE = 'aws4_request';
|
|
4
|
+
const S3_SERVICE = 's3';
|
|
5
|
+
const LIST_TYPE = '2';
|
|
6
|
+
const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
|
|
7
|
+
const DEFAULT_STREAM_CONTENT_TYPE = 'application/octet-stream';
|
|
8
|
+
const XML_CONTENT_TYPE = 'application/xml';
|
|
9
|
+
// List of keys that might contain sensitive information
|
|
10
|
+
const SENSITIVE_KEYS_REDACTED = ['accessKeyId', 'secretAccessKey', 'sessionToken', 'password', 'token'];
|
|
11
|
+
const DEFAULT_REQUEST_SIZE_IN_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
// Headers
|
|
13
|
+
const HEADER_AMZ_CONTENT_SHA256 = 'x-amz-content-sha256';
|
|
14
|
+
const HEADER_AMZ_DATE = 'x-amz-date';
|
|
15
|
+
const HEADER_HOST = 'host';
|
|
16
|
+
const HEADER_AUTHORIZATION = 'Authorization';
|
|
17
|
+
const HEADER_CONTENT_TYPE = 'Content-Type';
|
|
18
|
+
const HEADER_CONTENT_LENGTH = 'Content-Length';
|
|
19
|
+
const HEADER_ETAG = 'etag';
|
|
20
|
+
// Error messages
|
|
21
|
+
const ERROR_PREFIX = '[s3mini] ';
|
|
22
|
+
const ERROR_ACCESS_KEY_REQUIRED = `${ERROR_PREFIX}accessKeyId must be a non-empty string`;
|
|
23
|
+
const ERROR_SECRET_KEY_REQUIRED = `${ERROR_PREFIX}secretAccessKey must be a non-empty string`;
|
|
24
|
+
const ERROR_ENDPOINT_REQUIRED = `${ERROR_PREFIX}endpoint must be a non-empty string`;
|
|
25
|
+
const ERROR_ENDPOINT_FORMAT = `${ERROR_PREFIX}endpoint must be a valid URL. Expected format: https://<host>[:port][/base-path]`;
|
|
26
|
+
const ERROR_KEY_REQUIRED = `${ERROR_PREFIX}key must be a non-empty string`;
|
|
27
|
+
const ERROR_UPLOAD_ID_REQUIRED = `${ERROR_PREFIX}uploadId must be a non-empty string`;
|
|
28
|
+
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
|
+
const ERROR_PREFIX_TYPE = `${ERROR_PREFIX}prefix must be a string`;
|
|
31
|
+
const ERROR_DELIMITER_REQUIRED = `${ERROR_PREFIX}delimiter must be a string`;
|
|
32
|
+
|
|
33
|
+
// Initialize crypto functions
|
|
34
|
+
const _createHmac = crypto.createHmac || (await import('node:crypto')).createHmac;
|
|
35
|
+
const _createHash = crypto.createHash || (await import('node:crypto')).createHash;
|
|
36
|
+
/**
|
|
37
|
+
* Hash content using SHA-256
|
|
38
|
+
* @param {string|Buffer} content – data to hash
|
|
39
|
+
* @returns {string} Hex encoded hash
|
|
40
|
+
*/
|
|
41
|
+
const hash = (content) => {
|
|
42
|
+
return _createHash('sha256').update(content).digest('hex');
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Compute HMAC-SHA-256 of arbitrary data and return a hex string.
|
|
46
|
+
* @param {string|Buffer} key – secret key
|
|
47
|
+
* @param {string|Buffer} content – data to authenticate
|
|
48
|
+
* @param {BufferEncoding} [encoding='hex'] – hex | base64 | …
|
|
49
|
+
* @returns {string | Buffer} hex encoded HMAC
|
|
50
|
+
*/
|
|
51
|
+
const hmac = (key, content, encoding) => {
|
|
52
|
+
const mac = _createHmac('sha256', key).update(content);
|
|
53
|
+
return encoding ? mac.digest(encoding) : mac.digest();
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Sanitize ETag value by removing quotes and XML entities
|
|
57
|
+
* @param etag ETag value to sanitize
|
|
58
|
+
* @returns Sanitized ETag
|
|
59
|
+
*/
|
|
60
|
+
const sanitizeETag = (etag) => {
|
|
61
|
+
const replaceChars = {
|
|
62
|
+
'"': '',
|
|
63
|
+
'"': '',
|
|
64
|
+
'"': '',
|
|
65
|
+
'"': '',
|
|
66
|
+
'"': '',
|
|
67
|
+
};
|
|
68
|
+
return etag.replace(/^("|"|")|("|"|")$/g, m => replaceChars[m]);
|
|
69
|
+
};
|
|
70
|
+
const entityMap = {
|
|
71
|
+
'"': '"',
|
|
72
|
+
''': "'",
|
|
73
|
+
'<': '<',
|
|
74
|
+
'>': '>',
|
|
75
|
+
'&': '&',
|
|
76
|
+
};
|
|
77
|
+
const unescapeXml = (value) => value.replace(/&(quot|apos|lt|gt|amp);/g, m => entityMap[m] ?? m);
|
|
78
|
+
/**
|
|
79
|
+
* Parse a very small subset of XML into a JS structure.
|
|
80
|
+
*
|
|
81
|
+
* @param input raw XML string
|
|
82
|
+
* @returns string for leaf nodes, otherwise a map of children
|
|
83
|
+
*/
|
|
84
|
+
const parseXml = (input) => {
|
|
85
|
+
const RE_TAG = /<(\w)([-\w]+)(?:\/|[^>]*>((?:(?!<\1)[\s\S])*)<\/\1\2)>/gm;
|
|
86
|
+
const result = {}; // strong type, no `any`
|
|
87
|
+
let match;
|
|
88
|
+
while ((match = RE_TAG.exec(input)) !== null) {
|
|
89
|
+
const [, prefix = '', key, inner] = match;
|
|
90
|
+
const fullKey = `${prefix.toLowerCase()}${key}`;
|
|
91
|
+
const node = inner ? parseXml(inner) : '';
|
|
92
|
+
const current = result[fullKey];
|
|
93
|
+
if (current === undefined) {
|
|
94
|
+
// first occurrence
|
|
95
|
+
result[fullKey] = node;
|
|
96
|
+
}
|
|
97
|
+
else if (Array.isArray(current)) {
|
|
98
|
+
// already an array
|
|
99
|
+
current.push(node);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// promote to array on the second occurrence
|
|
103
|
+
result[fullKey] = [current, node];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// No child tags? — return the text, after entity decode
|
|
107
|
+
return Object.keys(result).length > 0 ? result : unescapeXml(input);
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Encode a character as a URI percent-encoded hex value
|
|
111
|
+
* @param c Character to encode
|
|
112
|
+
* @returns Percent-encoded character
|
|
113
|
+
*/
|
|
114
|
+
const encodeAsHex = (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`;
|
|
115
|
+
/**
|
|
116
|
+
* Escape a URI string using percent encoding
|
|
117
|
+
* @param uriStr URI string to escape
|
|
118
|
+
* @returns Escaped URI string
|
|
119
|
+
*/
|
|
120
|
+
const uriEscape = (uriStr) => {
|
|
121
|
+
return encodeURIComponent(uriStr).replace(/[!'()*]/g, encodeAsHex);
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Escape a URI resource path while preserving forward slashes
|
|
125
|
+
* @param string URI path to escape
|
|
126
|
+
* @returns Escaped URI path
|
|
127
|
+
*/
|
|
128
|
+
const uriResourceEscape = (string) => {
|
|
129
|
+
return uriEscape(string).replace(/%2F/g, '/');
|
|
130
|
+
};
|
|
131
|
+
const extractErrCode = (e) => {
|
|
132
|
+
if (typeof e !== 'object' || e === null) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const err = e;
|
|
136
|
+
if (typeof err.code === 'string') {
|
|
137
|
+
return err.code;
|
|
138
|
+
}
|
|
139
|
+
return typeof err.cause?.code === 'string' ? err.cause.code : undefined;
|
|
140
|
+
};
|
|
141
|
+
class S3Error extends Error {
|
|
142
|
+
code;
|
|
143
|
+
constructor(msg, code, cause) {
|
|
144
|
+
super(msg);
|
|
145
|
+
this.name = new.target.name; // keeps instanceof usable
|
|
146
|
+
this.code = code;
|
|
147
|
+
this.cause = cause;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
class S3NetworkError extends S3Error {
|
|
151
|
+
}
|
|
152
|
+
class S3ServiceError extends S3Error {
|
|
153
|
+
status;
|
|
154
|
+
serviceCode;
|
|
155
|
+
body;
|
|
156
|
+
constructor(msg, status, serviceCode, body) {
|
|
157
|
+
super(msg, serviceCode);
|
|
158
|
+
this.status = status;
|
|
159
|
+
this.serviceCode = serviceCode;
|
|
160
|
+
this.body = body;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Run async-returning tasks in batches with an *optional* minimum
|
|
165
|
+
* spacing (minIntervalMs) between the *start* times of successive batches.
|
|
166
|
+
*
|
|
167
|
+
* @param {Iterable<() => Promise<unknonw>>} tasks – functions returning Promises
|
|
168
|
+
* @param {number} [batchSize=30] – max concurrent requests
|
|
169
|
+
* @param {number} [minIntervalMs=0] – ≥0; 0 means “no pacing”
|
|
170
|
+
* @returns {Promise<Array<PromiseSettledResult<unknonw>>>}
|
|
171
|
+
*/
|
|
172
|
+
const runInBatches = async (tasks, batchSize = 30, minIntervalMs = 0) => {
|
|
173
|
+
const allResults = [];
|
|
174
|
+
let batch = [];
|
|
175
|
+
for (const task of tasks) {
|
|
176
|
+
batch.push(task);
|
|
177
|
+
if (batch.length === batchSize) {
|
|
178
|
+
await executeBatch(batch);
|
|
179
|
+
batch = [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (batch.length) {
|
|
183
|
+
await executeBatch(batch);
|
|
184
|
+
}
|
|
185
|
+
return allResults;
|
|
186
|
+
// ───────── helpers ──────────
|
|
187
|
+
async function executeBatch(batchFns) {
|
|
188
|
+
const start = Date.now();
|
|
189
|
+
const settled = await Promise.allSettled(batchFns.map(fn => fn()));
|
|
190
|
+
allResults.push(...settled);
|
|
191
|
+
if (minIntervalMs > 0) {
|
|
192
|
+
const wait = minIntervalMs - (Date.now() - start);
|
|
193
|
+
if (wait > 0) {
|
|
194
|
+
await new Promise(r => setTimeout(r, wait));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* S3 class for interacting with S3-compatible object storage services.
|
|
202
|
+
* This class provides methods for common S3 operations such as uploading, downloading,
|
|
203
|
+
* and deleting objects, as well as multipart uploads.
|
|
204
|
+
*
|
|
205
|
+
* @class
|
|
206
|
+
* @example
|
|
207
|
+
* const s3 = new CoreS3({
|
|
208
|
+
* accessKeyId: 'your-access-key',
|
|
209
|
+
* secretAccessKey: 'your-secret-key',
|
|
210
|
+
* endpoint: 'https://your-s3-endpoint.com',
|
|
211
|
+
* region: 'us-east-1' // by default is auto
|
|
212
|
+
* });
|
|
213
|
+
*
|
|
214
|
+
* // Upload a file
|
|
215
|
+
* await s3.putObject('example.txt', 'Hello, World!');
|
|
216
|
+
*
|
|
217
|
+
* // Download a file
|
|
218
|
+
* const content = await s3.getObject('example.txt');
|
|
219
|
+
*
|
|
220
|
+
* // Delete a file
|
|
221
|
+
* await s3.deleteObject('example.txt');
|
|
222
|
+
*/
|
|
223
|
+
class s3mini {
|
|
224
|
+
/**
|
|
225
|
+
* Creates an instance of the S3 class.
|
|
226
|
+
*
|
|
227
|
+
* @constructor
|
|
228
|
+
* @param {Object} config - Configuration options for the S3 instance.
|
|
229
|
+
* @param {string} config.accessKeyId - The access key ID for authentication.
|
|
230
|
+
* @param {string} config.secretAccessKey - The secret access key for authentication.
|
|
231
|
+
* @param {string} config.endpoint - The endpoint URL of the S3-compatible service.
|
|
232
|
+
* @param {string} [config.region='auto'] - The region of the S3 service.
|
|
233
|
+
* @param {number} [config.requestSizeInBytes=8388608] - The request size of a single request in bytes (AWS S3 is 8MB).
|
|
234
|
+
* @param {number} [config.requestAbortTimeout=undefined] - The timeout in milliseconds after which a request should be aborted (careful on streamed requests).
|
|
235
|
+
* @param {Object} [config.logger=null] - A logger object with methods like info, warn, error.
|
|
236
|
+
* @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
|
|
237
|
+
*/
|
|
238
|
+
accessKeyId;
|
|
239
|
+
secretAccessKey;
|
|
240
|
+
endpoint;
|
|
241
|
+
region;
|
|
242
|
+
requestSizeInBytes;
|
|
243
|
+
requestAbortTimeout;
|
|
244
|
+
logger;
|
|
245
|
+
fullDatetime;
|
|
246
|
+
shortDatetime;
|
|
247
|
+
signingKey;
|
|
248
|
+
credentialScope;
|
|
249
|
+
constructor({ accessKeyId, secretAccessKey, endpoint, region = 'auto', requestSizeInBytes = DEFAULT_REQUEST_SIZE_IN_BYTES, requestAbortTimeout = undefined, logger = undefined, }) {
|
|
250
|
+
this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
|
|
251
|
+
this.accessKeyId = accessKeyId;
|
|
252
|
+
this.secretAccessKey = secretAccessKey;
|
|
253
|
+
this.endpoint = this._ensureValidUrl(endpoint);
|
|
254
|
+
this.region = region;
|
|
255
|
+
this.requestSizeInBytes = requestSizeInBytes;
|
|
256
|
+
this.requestAbortTimeout = requestAbortTimeout;
|
|
257
|
+
this.logger = logger;
|
|
258
|
+
this.fullDatetime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
259
|
+
this.shortDatetime = this.fullDatetime.slice(0, 8);
|
|
260
|
+
this.signingKey = this._getSignatureKey(this.shortDatetime);
|
|
261
|
+
this.credentialScope = [this.shortDatetime, this.region, S3_SERVICE, AWS_REQUEST_TYPE].join('/');
|
|
262
|
+
}
|
|
263
|
+
_sanitize(obj) {
|
|
264
|
+
if (typeof obj !== 'object' || obj === null) {
|
|
265
|
+
return obj;
|
|
266
|
+
}
|
|
267
|
+
return Object.keys(obj).reduce((acc, key) => {
|
|
268
|
+
if (SENSITIVE_KEYS_REDACTED.includes(key.toLowerCase())) {
|
|
269
|
+
acc[key] = '[REDACTED]';
|
|
270
|
+
}
|
|
271
|
+
else if (typeof obj[key] === 'object' &&
|
|
272
|
+
obj[key] !== null) {
|
|
273
|
+
acc[key] = this._sanitize(obj[key]);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
acc[key] = obj[key];
|
|
277
|
+
}
|
|
278
|
+
return acc;
|
|
279
|
+
}, Array.isArray(obj) ? [] : {});
|
|
280
|
+
}
|
|
281
|
+
_log(level, message, additionalData = {}) {
|
|
282
|
+
if (this.logger && typeof this.logger[level] === 'function') {
|
|
283
|
+
// Function to recursively sanitize an object
|
|
284
|
+
// Sanitize the additional data
|
|
285
|
+
const sanitizedData = this._sanitize(additionalData);
|
|
286
|
+
// Prepare the log entry
|
|
287
|
+
const logEntry = {
|
|
288
|
+
timestamp: new Date().toISOString(),
|
|
289
|
+
level,
|
|
290
|
+
message,
|
|
291
|
+
details: sanitizedData,
|
|
292
|
+
// Include some general context, but sanitize sensitive parts
|
|
293
|
+
context: this._sanitize({
|
|
294
|
+
region: this.region,
|
|
295
|
+
endpoint: this.endpoint,
|
|
296
|
+
// Only include the first few characters of the access key, if it exists
|
|
297
|
+
accessKeyId: this.accessKeyId ? `${this.accessKeyId.substring(0, 4)}...` : undefined,
|
|
298
|
+
}),
|
|
299
|
+
};
|
|
300
|
+
// Log the sanitized entry
|
|
301
|
+
this.logger[level](JSON.stringify(logEntry));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
_validateConstructorParams(accessKeyId, secretAccessKey, endpoint) {
|
|
305
|
+
if (typeof accessKeyId !== 'string' || accessKeyId.trim().length === 0) {
|
|
306
|
+
throw new TypeError(ERROR_ACCESS_KEY_REQUIRED);
|
|
307
|
+
}
|
|
308
|
+
if (typeof secretAccessKey !== 'string' || secretAccessKey.trim().length === 0) {
|
|
309
|
+
throw new TypeError(ERROR_SECRET_KEY_REQUIRED);
|
|
310
|
+
}
|
|
311
|
+
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
|
|
312
|
+
throw new TypeError(ERROR_ENDPOINT_REQUIRED);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
_ensureValidUrl(raw) {
|
|
316
|
+
// prepend https:// if user forgot a scheme
|
|
317
|
+
const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
318
|
+
try {
|
|
319
|
+
new URL(candidate);
|
|
320
|
+
return candidate.replace(/\/+$/, ''); // strip trailing slash
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
const msg = `${ERROR_ENDPOINT_FORMAT} But provided: "${raw}"`;
|
|
324
|
+
this._log('error', msg);
|
|
325
|
+
throw new TypeError(msg);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
_validateMethodIsGetOrHead(method) {
|
|
329
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
330
|
+
this._log('error', `${ERROR_PREFIX}method must be either GET or HEAD`);
|
|
331
|
+
throw new Error('method must be either GET or HEAD');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
_checkKey(key) {
|
|
335
|
+
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
336
|
+
this._log('error', ERROR_KEY_REQUIRED);
|
|
337
|
+
throw new TypeError(ERROR_KEY_REQUIRED);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
_checkDelimiter(delimiter) {
|
|
341
|
+
if (typeof delimiter !== 'string' || delimiter.trim().length === 0) {
|
|
342
|
+
this._log('error', ERROR_DELIMITER_REQUIRED);
|
|
343
|
+
throw new TypeError(ERROR_DELIMITER_REQUIRED);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
_checkPrefix(prefix) {
|
|
347
|
+
if (typeof prefix !== 'string') {
|
|
348
|
+
this._log('error', ERROR_PREFIX_TYPE);
|
|
349
|
+
throw new TypeError(ERROR_PREFIX_TYPE);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// private _checkMaxKeys(maxKeys: number): void {
|
|
353
|
+
// if (typeof maxKeys !== 'number' || maxKeys <= 0) {
|
|
354
|
+
// this._log('error', C.ERROR_MAX_KEYS_TYPE);
|
|
355
|
+
// throw new TypeError(C.ERROR_MAX_KEYS_TYPE);
|
|
356
|
+
// }
|
|
357
|
+
// }
|
|
358
|
+
_checkOpts(opts) {
|
|
359
|
+
if (typeof opts !== 'object') {
|
|
360
|
+
this._log('error', `${ERROR_PREFIX}opts must be an object`);
|
|
361
|
+
throw new TypeError(`${ERROR_PREFIX}opts must be an object`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
_filterIfHeaders(opts) {
|
|
365
|
+
const filteredOpts = {};
|
|
366
|
+
const conditionalHeaders = {};
|
|
367
|
+
const ifHeaders = ['if-match', 'if-none-match', 'if-modified-since', 'if-unmodified-since'];
|
|
368
|
+
for (const [key, value] of Object.entries(opts)) {
|
|
369
|
+
if (ifHeaders.includes(key.toLowerCase())) {
|
|
370
|
+
// Convert to lowercase for consistency
|
|
371
|
+
conditionalHeaders[key] = value;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
filteredOpts[key] = value;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { filteredOpts, conditionalHeaders };
|
|
378
|
+
}
|
|
379
|
+
_validateUploadPartParams(key, uploadId, data, partNumber, opts) {
|
|
380
|
+
this._checkKey(key);
|
|
381
|
+
if (!(data instanceof Buffer || typeof data === 'string')) {
|
|
382
|
+
this._log('error', ERROR_DATA_BUFFER_REQUIRED);
|
|
383
|
+
throw new TypeError(ERROR_DATA_BUFFER_REQUIRED);
|
|
384
|
+
}
|
|
385
|
+
if (typeof uploadId !== 'string' || uploadId.trim().length === 0) {
|
|
386
|
+
this._log('error', ERROR_UPLOAD_ID_REQUIRED);
|
|
387
|
+
throw new TypeError(ERROR_UPLOAD_ID_REQUIRED);
|
|
388
|
+
}
|
|
389
|
+
if (!Number.isInteger(partNumber) || partNumber <= 0) {
|
|
390
|
+
this._log('error', `${ERROR_PREFIX}partNumber must be a positive integer`);
|
|
391
|
+
throw new TypeError(`${ERROR_PREFIX}partNumber must be a positive integer`);
|
|
392
|
+
}
|
|
393
|
+
this._checkOpts(opts);
|
|
394
|
+
}
|
|
395
|
+
_sign(method, keyPath, query = {}, headers = {}) {
|
|
396
|
+
// Create URL without appending keyPath first
|
|
397
|
+
const url = new URL(this.endpoint);
|
|
398
|
+
// Properly format the pathname to avoid double slashes
|
|
399
|
+
if (keyPath && keyPath.length > 0) {
|
|
400
|
+
url.pathname =
|
|
401
|
+
url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
|
|
402
|
+
}
|
|
403
|
+
headers[HEADER_AMZ_CONTENT_SHA256] = UNSIGNED_PAYLOAD; // body ? U.hash(body) : C.UNSIGNED_PAYLOAD;
|
|
404
|
+
headers[HEADER_AMZ_DATE] = this.fullDatetime;
|
|
405
|
+
headers[HEADER_HOST] = url.host;
|
|
406
|
+
const canonicalHeaders = this._buildCanonicalHeaders(headers);
|
|
407
|
+
const signedHeaders = Object.keys(headers)
|
|
408
|
+
.map(key => key.toLowerCase())
|
|
409
|
+
.sort()
|
|
410
|
+
.join(';');
|
|
411
|
+
const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders);
|
|
412
|
+
const stringToSign = this._buildStringToSign(canonicalRequest);
|
|
413
|
+
const signature = this._calculateSignature(stringToSign);
|
|
414
|
+
const authorizationHeader = this._buildAuthorizationHeader(signedHeaders, signature);
|
|
415
|
+
headers[HEADER_AUTHORIZATION] = authorizationHeader;
|
|
416
|
+
return { url: url.toString(), headers };
|
|
417
|
+
}
|
|
418
|
+
_buildCanonicalHeaders(headers) {
|
|
419
|
+
return Object.entries(headers)
|
|
420
|
+
.map(([key, value]) => `${key.toLowerCase()}:${String(value).trim()}`)
|
|
421
|
+
.sort()
|
|
422
|
+
.join('\n');
|
|
423
|
+
}
|
|
424
|
+
_buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders) {
|
|
425
|
+
return [
|
|
426
|
+
method,
|
|
427
|
+
url.pathname,
|
|
428
|
+
this._buildCanonicalQueryString(query),
|
|
429
|
+
`${canonicalHeaders}\n`,
|
|
430
|
+
signedHeaders,
|
|
431
|
+
UNSIGNED_PAYLOAD,
|
|
432
|
+
].join('\n');
|
|
433
|
+
}
|
|
434
|
+
_buildStringToSign(canonicalRequest) {
|
|
435
|
+
return [AWS_ALGORITHM, this.fullDatetime, this.credentialScope, hash(canonicalRequest)].join('\n');
|
|
436
|
+
}
|
|
437
|
+
_calculateSignature(stringToSign) {
|
|
438
|
+
return hmac(this.signingKey, stringToSign, 'hex');
|
|
439
|
+
}
|
|
440
|
+
_buildAuthorizationHeader(signedHeaders, signature) {
|
|
441
|
+
return [
|
|
442
|
+
`${AWS_ALGORITHM} Credential=${this.accessKeyId}/${this.credentialScope}`,
|
|
443
|
+
`SignedHeaders=${signedHeaders}`,
|
|
444
|
+
`Signature=${signature}`,
|
|
445
|
+
].join(', ');
|
|
446
|
+
}
|
|
447
|
+
async _signedRequest(method, // 'GET' | 'HEAD' | 'PUT' | 'POST' | 'DELETE'
|
|
448
|
+
key, // ‘’ allowed for bucket‑level ops
|
|
449
|
+
{ query = {}, // ?query=string
|
|
450
|
+
body = '', // string | Buffer | undefined
|
|
451
|
+
headers = {}, // extra/override headers
|
|
452
|
+
tolerated = [], // [200, 404] etc.
|
|
453
|
+
withQuery = false, // append query string to signed URL
|
|
454
|
+
} = {}) {
|
|
455
|
+
// Basic validation
|
|
456
|
+
if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
|
|
457
|
+
throw new Error(`${ERROR_PREFIX}Unsupported HTTP method ${method}`);
|
|
458
|
+
}
|
|
459
|
+
if (key) {
|
|
460
|
+
this._checkKey(key); // allow '' for bucket‑level
|
|
461
|
+
}
|
|
462
|
+
const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
|
|
463
|
+
? this._filterIfHeaders(query)
|
|
464
|
+
: { filteredOpts: query, conditionalHeaders: {} };
|
|
465
|
+
const baseHeaders = {
|
|
466
|
+
[HEADER_AMZ_CONTENT_SHA256]: UNSIGNED_PAYLOAD,
|
|
467
|
+
// ...(['GET', 'HEAD'].includes(method) ? { [C.HEADER_CONTENT_TYPE]: C.JSON_CONTENT_TYPE } : {}),
|
|
468
|
+
...headers,
|
|
469
|
+
...conditionalHeaders,
|
|
470
|
+
};
|
|
471
|
+
const encodedKey = key ? uriResourceEscape(key) : '';
|
|
472
|
+
const { url, headers: signedHeaders } = this._sign(method, encodedKey, filteredOpts, baseHeaders);
|
|
473
|
+
if (Object.keys(query).length > 0) {
|
|
474
|
+
withQuery = true; // append query string to signed URL
|
|
475
|
+
}
|
|
476
|
+
const filteredOptsStrings = Object.fromEntries(Object.entries(filteredOpts).map(([k, v]) => [k, String(v)]));
|
|
477
|
+
const finalUrl = withQuery && Object.keys(filteredOpts).length ? `${url}?${new URLSearchParams(filteredOptsStrings)}` : url;
|
|
478
|
+
const signedHeadersString = Object.fromEntries(Object.entries(signedHeaders).map(([k, v]) => [k, String(v)]));
|
|
479
|
+
return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
|
|
480
|
+
}
|
|
481
|
+
getProps() {
|
|
482
|
+
return {
|
|
483
|
+
accessKeyId: this.accessKeyId,
|
|
484
|
+
secretAccessKey: this.secretAccessKey,
|
|
485
|
+
endpoint: this.endpoint,
|
|
486
|
+
region: this.region,
|
|
487
|
+
requestSizeInBytes: this.requestSizeInBytes,
|
|
488
|
+
requestAbortTimeout: this.requestAbortTimeout,
|
|
489
|
+
logger: this.logger,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
setProps(props) {
|
|
493
|
+
this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
|
|
494
|
+
this.accessKeyId = props.accessKeyId;
|
|
495
|
+
this.secretAccessKey = props.secretAccessKey;
|
|
496
|
+
this.region = props.region || 'auto';
|
|
497
|
+
this.endpoint = props.endpoint;
|
|
498
|
+
this.requestSizeInBytes = props.requestSizeInBytes || DEFAULT_REQUEST_SIZE_IN_BYTES;
|
|
499
|
+
this.requestAbortTimeout = props.requestAbortTimeout;
|
|
500
|
+
this.logger = props.logger;
|
|
501
|
+
}
|
|
502
|
+
sanitizeETag(etag) {
|
|
503
|
+
return sanitizeETag(etag);
|
|
504
|
+
}
|
|
505
|
+
// TBD
|
|
506
|
+
async createBucket() {
|
|
507
|
+
const xmlBody = `
|
|
508
|
+
<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
509
|
+
<LocationConstraint>${this.region}</LocationConstraint>
|
|
510
|
+
</CreateBucketConfiguration>
|
|
511
|
+
`;
|
|
512
|
+
const headers = {
|
|
513
|
+
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
514
|
+
[HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
|
|
515
|
+
};
|
|
516
|
+
const res = await this._signedRequest('PUT', '', {
|
|
517
|
+
body: xmlBody,
|
|
518
|
+
headers,
|
|
519
|
+
tolerated: [200, 404, 403, 409], // don’t throw on 404/403 // 409 = bucket already exists
|
|
520
|
+
});
|
|
521
|
+
return res.status === 200;
|
|
522
|
+
}
|
|
523
|
+
async bucketExists() {
|
|
524
|
+
const res = await this._signedRequest('HEAD', '', { tolerated: [200, 404, 403] });
|
|
525
|
+
return res.status === 200;
|
|
526
|
+
}
|
|
527
|
+
async listObjects(delimiter = '/', prefix = '', maxKeys,
|
|
528
|
+
// method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
|
|
529
|
+
opts = {}) {
|
|
530
|
+
this._checkDelimiter(delimiter);
|
|
531
|
+
this._checkPrefix(prefix);
|
|
532
|
+
this._checkOpts(opts);
|
|
533
|
+
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
534
|
+
const unlimited = !(maxKeys && maxKeys > 0);
|
|
535
|
+
let remaining = unlimited ? Infinity : maxKeys;
|
|
536
|
+
let token;
|
|
537
|
+
const all = [];
|
|
538
|
+
do {
|
|
539
|
+
const batchSize = Math.min(remaining, 1000); // S3 ceiling
|
|
540
|
+
const query = {
|
|
541
|
+
'list-type': LIST_TYPE, // =2 for V2
|
|
542
|
+
'max-keys': String(batchSize),
|
|
543
|
+
...(prefix ? { prefix } : {}),
|
|
544
|
+
...(token ? { 'continuation-token': token } : {}),
|
|
545
|
+
...opts,
|
|
546
|
+
};
|
|
547
|
+
const res = await this._signedRequest('GET', keyPath, {
|
|
548
|
+
query,
|
|
549
|
+
withQuery: true,
|
|
550
|
+
tolerated: [200, 404],
|
|
551
|
+
});
|
|
552
|
+
if (res.status === 404) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
if (res.status !== 200) {
|
|
556
|
+
const errorBody = await res.text();
|
|
557
|
+
const errorCode = res.headers.get('x-amz-error-code') || 'Unknown';
|
|
558
|
+
const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
|
|
559
|
+
this._log('error', `${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
|
|
560
|
+
throw new Error(`${ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`);
|
|
561
|
+
}
|
|
562
|
+
const raw = parseXml(await res.text());
|
|
563
|
+
if (typeof raw !== 'object' || !raw || 'error' in raw) {
|
|
564
|
+
this._log('error', `${ERROR_PREFIX}Unexpected listObjects response shape: ${JSON.stringify(raw)}`);
|
|
565
|
+
throw new Error(`${ERROR_PREFIX}Unexpected listObjects response shape`);
|
|
566
|
+
}
|
|
567
|
+
const out = ('listBucketResult' in raw ? raw.listBucketResult : raw);
|
|
568
|
+
/* accumulate Contents */
|
|
569
|
+
const contents = out.contents;
|
|
570
|
+
if (contents) {
|
|
571
|
+
const batch = Array.isArray(contents) ? contents : [contents];
|
|
572
|
+
all.push(...batch);
|
|
573
|
+
if (!unlimited) {
|
|
574
|
+
remaining -= batch.length;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const truncated = out.isTruncated === 'true' || out.IsTruncated === 'true';
|
|
578
|
+
token = truncated
|
|
579
|
+
? (out.nextContinuationToken || out.NextContinuationToken || out.nextMarker || out.NextMarker)
|
|
580
|
+
: undefined;
|
|
581
|
+
} while (token && remaining > 0);
|
|
582
|
+
return all;
|
|
583
|
+
}
|
|
584
|
+
async listMultipartUploads(delimiter = '/', prefix = '', method = 'GET', opts = {}) {
|
|
585
|
+
this._checkDelimiter(delimiter);
|
|
586
|
+
this._checkPrefix(prefix);
|
|
587
|
+
this._validateMethodIsGetOrHead(method);
|
|
588
|
+
this._checkOpts(opts);
|
|
589
|
+
const query = { uploads: '', ...opts };
|
|
590
|
+
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
591
|
+
const res = await this._signedRequest(method, keyPath, {
|
|
592
|
+
query,
|
|
593
|
+
withQuery: true,
|
|
594
|
+
});
|
|
595
|
+
// doublecheck if this is needed
|
|
596
|
+
// if (method === 'HEAD') {
|
|
597
|
+
// return {
|
|
598
|
+
// size: +(res.headers.get(C.HEADER_CONTENT_LENGTH) ?? '0'),
|
|
599
|
+
// mtime: res.headers.get(C.HEADER_LAST_MODIFIED) ? new Date(res.headers.get(C.HEADER_LAST_MODIFIED)!) : undefined,
|
|
600
|
+
// etag: res.headers.get(C.HEADER_ETAG) ?? '',
|
|
601
|
+
// };
|
|
602
|
+
// }
|
|
603
|
+
const raw = parseXml(await res.text());
|
|
604
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
605
|
+
throw new Error(`${ERROR_PREFIX}Unexpected listMultipartUploads response shape`);
|
|
606
|
+
}
|
|
607
|
+
if ('listMultipartUploadsResult' in raw) {
|
|
608
|
+
return raw.listMultipartUploadsResult;
|
|
609
|
+
}
|
|
610
|
+
return raw;
|
|
611
|
+
}
|
|
612
|
+
async getObject(key, opts = {}) {
|
|
613
|
+
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
614
|
+
if ([404, 412, 304].includes(res.status)) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
return res.text();
|
|
618
|
+
}
|
|
619
|
+
async getObjectArrayBuffer(key, opts = {}) {
|
|
620
|
+
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
621
|
+
if ([404, 412, 304].includes(res.status)) {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
return res.arrayBuffer();
|
|
625
|
+
}
|
|
626
|
+
async getObjectWithETag(key, opts = {}) {
|
|
627
|
+
try {
|
|
628
|
+
const res = await this._signedRequest('GET', key, { query: opts, tolerated: [200, 404, 412, 304] });
|
|
629
|
+
if ([404, 412, 304].includes(res.status)) {
|
|
630
|
+
return { etag: null, data: null };
|
|
631
|
+
}
|
|
632
|
+
const etag = res.headers.get(HEADER_ETAG);
|
|
633
|
+
if (!etag) {
|
|
634
|
+
throw new Error('ETag not found in response headers');
|
|
635
|
+
}
|
|
636
|
+
return { etag: sanitizeETag(etag), data: await res.arrayBuffer() };
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
this._log('error', `Error getting object ${key} with ETag: ${String(err)}`);
|
|
640
|
+
throw err;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo = this.requestSizeInBytes, opts = {}) {
|
|
644
|
+
const rangeHdr = wholeFile ? {} : { range: `bytes=${rangeFrom}-${rangeTo - 1}` };
|
|
645
|
+
return this._signedRequest('GET', key, {
|
|
646
|
+
query: { ...opts },
|
|
647
|
+
headers: rangeHdr,
|
|
648
|
+
withQuery: true, // keep ?query=string behaviour
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
async getContentLength(key) {
|
|
652
|
+
const res = await this._signedRequest('HEAD', key);
|
|
653
|
+
const len = res.headers.get(HEADER_CONTENT_LENGTH);
|
|
654
|
+
return len ? +len : 0;
|
|
655
|
+
}
|
|
656
|
+
async objectExists(key, opts = {}) {
|
|
657
|
+
const res = await this._signedRequest('HEAD', key, {
|
|
658
|
+
query: opts,
|
|
659
|
+
tolerated: [200, 404, 412, 304],
|
|
660
|
+
});
|
|
661
|
+
if (res.status === 404) {
|
|
662
|
+
return false; // not found
|
|
663
|
+
}
|
|
664
|
+
if (res.status === 412 || res.status === 304) {
|
|
665
|
+
return null; // ETag mismatch
|
|
666
|
+
}
|
|
667
|
+
return true; // found (200)
|
|
668
|
+
}
|
|
669
|
+
async getEtag(key, opts = {}) {
|
|
670
|
+
const res = await this._signedRequest('HEAD', key, {
|
|
671
|
+
query: opts,
|
|
672
|
+
tolerated: [200, 404],
|
|
673
|
+
});
|
|
674
|
+
if (res.status === 404) {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
const etag = res.headers.get(HEADER_ETAG);
|
|
678
|
+
if (!etag) {
|
|
679
|
+
throw new Error('ETag not found in response headers');
|
|
680
|
+
}
|
|
681
|
+
return sanitizeETag(etag);
|
|
682
|
+
}
|
|
683
|
+
async putObject(key, data) {
|
|
684
|
+
if (!(data instanceof Buffer || typeof data === 'string')) {
|
|
685
|
+
throw new TypeError(ERROR_DATA_BUFFER_REQUIRED);
|
|
686
|
+
}
|
|
687
|
+
return this._signedRequest('PUT', key, {
|
|
688
|
+
body: data,
|
|
689
|
+
headers: { [HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length },
|
|
690
|
+
tolerated: [200],
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
async getMultipartUploadId(key, fileType = DEFAULT_STREAM_CONTENT_TYPE) {
|
|
694
|
+
this._checkKey(key);
|
|
695
|
+
if (typeof fileType !== 'string') {
|
|
696
|
+
throw new TypeError(`${ERROR_PREFIX}fileType must be a string`);
|
|
697
|
+
}
|
|
698
|
+
const query = { uploads: '' };
|
|
699
|
+
const headers = { [HEADER_CONTENT_TYPE]: fileType };
|
|
700
|
+
const res = await this._signedRequest('POST', key, {
|
|
701
|
+
query,
|
|
702
|
+
headers,
|
|
703
|
+
withQuery: true,
|
|
704
|
+
});
|
|
705
|
+
const parsed = parseXml(await res.text());
|
|
706
|
+
if (parsed &&
|
|
707
|
+
typeof parsed === 'object' &&
|
|
708
|
+
'initiateMultipartUploadResult' in parsed &&
|
|
709
|
+
parsed.initiateMultipartUploadResult &&
|
|
710
|
+
'uploadId' in parsed.initiateMultipartUploadResult) {
|
|
711
|
+
return parsed.initiateMultipartUploadResult.uploadId;
|
|
712
|
+
}
|
|
713
|
+
throw new Error(`${ERROR_PREFIX}Failed to create multipart upload: ${JSON.stringify(parsed)}`);
|
|
714
|
+
}
|
|
715
|
+
async uploadPart(key, uploadId, data, partNumber, opts = {}) {
|
|
716
|
+
this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
|
|
717
|
+
const query = { uploadId, partNumber, ...opts };
|
|
718
|
+
const res = await this._signedRequest('PUT', key, {
|
|
719
|
+
query,
|
|
720
|
+
body: data,
|
|
721
|
+
headers: { [HEADER_CONTENT_LENGTH]: typeof data === 'string' ? Buffer.byteLength(data) : data.length },
|
|
722
|
+
});
|
|
723
|
+
return { partNumber, etag: sanitizeETag(res.headers.get('etag') || '') };
|
|
724
|
+
}
|
|
725
|
+
async completeMultipartUpload(key, uploadId, parts) {
|
|
726
|
+
// …existing validation left untouched …
|
|
727
|
+
const query = { uploadId };
|
|
728
|
+
const xmlBody = this._buildCompleteMultipartUploadXml(parts);
|
|
729
|
+
const headers = {
|
|
730
|
+
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
731
|
+
[HEADER_CONTENT_LENGTH]: Buffer.byteLength(xmlBody).toString(),
|
|
732
|
+
};
|
|
733
|
+
const res = await this._signedRequest('POST', key, {
|
|
734
|
+
query,
|
|
735
|
+
body: xmlBody,
|
|
736
|
+
headers,
|
|
737
|
+
withQuery: true,
|
|
738
|
+
});
|
|
739
|
+
const parsed = parseXml(await res.text());
|
|
740
|
+
const result = parsed && typeof parsed === 'object' && 'completeMultipartUploadResult' in parsed
|
|
741
|
+
? parsed.completeMultipartUploadResult
|
|
742
|
+
: parsed;
|
|
743
|
+
if (!result || typeof result !== 'object') {
|
|
744
|
+
throw new Error(`${ERROR_PREFIX}Failed to complete multipart upload: ${JSON.stringify(parsed)}`);
|
|
745
|
+
}
|
|
746
|
+
if ('ETag' in result || 'eTag' in result) {
|
|
747
|
+
result.etag = this.sanitizeETag(result.eTag ?? result.ETag);
|
|
748
|
+
}
|
|
749
|
+
return result;
|
|
750
|
+
}
|
|
751
|
+
async abortMultipartUpload(key, uploadId) {
|
|
752
|
+
this._checkKey(key);
|
|
753
|
+
if (!uploadId) {
|
|
754
|
+
throw new TypeError(ERROR_UPLOAD_ID_REQUIRED);
|
|
755
|
+
}
|
|
756
|
+
const query = { uploadId };
|
|
757
|
+
const headers = { [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE };
|
|
758
|
+
const res = await this._signedRequest('DELETE', key, {
|
|
759
|
+
query,
|
|
760
|
+
headers,
|
|
761
|
+
withQuery: true,
|
|
762
|
+
});
|
|
763
|
+
const parsed = parseXml(await res.text());
|
|
764
|
+
if (parsed &&
|
|
765
|
+
'error' in parsed &&
|
|
766
|
+
typeof parsed.error === 'object' &&
|
|
767
|
+
parsed.error !== null &&
|
|
768
|
+
'message' in parsed.error) {
|
|
769
|
+
this._log('error', `${ERROR_PREFIX}Failed to abort multipart upload: ${String(parsed.error.message)}`);
|
|
770
|
+
throw new Error(`${ERROR_PREFIX}Failed to abort multipart upload: ${String(parsed.error.message)}`);
|
|
771
|
+
}
|
|
772
|
+
return { status: 'Aborted', key, uploadId, response: parsed };
|
|
773
|
+
}
|
|
774
|
+
_buildCompleteMultipartUploadXml(parts) {
|
|
775
|
+
return `
|
|
776
|
+
<CompleteMultipartUpload>
|
|
777
|
+
${parts
|
|
778
|
+
.map(part => `
|
|
779
|
+
<Part>
|
|
780
|
+
<PartNumber>${part.partNumber}</PartNumber>
|
|
781
|
+
<ETag>${part.etag}</ETag>
|
|
782
|
+
</Part>
|
|
783
|
+
`)
|
|
784
|
+
.join('')}
|
|
785
|
+
</CompleteMultipartUpload>
|
|
786
|
+
`;
|
|
787
|
+
}
|
|
788
|
+
async deleteObject(key) {
|
|
789
|
+
const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
|
|
790
|
+
return res.status === 200 || res.status === 204;
|
|
791
|
+
}
|
|
792
|
+
async _sendRequest(url, method, headers, body, toleratedStatusCodes = []) {
|
|
793
|
+
this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
|
|
794
|
+
try {
|
|
795
|
+
const res = await fetch(url, {
|
|
796
|
+
method,
|
|
797
|
+
headers,
|
|
798
|
+
keepalive: true,
|
|
799
|
+
body: ['GET', 'HEAD'].includes(method) ? undefined : body,
|
|
800
|
+
signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
|
|
801
|
+
});
|
|
802
|
+
this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
|
|
803
|
+
if (!res.ok && !toleratedStatusCodes.includes(res.status)) {
|
|
804
|
+
await this._handleErrorResponse(res);
|
|
805
|
+
}
|
|
806
|
+
return res;
|
|
807
|
+
}
|
|
808
|
+
catch (err) {
|
|
809
|
+
const code = extractErrCode(err);
|
|
810
|
+
if (code && ['ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'ECONNREFUSED'].includes(code)) {
|
|
811
|
+
throw new S3NetworkError(`S3 network error: ${code}`, code, err);
|
|
812
|
+
}
|
|
813
|
+
throw err;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async _handleErrorResponse(res) {
|
|
817
|
+
const errorBody = await res.text();
|
|
818
|
+
const svcCode = res.headers.get('x-amz-error-code') ?? 'Unknown';
|
|
819
|
+
const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
|
|
820
|
+
this._log('error', `${ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`);
|
|
821
|
+
throw new S3ServiceError(`S3 returned ${res.status} – ${svcCode}`, res.status, svcCode, errorBody);
|
|
822
|
+
}
|
|
823
|
+
_buildCanonicalQueryString(queryParams) {
|
|
824
|
+
if (!queryParams || Object.keys(queryParams).length === 0) {
|
|
825
|
+
return '';
|
|
826
|
+
}
|
|
827
|
+
return Object.keys(queryParams)
|
|
828
|
+
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
|
|
829
|
+
.sort()
|
|
830
|
+
.join('&');
|
|
831
|
+
}
|
|
832
|
+
_getSignatureKey(dateStamp) {
|
|
833
|
+
const kDate = hmac(`AWS4${this.secretAccessKey}`, dateStamp);
|
|
834
|
+
const kRegion = hmac(kDate, this.region);
|
|
835
|
+
const kService = hmac(kRegion, S3_SERVICE);
|
|
836
|
+
return hmac(kService, AWS_REQUEST_TYPE);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export { s3mini as default, runInBatches, s3mini, sanitizeETag };
|
|
841
|
+
//# sourceMappingURL=s3mini.js.map
|