s3mini 0.4.0 → 0.6.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 +12 -4
- package/dist/s3mini.d.ts +179 -51
- package/dist/s3mini.js +439 -229
- 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 +22 -19
- package/src/S3.ts +491 -256
- package/src/consts.ts +1 -1
- package/src/index.ts +2 -2
- package/src/types.ts +62 -13
- package/src/utils.ts +67 -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
|
|
@@ -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,82 +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
|
-
// sort headers alphabetically by key
|
|
264
|
-
const ignoredHeaders = ['authorization', 'content-length', 'content-type', 'user-agent'];
|
|
265
|
-
let headersForSigning = Object.fromEntries(
|
|
266
|
-
Object.entries(headers).filter(([key]) => !ignoredHeaders.includes(key.toLowerCase())),
|
|
267
|
-
);
|
|
268
275
|
|
|
269
|
-
|
|
270
|
-
Object.entries(headersForSigning).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)),
|
|
271
|
-
);
|
|
272
|
-
const canonicalHeaders = this._buildCanonicalHeaders(headersForSigning);
|
|
273
|
-
const signedHeaders = Object.keys(headersForSigning)
|
|
274
|
-
.map(key => key.toLowerCase())
|
|
275
|
-
.sort()
|
|
276
|
-
.join(';');
|
|
277
|
-
|
|
278
|
-
const canonicalRequest = this._buildCanonicalRequest(method, url, query, canonicalHeaders, signedHeaders);
|
|
279
|
-
const stringToSign = this._buildStringToSign(fullDatetime, credentialScope, canonicalRequest);
|
|
280
|
-
const signature = this._calculateSignature(shortDatetime, stringToSign);
|
|
281
|
-
const authorizationHeader = this._buildAuthorizationHeader(credentialScope, signedHeaders, signature);
|
|
282
|
-
headers[C.HEADER_AUTHORIZATION] = authorizationHeader;
|
|
283
|
-
return { url: url.toString(), headers };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
private _buildCanonicalHeaders(headers: Record<string, string | number>): string {
|
|
287
|
-
return Object.entries(headers)
|
|
288
|
-
.map(([key, value]) => `${key.toLowerCase()}:${String(value).trim()}`)
|
|
289
|
-
.join('\n');
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
private _buildCanonicalRequest(
|
|
293
|
-
method: IT.HttpMethod,
|
|
294
|
-
url: URL,
|
|
295
|
-
query: Record<string, unknown>,
|
|
296
|
-
canonicalHeaders: string,
|
|
297
|
-
signedHeaders: string,
|
|
298
|
-
): string {
|
|
299
|
-
const parts = [
|
|
300
|
-
method,
|
|
301
|
-
url.pathname,
|
|
302
|
-
this._buildCanonicalQueryString(query),
|
|
303
|
-
canonicalHeaders + '\n', // Canonical headers end with extra newline
|
|
304
|
-
signedHeaders,
|
|
305
|
-
C.UNSIGNED_PAYLOAD,
|
|
306
|
-
];
|
|
307
|
-
return parts.join('\n');
|
|
308
|
-
}
|
|
276
|
+
const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);
|
|
309
277
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private _buildStringToSign(fullDatetime: string, credentialScope: string, canonicalRequest: string): string {
|
|
315
|
-
return [C.AWS_ALGORITHM, fullDatetime, credentialScope, U.hash(canonicalRequest)].join('\n');
|
|
316
|
-
}
|
|
278
|
+
let canonicalHeaders = '';
|
|
279
|
+
let signedHeaders = '';
|
|
317
280
|
|
|
318
|
-
|
|
319
|
-
|
|
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))}`;
|
|
294
|
+
if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
|
|
320
295
|
this.signingKeyDate = shortDatetime;
|
|
321
|
-
this.signingKey = this._getSignatureKey(shortDatetime);
|
|
296
|
+
this.signingKey = await this._getSignatureKey(shortDatetime);
|
|
322
297
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
return [
|
|
328
|
-
`${C.AWS_ALGORITHM} Credential=${this.accessKeyId}/${credentialScope}`,
|
|
329
|
-
`SignedHeaders=${signedHeaders}`,
|
|
330
|
-
`Signature=${signature}`,
|
|
331
|
-
].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 };
|
|
332
302
|
}
|
|
333
303
|
|
|
334
304
|
private async _signedRequest(
|
|
@@ -336,22 +306,22 @@ class S3mini {
|
|
|
336
306
|
key: string, // ‘’ allowed for bucket‑level ops
|
|
337
307
|
{
|
|
338
308
|
query = {}, // ?query=string
|
|
339
|
-
body = '', //
|
|
309
|
+
body = '', // BodyInit | undefined
|
|
340
310
|
headers = {}, // extra/override headers
|
|
341
311
|
tolerated = [], // [200, 404] etc.
|
|
342
312
|
withQuery = false, // append query string to signed URL
|
|
343
313
|
}: {
|
|
344
|
-
query?: Record<string, unknown
|
|
345
|
-
body?:
|
|
346
|
-
headers?: Record<string, string | number | undefined> | IT.SSECHeaders |
|
|
347
|
-
tolerated?: number[]
|
|
348
|
-
withQuery?: boolean
|
|
314
|
+
query?: Record<string, unknown>;
|
|
315
|
+
body?: BodyInit;
|
|
316
|
+
headers?: Record<string, string | number | undefined> | IT.SSECHeaders | IT.AWSHeaders;
|
|
317
|
+
tolerated?: number[];
|
|
318
|
+
withQuery?: boolean;
|
|
349
319
|
} = {},
|
|
350
320
|
): Promise<Response> {
|
|
351
321
|
// Basic validation
|
|
352
|
-
if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
|
|
353
|
-
|
|
354
|
-
}
|
|
322
|
+
// if (!['GET', 'HEAD', 'PUT', 'POST', 'DELETE'].includes(method)) {
|
|
323
|
+
// throw new Error(`${C.ERROR_PREFIX}Unsupported HTTP method ${method as string}`);
|
|
324
|
+
// }
|
|
355
325
|
|
|
356
326
|
const { filteredOpts, conditionalHeaders } = ['GET', 'HEAD'].includes(method)
|
|
357
327
|
? this._filterIfHeaders(query)
|
|
@@ -364,7 +334,7 @@ class S3mini {
|
|
|
364
334
|
};
|
|
365
335
|
|
|
366
336
|
const encodedKey = key ? U.uriResourceEscape(key) : '';
|
|
367
|
-
const { url, headers: signedHeaders } = this._sign(method, encodedKey, filteredOpts, baseHeaders);
|
|
337
|
+
const { url, headers: signedHeaders } = await this._sign(method, encodedKey, filteredOpts, baseHeaders);
|
|
368
338
|
if (Object.keys(query).length > 0) {
|
|
369
339
|
withQuery = true; // append query string to signed URL
|
|
370
340
|
}
|
|
@@ -379,55 +349,6 @@ class S3mini {
|
|
|
379
349
|
return this._sendRequest(finalUrl, method, signedHeadersString, body, tolerated);
|
|
380
350
|
}
|
|
381
351
|
|
|
382
|
-
/**
|
|
383
|
-
* Gets the current configuration properties of the S3 instance.
|
|
384
|
-
* @returns {IT.S3Config} The current S3 configuration object containing all settings.
|
|
385
|
-
* @example
|
|
386
|
-
* const config = s3.getProps();
|
|
387
|
-
* console.log(config.endpoint); // 'https://s3.amazonaws.com/my-bucket'
|
|
388
|
-
*/
|
|
389
|
-
public getProps(): IT.S3Config {
|
|
390
|
-
return {
|
|
391
|
-
accessKeyId: this.accessKeyId,
|
|
392
|
-
secretAccessKey: this.secretAccessKey,
|
|
393
|
-
endpoint: this.endpoint,
|
|
394
|
-
region: this.region,
|
|
395
|
-
requestSizeInBytes: this.requestSizeInBytes,
|
|
396
|
-
requestAbortTimeout: this.requestAbortTimeout,
|
|
397
|
-
logger: this.logger,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Updates the configuration properties of the S3 instance.
|
|
403
|
-
* @param {IT.S3Config} props - The new configuration object.
|
|
404
|
-
* @param {string} props.accessKeyId - The access key ID for authentication.
|
|
405
|
-
* @param {string} props.secretAccessKey - The secret access key for authentication.
|
|
406
|
-
* @param {string} props.endpoint - The endpoint URL of the S3-compatible service.
|
|
407
|
-
* @param {string} [props.region='auto'] - The region of the S3 service.
|
|
408
|
-
* @param {number} [props.requestSizeInBytes=8388608] - The request size of a single request in bytes.
|
|
409
|
-
* @param {number} [props.requestAbortTimeout] - The timeout in milliseconds after which a request should be aborted.
|
|
410
|
-
* @param {IT.Logger} [props.logger] - A logger object with methods like info, warn, error.
|
|
411
|
-
* @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
|
|
412
|
-
* @example
|
|
413
|
-
* s3.setProps({
|
|
414
|
-
* accessKeyId: 'new-access-key',
|
|
415
|
-
* secretAccessKey: 'new-secret-key',
|
|
416
|
-
* endpoint: 'https://new-endpoint.com/my-bucket',
|
|
417
|
-
* region: 'us-west-2' // by default is auto
|
|
418
|
-
* });
|
|
419
|
-
*/
|
|
420
|
-
public setProps(props: IT.S3Config): void {
|
|
421
|
-
this._validateConstructorParams(props.accessKeyId, props.secretAccessKey, props.endpoint);
|
|
422
|
-
this.accessKeyId = props.accessKeyId;
|
|
423
|
-
this.secretAccessKey = props.secretAccessKey;
|
|
424
|
-
this.region = props.region || 'auto';
|
|
425
|
-
this.endpoint = props.endpoint;
|
|
426
|
-
this.requestSizeInBytes = props.requestSizeInBytes || C.DEFAULT_REQUEST_SIZE_IN_BYTES;
|
|
427
|
-
this.requestAbortTimeout = props.requestAbortTimeout;
|
|
428
|
-
this.logger = props.logger;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
352
|
/**
|
|
432
353
|
* Sanitizes an ETag value by removing surrounding quotes and whitespace.
|
|
433
354
|
* Still returns RFC compliant ETag. https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3
|
|
@@ -453,7 +374,7 @@ class S3mini {
|
|
|
453
374
|
`;
|
|
454
375
|
const headers = {
|
|
455
376
|
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
456
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
377
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
|
|
457
378
|
};
|
|
458
379
|
const res = await this._signedRequest('PUT', '', {
|
|
459
380
|
body: xmlBody,
|
|
@@ -463,6 +384,42 @@ class S3mini {
|
|
|
463
384
|
return res.status === 200;
|
|
464
385
|
}
|
|
465
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
|
+
|
|
466
423
|
/**
|
|
467
424
|
* Checks if a bucket exists.
|
|
468
425
|
* This method sends a request to check if the specified bucket exists in the S3-compatible service.
|
|
@@ -492,7 +449,6 @@ class S3mini {
|
|
|
492
449
|
delimiter: string = '/',
|
|
493
450
|
prefix: string = '',
|
|
494
451
|
maxKeys?: number,
|
|
495
|
-
// method: IT.HttpMethod = 'GET', // 'GET' or 'HEAD'
|
|
496
452
|
opts: Record<string, unknown> = {},
|
|
497
453
|
): Promise<IT.ListObject[] | null> {
|
|
498
454
|
this._checkDelimiter(delimiter);
|
|
@@ -500,69 +456,131 @@ class S3mini {
|
|
|
500
456
|
this._checkOpts(opts);
|
|
501
457
|
|
|
502
458
|
const keyPath = delimiter === '/' ? delimiter : U.uriEscape(delimiter);
|
|
503
|
-
|
|
504
459
|
const unlimited = !(maxKeys && maxKeys > 0);
|
|
505
460
|
let remaining = unlimited ? Infinity : maxKeys;
|
|
506
461
|
let token: string | undefined;
|
|
507
462
|
const all: IT.ListObject[] = [];
|
|
508
463
|
|
|
509
464
|
do {
|
|
510
|
-
const
|
|
511
|
-
const query: Record<string, unknown> = {
|
|
512
|
-
'list-type': C.LIST_TYPE, // =2 for V2
|
|
513
|
-
'max-keys': String(batchSize),
|
|
514
|
-
...(prefix ? { prefix } : {}),
|
|
515
|
-
...(token ? { 'continuation-token': token } : {}),
|
|
516
|
-
...opts,
|
|
517
|
-
};
|
|
465
|
+
const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
|
|
518
466
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
withQuery: true,
|
|
522
|
-
tolerated: [200, 404],
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
if (res.status === 404) {
|
|
526
|
-
return null;
|
|
527
|
-
}
|
|
528
|
-
if (res.status !== 200) {
|
|
529
|
-
const errorBody = await res.text();
|
|
530
|
-
const errorCode = res.headers.get('x-amz-error-code') || 'Unknown';
|
|
531
|
-
const errorMessage = res.headers.get('x-amz-error-message') || res.statusText;
|
|
532
|
-
this._log(
|
|
533
|
-
'error',
|
|
534
|
-
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
|
|
535
|
-
);
|
|
536
|
-
throw new Error(
|
|
537
|
-
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${errorCode} - ${errorMessage}, err body: ${errorBody}`,
|
|
538
|
-
);
|
|
467
|
+
if (batchResult === null) {
|
|
468
|
+
return null; // 404 - bucket not found
|
|
539
469
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const out = (raw.ListBucketResult || raw.listBucketResult || raw) as Record<string, unknown>;
|
|
546
|
-
/* accumulate Contents */
|
|
547
|
-
const contents = out.Contents || out.contents; // S3 v2 vs v1
|
|
548
|
-
if (contents) {
|
|
549
|
-
const batch = Array.isArray(contents) ? contents : [contents];
|
|
550
|
-
all.push(...(batch as IT.ListObject[]));
|
|
551
|
-
if (!unlimited) {
|
|
552
|
-
remaining -= batch.length;
|
|
553
|
-
}
|
|
470
|
+
|
|
471
|
+
all.push(...batchResult.objects);
|
|
472
|
+
|
|
473
|
+
if (!unlimited) {
|
|
474
|
+
remaining -= batchResult.objects.length;
|
|
554
475
|
}
|
|
555
|
-
|
|
556
|
-
token =
|
|
557
|
-
? ((out.NextContinuationToken || out.nextContinuationToken || out.NextMarker || out.nextMarker) as
|
|
558
|
-
| string
|
|
559
|
-
| undefined)
|
|
560
|
-
: undefined;
|
|
476
|
+
|
|
477
|
+
token = batchResult.continuationToken;
|
|
561
478
|
} while (token && remaining > 0);
|
|
562
479
|
|
|
563
480
|
return all;
|
|
564
481
|
}
|
|
565
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
|
+
|
|
566
584
|
/**
|
|
567
585
|
* Lists multipart uploads in the bucket.
|
|
568
586
|
* This method sends a request to list multipart uploads in the specified bucket.
|
|
@@ -856,6 +874,7 @@ class S3mini {
|
|
|
856
874
|
* @param {string | Buffer} data - The data to upload (string or Buffer).
|
|
857
875
|
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
858
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.
|
|
859
878
|
* @returns {Promise<Response>} A promise that resolves to the Response object from the upload request.
|
|
860
879
|
* @throws {TypeError} If data is not a string or Buffer.
|
|
861
880
|
* @example
|
|
@@ -868,18 +887,17 @@ class S3mini {
|
|
|
868
887
|
*/
|
|
869
888
|
public async putObject(
|
|
870
889
|
key: string,
|
|
871
|
-
data: string |
|
|
890
|
+
data: string | IT.MaybeBuffer,
|
|
872
891
|
fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
|
|
873
892
|
ssecHeaders?: IT.SSECHeaders,
|
|
893
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
874
894
|
): Promise<Response> {
|
|
875
|
-
if (!(data instanceof Buffer || typeof data === 'string')) {
|
|
876
|
-
throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
|
|
877
|
-
}
|
|
878
895
|
return this._signedRequest('PUT', key, {
|
|
879
|
-
body: data,
|
|
896
|
+
body: this._validateData(data),
|
|
880
897
|
headers: {
|
|
881
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
898
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(data),
|
|
882
899
|
[C.HEADER_CONTENT_TYPE]: fileType,
|
|
900
|
+
...additionalHeaders,
|
|
883
901
|
...ssecHeaders,
|
|
884
902
|
},
|
|
885
903
|
tolerated: [200],
|
|
@@ -917,16 +935,6 @@ class S3mini {
|
|
|
917
935
|
});
|
|
918
936
|
const parsed = U.parseXml(await res.text()) as Record<string, unknown>;
|
|
919
937
|
|
|
920
|
-
// if (
|
|
921
|
-
// parsed &&
|
|
922
|
-
// typeof parsed === 'object' &&
|
|
923
|
-
// 'initiateMultipartUploadResult' in parsed &&
|
|
924
|
-
// parsed.initiateMultipartUploadResult &&
|
|
925
|
-
// 'uploadId' in (parsed.initiateMultipartUploadResult as { uploadId: string })
|
|
926
|
-
// ) {
|
|
927
|
-
// return (parsed.initiateMultipartUploadResult as { uploadId: string }).uploadId;
|
|
928
|
-
// }
|
|
929
|
-
|
|
930
938
|
if (parsed && typeof parsed === 'object') {
|
|
931
939
|
// Check for both cases of InitiateMultipartUploadResult
|
|
932
940
|
const uploadResult =
|
|
@@ -968,19 +976,19 @@ class S3mini {
|
|
|
968
976
|
public async uploadPart(
|
|
969
977
|
key: string,
|
|
970
978
|
uploadId: string,
|
|
971
|
-
data:
|
|
979
|
+
data: IT.MaybeBuffer | string,
|
|
972
980
|
partNumber: number,
|
|
973
981
|
opts: Record<string, unknown> = {},
|
|
974
982
|
ssecHeaders?: IT.SSECHeaders,
|
|
975
983
|
): Promise<IT.UploadPart> {
|
|
976
|
-
this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
|
|
984
|
+
const body = this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
|
|
977
985
|
|
|
978
986
|
const query = { uploadId, partNumber, ...opts };
|
|
979
987
|
const res = await this._signedRequest('PUT', key, {
|
|
980
988
|
query,
|
|
981
|
-
body
|
|
989
|
+
body,
|
|
982
990
|
headers: {
|
|
983
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
991
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(data),
|
|
984
992
|
...ssecHeaders,
|
|
985
993
|
},
|
|
986
994
|
});
|
|
@@ -1015,7 +1023,7 @@ class S3mini {
|
|
|
1015
1023
|
const xmlBody = this._buildCompleteMultipartUploadXml(parts);
|
|
1016
1024
|
const headers = {
|
|
1017
1025
|
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1018
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
1026
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
|
|
1019
1027
|
};
|
|
1020
1028
|
|
|
1021
1029
|
const res = await this._signedRequest('POST', key, {
|
|
@@ -1038,7 +1046,7 @@ class S3mini {
|
|
|
1038
1046
|
if (etag && typeof etag === 'string') {
|
|
1039
1047
|
return {
|
|
1040
1048
|
...resultObj,
|
|
1041
|
-
etag:
|
|
1049
|
+
etag: U.sanitizeETag(etag),
|
|
1042
1050
|
} as IT.CompleteMultipartUploadResult;
|
|
1043
1051
|
}
|
|
1044
1052
|
|
|
@@ -1094,20 +1102,228 @@ class S3mini {
|
|
|
1094
1102
|
}
|
|
1095
1103
|
|
|
1096
1104
|
private _buildCompleteMultipartUploadXml(parts: Array<IT.UploadPart>): string {
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
+
};
|
|
1111
1327
|
}
|
|
1112
1328
|
|
|
1113
1329
|
/**
|
|
@@ -1122,13 +1338,14 @@ class S3mini {
|
|
|
1122
1338
|
}
|
|
1123
1339
|
|
|
1124
1340
|
private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
|
|
1125
|
-
const
|
|
1341
|
+
const objectsXml = keys.map(key => `<Object><Key>${U.escapeXml(key)}</Key></Object>`).join('');
|
|
1342
|
+
const xmlBody = '<Delete>' + objectsXml + '</Delete>';
|
|
1126
1343
|
const query = { delete: '' };
|
|
1127
|
-
const
|
|
1344
|
+
const sha256base64 = U.base64FromBuffer(await U.sha256(xmlBody));
|
|
1128
1345
|
const headers = {
|
|
1129
1346
|
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1130
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
1131
|
-
|
|
1347
|
+
[C.HEADER_CONTENT_LENGTH]: U.getByteSize(xmlBody),
|
|
1348
|
+
[C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1132
1349
|
};
|
|
1133
1350
|
|
|
1134
1351
|
const res = await this._signedRequest('POST', '', {
|
|
@@ -1215,7 +1432,7 @@ class S3mini {
|
|
|
1215
1432
|
url: string,
|
|
1216
1433
|
method: IT.HttpMethod,
|
|
1217
1434
|
headers: Record<string, string>,
|
|
1218
|
-
body?:
|
|
1435
|
+
body?: BodyInit,
|
|
1219
1436
|
toleratedStatusCodes: number[] = [],
|
|
1220
1437
|
): Promise<Response> {
|
|
1221
1438
|
this._log('info', `Sending ${method} request to ${url}`, `headers: ${JSON.stringify(headers)}`);
|
|
@@ -1223,13 +1440,14 @@ class S3mini {
|
|
|
1223
1440
|
const res = await fetch(url, {
|
|
1224
1441
|
method,
|
|
1225
1442
|
headers,
|
|
1226
|
-
body: ['GET', 'HEAD'].includes(method) ? undefined :
|
|
1443
|
+
body: ['GET', 'HEAD'].includes(method) ? undefined : body,
|
|
1227
1444
|
signal: this.requestAbortTimeout !== undefined ? AbortSignal.timeout(this.requestAbortTimeout) : undefined,
|
|
1228
1445
|
});
|
|
1229
1446
|
this._log('info', `Response status: ${res.status}, tolerated: ${toleratedStatusCodes.join(',')}`);
|
|
1230
|
-
if (
|
|
1231
|
-
|
|
1447
|
+
if (res.ok || toleratedStatusCodes.includes(res.status)) {
|
|
1448
|
+
return res;
|
|
1232
1449
|
}
|
|
1450
|
+
await this._handleErrorResponse(res);
|
|
1233
1451
|
return res;
|
|
1234
1452
|
} catch (err: unknown) {
|
|
1235
1453
|
const code = U.extractErrCode(err);
|
|
@@ -1240,10 +1458,32 @@ class S3mini {
|
|
|
1240
1458
|
}
|
|
1241
1459
|
}
|
|
1242
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
|
+
|
|
1243
1482
|
private async _handleErrorResponse(res: Response): Promise<void> {
|
|
1244
1483
|
const errorBody = await res.text();
|
|
1245
|
-
const
|
|
1246
|
-
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;
|
|
1247
1487
|
this._log(
|
|
1248
1488
|
'error',
|
|
1249
1489
|
`${C.ERROR_PREFIX}Request failed with status ${res.status}: ${svcCode} - ${errorMessage},err body: ${errorBody}`,
|
|
@@ -1257,21 +1497,16 @@ class S3mini {
|
|
|
1257
1497
|
}
|
|
1258
1498
|
return Object.keys(queryParams)
|
|
1259
1499
|
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] as string)}`)
|
|
1260
|
-
.sort()
|
|
1500
|
+
.sort((a, b) => a.localeCompare(b))
|
|
1261
1501
|
.join('&');
|
|
1262
1502
|
}
|
|
1263
|
-
private _getSignatureKey(dateStamp: string):
|
|
1264
|
-
const kDate = U.hmac(`AWS4${this.secretAccessKey}`, dateStamp)
|
|
1265
|
-
const kRegion = U.hmac(kDate, this.region)
|
|
1266
|
-
const kService = U.hmac(kRegion, C.S3_SERVICE)
|
|
1267
|
-
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);
|
|
1268
1508
|
}
|
|
1269
1509
|
}
|
|
1270
1510
|
|
|
1271
|
-
|
|
1272
|
-
* @deprecated Use `S3mini` instead.
|
|
1273
|
-
*/
|
|
1274
|
-
const s3mini = S3mini;
|
|
1275
|
-
|
|
1276
|
-
export { S3mini, s3mini };
|
|
1511
|
+
export { S3mini };
|
|
1277
1512
|
export default S3mini;
|