s3-test-server 0.0.0-stage → 1.0.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.
@@ -0,0 +1,1216 @@
1
+ 'use strict';
2
+
3
+ const { Readable } = require('stream');
4
+ const crypto = require('crypto');
5
+ const { S3Error } = require('../models/error');
6
+ const { parseXml, asArray } = require('../xml');
7
+ const { AwsChunkedDecoder } = require('../chunked');
8
+ const { checksumOf } = require('../checksums');
9
+ const {
10
+ send,
11
+ sendDoc,
12
+ sendXml,
13
+ quoteEtag,
14
+ stripEtagQuotes,
15
+ } = require('../models/response');
16
+ const {
17
+ readBody,
18
+ isoDate,
19
+ httpDate,
20
+ awsUriEncode,
21
+ DUMMY_OWNER,
22
+ } = require('../utils');
23
+ const { requireBucket, dummyAclXml, parseTaggingConfig } = require('./bucket');
24
+
25
+ const MIN_PART_SIZE = 5 * 1024 * 1024;
26
+ const CHECKSUM_ALGOS = ['CRC32', 'CRC32C', 'CRC64NVME', 'SHA1', 'SHA256'];
27
+ const DEFAULT_CONTENT_TYPE = 'binary/octet-stream';
28
+
29
+ // ---------------------------------------------------------------- helpers
30
+
31
+ function checksumHeaderName(algorithm) {
32
+ return `x-amz-checksum-${algorithm.toLowerCase()}`;
33
+ }
34
+
35
+ function extractChecksumRequest(headers) {
36
+ const declared = {};
37
+ for (const algorithm of CHECKSUM_ALGOS) {
38
+ const value = headers[checksumHeaderName(algorithm)];
39
+ if (value !== undefined) declared[algorithm] = value;
40
+ }
41
+ const trailerAlgos = String(headers['x-amz-trailer'] || '')
42
+ .split(',')
43
+ .map((name) => name.trim().toLowerCase())
44
+ .filter((name) => name.startsWith('x-amz-checksum-'))
45
+ .map((name) => name.slice('x-amz-checksum-'.length).toUpperCase())
46
+ .filter((algorithm) => CHECKSUM_ALGOS.includes(algorithm));
47
+ let sdkAlgo = headers['x-amz-sdk-checksum-algorithm'];
48
+ if (sdkAlgo) sdkAlgo = String(sdkAlgo).toUpperCase();
49
+ const algorithms = new Set([
50
+ ...Object.keys(declared),
51
+ ...trailerAlgos,
52
+ ...(sdkAlgo && CHECKSUM_ALGOS.includes(sdkAlgo) ? [sdkAlgo] : []),
53
+ ]);
54
+ return { declared, algorithms: [...algorithms] };
55
+ }
56
+
57
+ function getUploadBodyStream(req) {
58
+ const contentSha = String(req.headers['x-amz-content-sha256'] || '');
59
+ const contentEncoding = String(req.headers['content-encoding'] || '');
60
+ const isChunked =
61
+ contentSha.startsWith('STREAMING-') ||
62
+ contentEncoding
63
+ .split(',')
64
+ .map((token) => token.trim().toLowerCase())
65
+ .includes('aws-chunked');
66
+ if (!isChunked) return { stream: req, decoder: null };
67
+ const decoder = new AwsChunkedDecoder();
68
+ req.pipe(decoder);
69
+ req.on('error', (err) => decoder.destroy(err));
70
+ return { stream: decoder, decoder };
71
+ }
72
+
73
+ /**
74
+ * Validates integrity declarations (Content-MD5, x-amz-content-sha256,
75
+ * x-amz-checksum-*) against computed digests. Returns the checksum fields to
76
+ * store on the object/part metadata.
77
+ */
78
+ function validateDigests({ headers, auth, declared, trailers, digests }) {
79
+ const expected = { ...declared };
80
+ for (const algorithm of CHECKSUM_ALGOS) {
81
+ const trailerValue = trailers
82
+ ? trailers[checksumHeaderName(algorithm)]
83
+ : undefined;
84
+ if (trailerValue !== undefined) expected[algorithm] = trailerValue;
85
+ }
86
+
87
+ const contentMd5 = headers['content-md5'];
88
+ if (contentMd5) {
89
+ let decoded;
90
+ try {
91
+ decoded = Buffer.from(contentMd5, 'base64');
92
+ } catch {
93
+ decoded = Buffer.alloc(0);
94
+ }
95
+ if (decoded.length !== 16) {
96
+ throw new S3Error(
97
+ 'InvalidDigest',
98
+ 'The Content-MD5 you specified is not valid.',
99
+ {
100
+ 'Content-MD5': contentMd5,
101
+ }
102
+ );
103
+ }
104
+ if (decoded.toString('hex') !== digests.md5Hex) {
105
+ throw new S3Error(
106
+ 'BadDigest',
107
+ 'The Content-MD5 you specified did not match what we received.',
108
+ {
109
+ ExpectedDigest: contentMd5,
110
+ CalculatedDigest: digests.md5Base64,
111
+ }
112
+ );
113
+ }
114
+ }
115
+
116
+ const contentSha = auth && auth.contentSha256;
117
+ if (
118
+ contentSha &&
119
+ /^[0-9a-f]{64}$/.test(contentSha) &&
120
+ contentSha !== digests.sha256Hex
121
+ ) {
122
+ throw new S3Error(
123
+ 'XAmzContentSHA256Mismatch',
124
+ 'The provided x-amz-content-sha256 does not match what was computed.',
125
+ {
126
+ ClientComputedContentSHA256: contentSha,
127
+ S3ComputedContentSHA256: digests.sha256Hex,
128
+ }
129
+ );
130
+ }
131
+
132
+ for (const [algorithm, value] of Object.entries(expected)) {
133
+ const computed = digests.checksums[algorithm];
134
+ if (computed !== undefined && computed !== value) {
135
+ throw new S3Error(
136
+ 'BadDigest',
137
+ `The ${algorithm} you specified did not match the calculated checksum.`
138
+ );
139
+ }
140
+ }
141
+ return expected;
142
+ }
143
+
144
+ function metaFieldsFromHeaders(headers) {
145
+ const fields = {
146
+ contentType: headers['content-type'] || DEFAULT_CONTENT_TYPE,
147
+ userMetadata: {},
148
+ };
149
+ const simple = {
150
+ 'cache-control': 'cacheControl',
151
+ 'content-disposition': 'contentDisposition',
152
+ 'content-language': 'contentLanguage',
153
+ expires: 'expiresHeader',
154
+ 'x-amz-storage-class': 'storageClass',
155
+ 'x-amz-website-redirect-location': 'websiteRedirectLocation',
156
+ };
157
+ for (const [header, field] of Object.entries(simple)) {
158
+ if (headers[header] !== undefined) fields[field] = String(headers[header]);
159
+ }
160
+ const contentEncoding = String(headers['content-encoding'] || '')
161
+ .split(',')
162
+ .map((token) => token.trim())
163
+ .filter((token) => token && token.toLowerCase() !== 'aws-chunked')
164
+ .join(', ');
165
+ if (contentEncoding) fields.contentEncoding = contentEncoding;
166
+ for (const [name, value] of Object.entries(headers)) {
167
+ if (name.startsWith('x-amz-meta-')) {
168
+ fields.userMetadata[name.slice('x-amz-meta-'.length)] = Array.isArray(
169
+ value
170
+ )
171
+ ? value.join(',')
172
+ : String(value);
173
+ }
174
+ }
175
+ if (headers['x-amz-tagging']) {
176
+ const tags = {};
177
+ for (const [tagKey, tagValue] of new URLSearchParams(
178
+ String(headers['x-amz-tagging'])
179
+ )) {
180
+ tags[tagKey] = tagValue;
181
+ }
182
+ fields.tagging = tags;
183
+ }
184
+ return fields;
185
+ }
186
+
187
+ function checkWriteConditions(headers, existing) {
188
+ const ifNoneMatch = headers['if-none-match'];
189
+ if (ifNoneMatch !== undefined) {
190
+ if (ifNoneMatch === '*' && existing) {
191
+ throw new S3Error(
192
+ 'PreconditionFailed',
193
+ 'At least one of the pre-conditions you specified did not hold',
194
+ { Condition: 'If-None-Match' }
195
+ );
196
+ }
197
+ if (
198
+ ifNoneMatch !== '*' &&
199
+ existing &&
200
+ stripEtagQuotes(ifNoneMatch) === existing.etag
201
+ ) {
202
+ throw new S3Error(
203
+ 'PreconditionFailed',
204
+ 'At least one of the pre-conditions you specified did not hold',
205
+ { Condition: 'If-None-Match' }
206
+ );
207
+ }
208
+ }
209
+ const ifMatch = headers['if-match'];
210
+ if (ifMatch !== undefined) {
211
+ if (
212
+ !existing ||
213
+ (ifMatch !== '*' && stripEtagQuotes(ifMatch) !== existing.etag)
214
+ ) {
215
+ throw new S3Error(
216
+ 'PreconditionFailed',
217
+ 'At least one of the pre-conditions you specified did not hold',
218
+ { Condition: 'If-Match' }
219
+ );
220
+ }
221
+ }
222
+ }
223
+
224
+ function checkReadConditions(headers, meta) {
225
+ const lastModifiedSec = Math.floor(meta.lastModified / 1000) * 1000;
226
+ const ifMatch = headers['if-match'];
227
+ if (ifMatch !== undefined) {
228
+ const etags = String(ifMatch)
229
+ .split(',')
230
+ .map((value) => stripEtagQuotes(value.trim()));
231
+ if (!etags.includes('*') && !etags.includes(meta.etag)) {
232
+ throw new S3Error(
233
+ 'PreconditionFailed',
234
+ 'At least one of the pre-conditions you specified did not hold',
235
+ { Condition: 'If-Match' }
236
+ );
237
+ }
238
+ } else if (headers['if-unmodified-since'] !== undefined) {
239
+ const since = Date.parse(headers['if-unmodified-since']);
240
+ if (!Number.isNaN(since) && lastModifiedSec > since) {
241
+ throw new S3Error(
242
+ 'PreconditionFailed',
243
+ 'At least one of the pre-conditions you specified did not hold',
244
+ { Condition: 'If-Unmodified-Since' }
245
+ );
246
+ }
247
+ }
248
+ const ifNoneMatch = headers['if-none-match'];
249
+ if (ifNoneMatch !== undefined) {
250
+ const etags = String(ifNoneMatch)
251
+ .split(',')
252
+ .map((value) => stripEtagQuotes(value.trim()));
253
+ if (etags.includes('*') || etags.includes(meta.etag))
254
+ return { notModified: true };
255
+ } else if (headers['if-modified-since'] !== undefined) {
256
+ const since = Date.parse(headers['if-modified-since']);
257
+ if (!Number.isNaN(since) && lastModifiedSec <= since)
258
+ return { notModified: true };
259
+ }
260
+ return { notModified: false };
261
+ }
262
+
263
+ function parseRange(header, size) {
264
+ const match = /^bytes=(\d*)-(\d*)(?:,|$)/.exec(String(header || '').trim());
265
+ if (!match || (match[1] === '' && match[2] === '')) return null;
266
+ let start;
267
+ let end;
268
+ if (match[1] === '') {
269
+ const suffix = parseInt(match[2], 10);
270
+ if (suffix === 0) {
271
+ throw invalidRange(size);
272
+ }
273
+ start = Math.max(0, size - suffix);
274
+ end = size - 1;
275
+ } else {
276
+ start = parseInt(match[1], 10);
277
+ end =
278
+ match[2] === '' ? size - 1 : Math.min(parseInt(match[2], 10), size - 1);
279
+ }
280
+ if (start >= size || start > end) {
281
+ throw invalidRange(size);
282
+ }
283
+ return { start, end };
284
+ }
285
+
286
+ function invalidRange(size) {
287
+ return new S3Error('InvalidRange', 'The requested range is not satisfiable', {
288
+ RangeRequested: '',
289
+ ActualObjectSize: size,
290
+ });
291
+ }
292
+
293
+ async function requireObject(ctx, key = ctx.key) {
294
+ const meta = await ctx.store.getObject(ctx.bucket, key);
295
+ if (!meta) {
296
+ throw new S3Error('NoSuchKey', 'The specified key does not exist.', {
297
+ Key: key,
298
+ });
299
+ }
300
+ return meta;
301
+ }
302
+
303
+ function parseCopySource(ctx) {
304
+ const raw = String(ctx.req.headers['x-amz-copy-source'] || '');
305
+ const [pathPart, queryPart] = raw.split('?');
306
+ const trimmed = pathPart.replace(/^\//, '');
307
+ const slash = trimmed.indexOf('/');
308
+ if (slash <= 0 || slash === trimmed.length - 1) {
309
+ throw new S3Error(
310
+ 'InvalidArgument',
311
+ 'Copy Source must mention the source bucket and key: sourcebucket/sourcekey',
312
+ {
313
+ ArgumentName: 'x-amz-copy-source',
314
+ ArgumentValue: raw,
315
+ }
316
+ );
317
+ }
318
+ let bucket;
319
+ let key;
320
+ try {
321
+ bucket = decodeURIComponent(trimmed.slice(0, slash));
322
+ key = decodeURIComponent(trimmed.slice(slash + 1));
323
+ } catch {
324
+ throw new S3Error('InvalidURI', "Couldn't parse the specified URI.");
325
+ }
326
+ let versionId;
327
+ if (queryPart) {
328
+ versionId = new URLSearchParams(queryPart).get('versionId') || undefined;
329
+ }
330
+ return { bucket, key, versionId };
331
+ }
332
+
333
+ function checkCopyConditions(headers, sourceMeta) {
334
+ const ifMatch = headers['x-amz-copy-source-if-match'];
335
+ const ifNoneMatch = headers['x-amz-copy-source-if-none-match'];
336
+ const ifModified = headers['x-amz-copy-source-if-modified-since'];
337
+ const ifUnmodified = headers['x-amz-copy-source-if-unmodified-since'];
338
+ const fail = () => {
339
+ throw new S3Error(
340
+ 'PreconditionFailed',
341
+ 'At least one of the pre-conditions you specified did not hold'
342
+ );
343
+ };
344
+ if (ifMatch !== undefined && stripEtagQuotes(ifMatch) !== sourceMeta.etag)
345
+ fail();
346
+ if (
347
+ ifNoneMatch !== undefined &&
348
+ stripEtagQuotes(ifNoneMatch) === sourceMeta.etag
349
+ )
350
+ fail();
351
+ if (ifUnmodified !== undefined) {
352
+ const since = Date.parse(ifUnmodified);
353
+ if (!Number.isNaN(since) && sourceMeta.lastModified > since) fail();
354
+ }
355
+ if (ifModified !== undefined) {
356
+ const since = Date.parse(ifModified);
357
+ if (!Number.isNaN(since) && sourceMeta.lastModified <= since) fail();
358
+ }
359
+ }
360
+
361
+ function objectResponseHeaders(meta, { checksumMode } = {}) {
362
+ const headers = {
363
+ ETag: quoteEtag(meta.etag),
364
+ 'Last-Modified': httpDate(meta.lastModified),
365
+ 'Accept-Ranges': 'bytes',
366
+ 'Content-Type': meta.contentType || DEFAULT_CONTENT_TYPE,
367
+ 'x-amz-server-side-encryption': 'AES256',
368
+ };
369
+ if (meta.cacheControl) headers['Cache-Control'] = meta.cacheControl;
370
+ if (meta.contentDisposition)
371
+ headers['Content-Disposition'] = meta.contentDisposition;
372
+ if (meta.contentEncoding) headers['Content-Encoding'] = meta.contentEncoding;
373
+ if (meta.contentLanguage) headers['Content-Language'] = meta.contentLanguage;
374
+ if (meta.expiresHeader) headers.Expires = meta.expiresHeader;
375
+ if (meta.websiteRedirectLocation) {
376
+ headers['x-amz-website-redirect-location'] = meta.websiteRedirectLocation;
377
+ }
378
+ if (meta.storageClass && meta.storageClass !== 'STANDARD') {
379
+ headers['x-amz-storage-class'] = meta.storageClass;
380
+ }
381
+ for (const [name, value] of Object.entries(meta.userMetadata || {})) {
382
+ headers[`x-amz-meta-${name}`] = value;
383
+ }
384
+ const tagCount = Object.keys(meta.tagging || {}).length;
385
+ if (tagCount > 0) headers['x-amz-tagging-count'] = tagCount;
386
+ if (meta.partsCount) headers['x-amz-mp-parts-count'] = meta.partsCount;
387
+ if (checksumMode && meta.checksums) {
388
+ for (const [algorithm, value] of Object.entries(meta.checksums)) {
389
+ headers[checksumHeaderName(algorithm)] = value;
390
+ }
391
+ if (meta.checksumType) headers['x-amz-checksum-type'] = meta.checksumType;
392
+ }
393
+ return headers;
394
+ }
395
+
396
+ // ---------------------------------------------------------------- GET/HEAD
397
+
398
+ async function getOrHeadObject(ctx) {
399
+ const { req, res, query } = ctx;
400
+
401
+ if (query.has('uploadId')) {
402
+ await listParts(ctx);
403
+ return;
404
+ }
405
+ if (query.has('acl')) {
406
+ await requireBucket(ctx);
407
+ await requireObject(ctx);
408
+ sendXml(res, 200, dummyAclXml());
409
+ return;
410
+ }
411
+ if (query.has('tagging')) {
412
+ await requireBucket(ctx);
413
+ const meta = await requireObject(ctx);
414
+ sendDoc(res, 200, 'Tagging', {
415
+ TagSet: {
416
+ Tag: Object.entries(meta.tagging || {}).map(([Key, Value]) => ({
417
+ Key,
418
+ Value,
419
+ })),
420
+ },
421
+ });
422
+ return;
423
+ }
424
+ if (query.has('attributes')) {
425
+ await getObjectAttributes(ctx);
426
+ return;
427
+ }
428
+ if (query.has('torrent')) {
429
+ throw new S3Error(
430
+ 'NotImplemented',
431
+ 'A header or query you provided requested a function that is not implemented.'
432
+ );
433
+ }
434
+
435
+ await requireBucket(ctx);
436
+ const meta = await requireObject(ctx);
437
+
438
+ const { notModified } = checkReadConditions(req.headers, meta);
439
+ // Like S3, checksum headers are only returned for whole-object responses.
440
+ const checksumMode =
441
+ String(req.headers['x-amz-checksum-mode'] || '').toUpperCase() ===
442
+ 'ENABLED' && !req.headers.range;
443
+ const headers = objectResponseHeaders(meta, { checksumMode });
444
+
445
+ // response-* overrides (commonly used with presigned URLs)
446
+ const overrides = {
447
+ 'response-content-type': 'Content-Type',
448
+ 'response-content-language': 'Content-Language',
449
+ 'response-expires': 'Expires',
450
+ 'response-cache-control': 'Cache-Control',
451
+ 'response-content-disposition': 'Content-Disposition',
452
+ 'response-content-encoding': 'Content-Encoding',
453
+ };
454
+ for (const [param, header] of Object.entries(overrides)) {
455
+ const value = query.get(param);
456
+ if (value !== null) headers[header] = value;
457
+ }
458
+
459
+ if (notModified) {
460
+ delete headers['Content-Type'];
461
+ send(res, 304, headers);
462
+ return;
463
+ }
464
+
465
+ let range = null;
466
+ if (req.headers.range) {
467
+ range = parseRange(req.headers.range, meta.size);
468
+ }
469
+ const status = range ? 206 : 200;
470
+ const length = range ? range.end - range.start + 1 : meta.size;
471
+ if (range) {
472
+ headers['Content-Range'] = `bytes ${range.start}-${range.end}/${meta.size}`;
473
+ }
474
+ headers['Content-Length'] = length;
475
+
476
+ if (req.method === 'HEAD') {
477
+ res.writeHead(status, headers);
478
+ res.end();
479
+ return;
480
+ }
481
+ res.writeHead(status, headers);
482
+ if (length === 0) {
483
+ res.end();
484
+ return;
485
+ }
486
+ const stream = ctx.store.createObjectReadStream(ctx.bucket, ctx.key, range);
487
+ stream.on('error', () => {
488
+ res.destroy();
489
+ });
490
+ stream.pipe(res);
491
+ }
492
+
493
+ async function getObjectAttributes(ctx) {
494
+ await requireBucket(ctx);
495
+ const meta = await requireObject(ctx);
496
+ const requested = String(ctx.req.headers['x-amz-object-attributes'] || '')
497
+ .split(',')
498
+ .map((name) => name.trim())
499
+ .filter(Boolean);
500
+ if (requested.length === 0) {
501
+ throw new S3Error(
502
+ 'InvalidRequest',
503
+ 'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty'
504
+ );
505
+ }
506
+ const result = {};
507
+ if (requested.includes('ETag')) result.ETag = meta.etag;
508
+ if (requested.includes('Checksum') && meta.checksums) {
509
+ const checksum = {};
510
+ for (const [algorithm, value] of Object.entries(meta.checksums)) {
511
+ checksum[`Checksum${algorithm}`] = value;
512
+ }
513
+ if (meta.checksumType) checksum.ChecksumType = meta.checksumType;
514
+ result.Checksum = checksum;
515
+ }
516
+ if (requested.includes('ObjectParts') && meta.partsCount) {
517
+ // The XML element for TotalPartsCount is named PartsCount.
518
+ result.ObjectParts = { PartsCount: meta.partsCount };
519
+ }
520
+ if (requested.includes('StorageClass')) {
521
+ result.StorageClass = meta.storageClass || 'STANDARD';
522
+ }
523
+ if (requested.includes('ObjectSize')) result.ObjectSize = meta.size;
524
+ ctx.res.setHeader('Last-Modified', httpDate(meta.lastModified));
525
+ sendDoc(ctx.res, 200, 'GetObjectAttributesResponse', result);
526
+ }
527
+
528
+ async function listParts(ctx) {
529
+ await requireBucket(ctx);
530
+ const uploadId = ctx.query.get('uploadId');
531
+ const upload = await ctx.store.getUpload(ctx.bucket, uploadId);
532
+ if (!upload || upload.key !== ctx.key) {
533
+ throw new S3Error(
534
+ 'NoSuchUpload',
535
+ 'The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.',
536
+ {
537
+ UploadId: uploadId,
538
+ }
539
+ );
540
+ }
541
+ const marker = parseInt(ctx.query.get('part-number-marker') || '0', 10) || 0;
542
+ const maxParts = Math.min(
543
+ parseInt(ctx.query.get('max-parts') || '1000', 10) || 1000,
544
+ 1000
545
+ );
546
+ const allParts = (await ctx.store.listParts(ctx.bucket, uploadId)).filter(
547
+ (part) => part.partNumber > marker
548
+ );
549
+ const page = allParts.slice(0, maxParts);
550
+ const truncated = allParts.length > page.length;
551
+
552
+ sendDoc(ctx.res, 200, 'ListPartsResult', {
553
+ Bucket: ctx.bucket,
554
+ Key: ctx.key,
555
+ UploadId: uploadId,
556
+ PartNumberMarker: marker,
557
+ NextPartNumberMarker: truncated
558
+ ? page[page.length - 1].partNumber
559
+ : undefined,
560
+ MaxParts: maxParts,
561
+ IsTruncated: truncated,
562
+ Part: page.map((part) => ({
563
+ PartNumber: part.partNumber,
564
+ LastModified: isoDate(part.lastModified),
565
+ ETag: quoteEtag(part.etag),
566
+ Size: part.size,
567
+ ...checksumXmlFields(part.checksums),
568
+ })),
569
+ Initiator: DUMMY_OWNER,
570
+ Owner: DUMMY_OWNER,
571
+ StorageClass: upload.storageClass || 'STANDARD',
572
+ ChecksumAlgorithm: upload.checksumAlgorithm,
573
+ ChecksumType: upload.checksumType,
574
+ });
575
+ }
576
+
577
+ function checksumXmlFields(checksums) {
578
+ const fields = {};
579
+ for (const [algorithm, value] of Object.entries(checksums || {})) {
580
+ fields[`Checksum${algorithm}`] = value;
581
+ }
582
+ return fields;
583
+ }
584
+
585
+ // ---------------------------------------------------------------- PUT
586
+
587
+ async function putObject(ctx) {
588
+ const { req, res, query } = ctx;
589
+
590
+ if (query.has('acl')) {
591
+ await requireBucket(ctx);
592
+ await requireObject(ctx);
593
+ await readBody(req);
594
+ send(res, 200, {});
595
+ return;
596
+ }
597
+ if (query.has('tagging')) {
598
+ await requireBucket(ctx);
599
+ const meta = await requireObject(ctx);
600
+ const body = await readBody(req);
601
+ const config = parseTaggingConfig(body.toString('utf8'));
602
+ meta.tagging = config.tags;
603
+ await ctx.store.updateObjectMeta(ctx.bucket, ctx.key, meta);
604
+ send(res, 200, {});
605
+ return;
606
+ }
607
+ if (query.has('uploadId')) {
608
+ if (req.headers['x-amz-copy-source']) {
609
+ await uploadPartCopy(ctx);
610
+ } else {
611
+ await uploadPart(ctx);
612
+ }
613
+ return;
614
+ }
615
+ if (req.headers['x-amz-copy-source']) {
616
+ await copyObject(ctx);
617
+ return;
618
+ }
619
+
620
+ await requireBucket(ctx);
621
+ if (ctx.key.length > 1024) {
622
+ throw new S3Error('KeyTooLongError', 'Your key is too long', {
623
+ MaxSizeAllowed: 1024,
624
+ Size: ctx.key.length,
625
+ });
626
+ }
627
+ if (
628
+ req.headers['content-length'] === undefined &&
629
+ String(req.headers['transfer-encoding'] || '').toLowerCase() !== 'chunked'
630
+ ) {
631
+ throw new S3Error(
632
+ 'MissingContentLength',
633
+ 'You must provide the Content-Length HTTP header.'
634
+ );
635
+ }
636
+
637
+ const existing = await ctx.store.getObject(ctx.bucket, ctx.key);
638
+ checkWriteConditions(req.headers, existing);
639
+
640
+ const { declared, algorithms } = extractChecksumRequest(req.headers);
641
+ const computeAlgos = algorithms.length > 0 ? algorithms : ['CRC64NVME'];
642
+ const { stream, decoder } = getUploadBodyStream(req);
643
+ const staged = await ctx.store.stageObject(ctx.bucket, stream, computeAlgos);
644
+
645
+ let checksumFields;
646
+ try {
647
+ checksumFields = validateDigests({
648
+ headers: req.headers,
649
+ auth: ctx.auth,
650
+ declared,
651
+ trailers: decoder ? decoder.trailers : null,
652
+ digests: staged.digests,
653
+ });
654
+ } catch (err) {
655
+ await staged.abort();
656
+ throw err;
657
+ }
658
+
659
+ const fields = metaFieldsFromHeaders(req.headers);
660
+ fields.checksums = staged.digests.checksums;
661
+ fields.checksumAlgorithm =
662
+ Object.keys(checksumFields)[0] ||
663
+ (algorithms.length === 0 ? 'CRC64NVME' : algorithms[0]);
664
+ fields.checksumType = 'FULL_OBJECT';
665
+ const meta = await staged.commit(ctx.key, fields);
666
+
667
+ const headers = {
668
+ ETag: quoteEtag(meta.etag),
669
+ 'x-amz-server-side-encryption': 'AES256',
670
+ };
671
+ for (const [algorithm, value] of Object.entries(meta.checksums || {})) {
672
+ headers[checksumHeaderName(algorithm)] = value;
673
+ }
674
+ headers['x-amz-checksum-type'] = meta.checksumType;
675
+ ctx.emitEvent('ObjectCreated:Put', meta);
676
+ send(res, 200, headers);
677
+ }
678
+
679
+ async function copyObject(ctx) {
680
+ await requireBucket(ctx);
681
+ const source = parseCopySource(ctx);
682
+ if (source.versionId && source.versionId !== 'null') {
683
+ throw new S3Error(
684
+ 'NoSuchVersion',
685
+ 'The specified version does not exist.',
686
+ {
687
+ statusCode: 404,
688
+ VersionId: source.versionId,
689
+ }
690
+ );
691
+ }
692
+ const sourceBucket = await ctx.store.getBucket(source.bucket);
693
+ if (!sourceBucket) {
694
+ throw new S3Error('NoSuchBucket', 'The specified bucket does not exist', {
695
+ BucketName: source.bucket,
696
+ });
697
+ }
698
+ const sourceMeta = await ctx.store.getObject(source.bucket, source.key);
699
+ if (!sourceMeta) {
700
+ throw new S3Error('NoSuchKey', 'The specified key does not exist.', {
701
+ Key: source.key,
702
+ });
703
+ }
704
+ checkCopyConditions(ctx.req.headers, sourceMeta);
705
+
706
+ const metadataDirective = String(
707
+ ctx.req.headers['x-amz-metadata-directive'] || 'COPY'
708
+ ).toUpperCase();
709
+ const taggingDirective = String(
710
+ ctx.req.headers['x-amz-tagging-directive'] || 'COPY'
711
+ ).toUpperCase();
712
+ const sameTarget = source.bucket === ctx.bucket && source.key === ctx.key;
713
+ if (sameTarget && metadataDirective === 'COPY') {
714
+ throw new S3Error(
715
+ 'InvalidRequest',
716
+ "This copy request is illegal because it is trying to copy an object to itself without changing the object's metadata, storage class, website redirect location or encryption attributes."
717
+ );
718
+ }
719
+ const existing = await ctx.store.getObject(ctx.bucket, ctx.key);
720
+ checkWriteConditions(ctx.req.headers, existing);
721
+
722
+ const sourceStream = ctx.store.createObjectReadStream(
723
+ source.bucket,
724
+ source.key
725
+ );
726
+ const algorithms = sourceMeta.checksumAlgorithm
727
+ ? [sourceMeta.checksumAlgorithm]
728
+ : ['CRC64NVME'];
729
+ const staged = await ctx.store.stageObject(
730
+ ctx.bucket,
731
+ sourceStream,
732
+ algorithms
733
+ );
734
+
735
+ let fields;
736
+ if (metadataDirective === 'REPLACE') {
737
+ fields = metaFieldsFromHeaders(ctx.req.headers);
738
+ } else {
739
+ fields = {
740
+ contentType: sourceMeta.contentType,
741
+ cacheControl: sourceMeta.cacheControl,
742
+ contentDisposition: sourceMeta.contentDisposition,
743
+ contentEncoding: sourceMeta.contentEncoding,
744
+ contentLanguage: sourceMeta.contentLanguage,
745
+ expiresHeader: sourceMeta.expiresHeader,
746
+ storageClass:
747
+ ctx.req.headers['x-amz-storage-class'] || sourceMeta.storageClass,
748
+ websiteRedirectLocation: sourceMeta.websiteRedirectLocation,
749
+ userMetadata: { ...sourceMeta.userMetadata },
750
+ };
751
+ }
752
+ fields.tagging =
753
+ taggingDirective === 'REPLACE'
754
+ ? metaFieldsFromHeaders(ctx.req.headers).tagging || {}
755
+ : { ...sourceMeta.tagging };
756
+ fields.checksums = staged.digests.checksums;
757
+ fields.checksumAlgorithm = algorithms[0];
758
+ fields.checksumType = 'FULL_OBJECT';
759
+
760
+ const meta = await staged.commit(ctx.key, fields);
761
+ ctx.emitEvent('ObjectCreated:Copy', meta);
762
+ sendDoc(ctx.res, 200, 'CopyObjectResult', {
763
+ LastModified: isoDate(meta.lastModified),
764
+ ETag: quoteEtag(meta.etag),
765
+ ...checksumXmlFields(meta.checksums),
766
+ ChecksumType: meta.checksumType,
767
+ });
768
+ }
769
+
770
+ async function requireUpload(ctx) {
771
+ const uploadId = ctx.query.get('uploadId');
772
+ const upload = await ctx.store.getUpload(ctx.bucket, uploadId);
773
+ if (!upload || upload.key !== ctx.key) {
774
+ throw new S3Error(
775
+ 'NoSuchUpload',
776
+ 'The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.',
777
+ {
778
+ UploadId: uploadId,
779
+ }
780
+ );
781
+ }
782
+ return upload;
783
+ }
784
+
785
+ function parsePartNumber(ctx) {
786
+ const raw = ctx.query.get('partNumber');
787
+ const partNumber = parseInt(raw, 10);
788
+ if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > 10000) {
789
+ throw new S3Error(
790
+ 'InvalidArgument',
791
+ 'Part number must be an integer between 1 and 10000, inclusive',
792
+ {
793
+ ArgumentName: 'partNumber',
794
+ ArgumentValue: raw,
795
+ }
796
+ );
797
+ }
798
+ return partNumber;
799
+ }
800
+
801
+ async function uploadPart(ctx) {
802
+ await requireBucket(ctx);
803
+ const upload = await requireUpload(ctx);
804
+ const partNumber = parsePartNumber(ctx);
805
+
806
+ const { declared, algorithms } = extractChecksumRequest(ctx.req.headers);
807
+ const computeAlgos = new Set(algorithms);
808
+ if (upload.checksumAlgorithm) computeAlgos.add(upload.checksumAlgorithm);
809
+ const { stream, decoder } = getUploadBodyStream(ctx.req);
810
+ const staged = await ctx.store.putPart(
811
+ ctx.bucket,
812
+ upload.uploadId,
813
+ partNumber,
814
+ stream,
815
+ [...computeAlgos]
816
+ );
817
+
818
+ try {
819
+ validateDigests({
820
+ headers: ctx.req.headers,
821
+ auth: ctx.auth,
822
+ declared,
823
+ trailers: decoder ? decoder.trailers : null,
824
+ digests: staged.digests,
825
+ });
826
+ } catch (err) {
827
+ await staged.abort();
828
+ throw err;
829
+ }
830
+ const part = await staged.commit();
831
+
832
+ const headers = {
833
+ ETag: quoteEtag(part.etag),
834
+ 'x-amz-server-side-encryption': 'AES256',
835
+ };
836
+ for (const [algorithm, value] of Object.entries(part.checksums || {})) {
837
+ headers[checksumHeaderName(algorithm)] = value;
838
+ }
839
+ send(ctx.res, 200, headers);
840
+ }
841
+
842
+ async function uploadPartCopy(ctx) {
843
+ await requireBucket(ctx);
844
+ const upload = await requireUpload(ctx);
845
+ const partNumber = parsePartNumber(ctx);
846
+ const source = parseCopySource(ctx);
847
+ const sourceMeta = await ctx.store.getObject(source.bucket, source.key);
848
+ if (!sourceMeta) {
849
+ throw new S3Error('NoSuchKey', 'The specified key does not exist.', {
850
+ Key: source.key,
851
+ });
852
+ }
853
+ checkCopyConditions(ctx.req.headers, sourceMeta);
854
+
855
+ let range = null;
856
+ const rangeHeader = ctx.req.headers['x-amz-copy-source-range'];
857
+ if (rangeHeader) {
858
+ range = parseRange(rangeHeader, sourceMeta.size);
859
+ if (!range) {
860
+ throw new S3Error(
861
+ 'InvalidArgument',
862
+ 'The x-amz-copy-source-range value must be of the form bytes=first-last',
863
+ {
864
+ ArgumentName: 'x-amz-copy-source-range',
865
+ ArgumentValue: rangeHeader,
866
+ }
867
+ );
868
+ }
869
+ }
870
+ const sourceStream = ctx.store.createObjectReadStream(
871
+ source.bucket,
872
+ source.key,
873
+ range
874
+ );
875
+ const computeAlgos = upload.checksumAlgorithm
876
+ ? [upload.checksumAlgorithm]
877
+ : [];
878
+ const staged = await ctx.store.putPart(
879
+ ctx.bucket,
880
+ upload.uploadId,
881
+ partNumber,
882
+ sourceStream,
883
+ computeAlgos
884
+ );
885
+ const part = await staged.commit();
886
+ sendDoc(ctx.res, 200, 'CopyPartResult', {
887
+ LastModified: isoDate(part.lastModified),
888
+ ETag: quoteEtag(part.etag),
889
+ ...checksumXmlFields(part.checksums),
890
+ });
891
+ }
892
+
893
+ // ---------------------------------------------------------------- POST
894
+
895
+ async function postObject(ctx) {
896
+ const { query } = ctx;
897
+ if (query.has('uploads')) {
898
+ await createMultipartUpload(ctx);
899
+ return;
900
+ }
901
+ if (query.has('uploadId')) {
902
+ await completeMultipartUpload(ctx);
903
+ return;
904
+ }
905
+ throw new S3Error(
906
+ 'MethodNotAllowed',
907
+ 'The specified method is not allowed against this resource.',
908
+ {
909
+ Method: 'POST',
910
+ ResourceType: 'OBJECT',
911
+ }
912
+ );
913
+ }
914
+
915
+ async function createMultipartUpload(ctx) {
916
+ await requireBucket(ctx);
917
+ await readBody(ctx.req);
918
+ const fields = metaFieldsFromHeaders(ctx.req.headers);
919
+ let checksumAlgorithm = ctx.req.headers['x-amz-checksum-algorithm'];
920
+ if (checksumAlgorithm) {
921
+ checksumAlgorithm = String(checksumAlgorithm).toUpperCase();
922
+ if (!CHECKSUM_ALGOS.includes(checksumAlgorithm)) {
923
+ throw new S3Error(
924
+ 'InvalidRequest',
925
+ 'The algorithm type you specified in x-amz-checksum-algorithm header is invalid.'
926
+ );
927
+ }
928
+ fields.checksumAlgorithm = checksumAlgorithm;
929
+ let checksumType = ctx.req.headers['x-amz-checksum-type'];
930
+ if (checksumType) {
931
+ checksumType = String(checksumType).toUpperCase();
932
+ } else {
933
+ checksumType =
934
+ checksumAlgorithm === 'CRC64NVME' ? 'FULL_OBJECT' : 'COMPOSITE';
935
+ }
936
+ if (checksumAlgorithm === 'SHA1' || checksumAlgorithm === 'SHA256') {
937
+ checksumType = 'COMPOSITE';
938
+ }
939
+ fields.checksumType = checksumType;
940
+ }
941
+ const upload = await ctx.store.createUpload(ctx.bucket, ctx.key, fields);
942
+ const headers = {};
943
+ if (fields.checksumAlgorithm) {
944
+ headers['x-amz-checksum-algorithm'] = fields.checksumAlgorithm;
945
+ headers['x-amz-checksum-type'] = fields.checksumType;
946
+ }
947
+ for (const [name, value] of Object.entries(headers)) {
948
+ ctx.res.setHeader(name, value);
949
+ }
950
+ sendDoc(ctx.res, 200, 'InitiateMultipartUploadResult', {
951
+ Bucket: ctx.bucket,
952
+ Key: ctx.key,
953
+ UploadId: upload.uploadId,
954
+ });
955
+ }
956
+
957
+ async function completeMultipartUpload(ctx) {
958
+ await requireBucket(ctx);
959
+ const upload = await requireUpload(ctx);
960
+ const body = await readBody(ctx.req);
961
+ let requestedParts;
962
+ try {
963
+ const parsed = parseXml(body.toString('utf8'));
964
+ requestedParts = asArray(
965
+ parsed.CompleteMultipartUpload && parsed.CompleteMultipartUpload.Part
966
+ );
967
+ } catch {
968
+ throw new S3Error(
969
+ 'MalformedXML',
970
+ 'The XML you provided was not well-formed or did not validate against our published schema'
971
+ );
972
+ }
973
+ if (requestedParts.length === 0) {
974
+ throw new S3Error(
975
+ 'MalformedXML',
976
+ 'The XML you provided was not well-formed or did not validate against our published schema'
977
+ );
978
+ }
979
+
980
+ const existing = await ctx.store.getObject(ctx.bucket, ctx.key);
981
+ checkWriteConditions(ctx.req.headers, existing);
982
+
983
+ const storedParts = new Map(
984
+ (await ctx.store.listParts(ctx.bucket, upload.uploadId)).map((part) => [
985
+ part.partNumber,
986
+ part,
987
+ ])
988
+ );
989
+
990
+ let previousNumber = 0;
991
+ const parts = [];
992
+ for (const requested of requestedParts) {
993
+ const partNumber = parseInt(requested.PartNumber, 10);
994
+ if (!Number.isInteger(partNumber) || partNumber <= previousNumber) {
995
+ throw new S3Error(
996
+ 'InvalidPartOrder',
997
+ 'The list of parts was not in ascending order. Parts must be ordered by part number.',
998
+ {
999
+ UploadId: upload.uploadId,
1000
+ }
1001
+ );
1002
+ }
1003
+ previousNumber = partNumber;
1004
+ const stored = storedParts.get(partNumber);
1005
+ if (!stored || stripEtagQuotes(requested.ETag) !== stored.etag) {
1006
+ throw new S3Error(
1007
+ 'InvalidPart',
1008
+ "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.",
1009
+ {
1010
+ UploadId: upload.uploadId,
1011
+ PartNumber: partNumber,
1012
+ ETag: requested.ETag,
1013
+ }
1014
+ );
1015
+ }
1016
+ parts.push(stored);
1017
+ }
1018
+ for (let i = 0; i < parts.length - 1; i++) {
1019
+ if (parts[i].size < MIN_PART_SIZE) {
1020
+ throw new S3Error(
1021
+ 'EntityTooSmall',
1022
+ 'Your proposed upload is smaller than the minimum allowed size',
1023
+ {
1024
+ PartNumber: parts[i].partNumber,
1025
+ ProposedSize: parts[i].size,
1026
+ MinSizeAllowed: MIN_PART_SIZE,
1027
+ }
1028
+ );
1029
+ }
1030
+ }
1031
+
1032
+ // Concatenate the parts into the final object.
1033
+ const store = ctx.store;
1034
+ const bucket = ctx.bucket;
1035
+ const partStreams = async function* () {
1036
+ for (const part of parts) {
1037
+ yield* store.createPartReadStream(
1038
+ bucket,
1039
+ upload.uploadId,
1040
+ part.partNumber
1041
+ );
1042
+ }
1043
+ };
1044
+ const fullObjectAlgos = upload.checksumAlgorithm
1045
+ ? [upload.checksumAlgorithm]
1046
+ : ['CRC64NVME'];
1047
+ const staged = await ctx.store.stageObject(
1048
+ ctx.bucket,
1049
+ Readable.from(partStreams()),
1050
+ fullObjectAlgos
1051
+ );
1052
+
1053
+ // Multipart ETag: md5 of the concatenated binary part MD5s, suffixed -N.
1054
+ const md5Concat = crypto.createHash('md5');
1055
+ for (const part of parts) {
1056
+ md5Concat.update(Buffer.from(part.etag, 'hex'));
1057
+ }
1058
+ const etag = `${md5Concat.digest('hex')}-${parts.length}`;
1059
+
1060
+ // Resolve checksum: composite (checksum-of-checksums) or full-object.
1061
+ const checksums = {};
1062
+ let checksumType;
1063
+ if (upload.checksumAlgorithm) {
1064
+ checksumType = upload.checksumType || 'COMPOSITE';
1065
+ if (checksumType === 'COMPOSITE') {
1066
+ const concatenated = Buffer.concat(
1067
+ parts.map((part) =>
1068
+ Buffer.from(part.checksums[upload.checksumAlgorithm] || '', 'base64')
1069
+ )
1070
+ );
1071
+ checksums[upload.checksumAlgorithm] =
1072
+ `${checksumOf(upload.checksumAlgorithm, concatenated)}-${parts.length}`;
1073
+ } else {
1074
+ checksums[upload.checksumAlgorithm] =
1075
+ staged.digests.checksums[upload.checksumAlgorithm];
1076
+ }
1077
+ } else {
1078
+ checksumType = 'FULL_OBJECT';
1079
+ checksums.CRC64NVME = staged.digests.checksums.CRC64NVME;
1080
+ }
1081
+
1082
+ // Validate a client-provided full-object checksum, if any.
1083
+ const { declared } = extractChecksumRequest(ctx.req.headers);
1084
+ for (const [algorithm, value] of Object.entries(declared)) {
1085
+ const computed =
1086
+ checksumType === 'FULL_OBJECT'
1087
+ ? checksums[algorithm]
1088
+ : staged.digests.checksums[algorithm];
1089
+ if (
1090
+ computed !== undefined &&
1091
+ stripPartSuffix(computed) !== stripPartSuffix(value)
1092
+ ) {
1093
+ await staged.abort();
1094
+ throw new S3Error(
1095
+ 'BadDigest',
1096
+ `The ${algorithm} you specified did not match the calculated checksum.`
1097
+ );
1098
+ }
1099
+ }
1100
+
1101
+ const fields = {
1102
+ contentType: upload.contentType,
1103
+ cacheControl: upload.cacheControl,
1104
+ contentDisposition: upload.contentDisposition,
1105
+ contentEncoding: upload.contentEncoding,
1106
+ contentLanguage: upload.contentLanguage,
1107
+ expiresHeader: upload.expiresHeader,
1108
+ storageClass: upload.storageClass,
1109
+ websiteRedirectLocation: upload.websiteRedirectLocation,
1110
+ userMetadata: upload.userMetadata || {},
1111
+ tagging: upload.tagging || {},
1112
+ etag,
1113
+ checksums,
1114
+ checksumAlgorithm: upload.checksumAlgorithm || 'CRC64NVME',
1115
+ checksumType,
1116
+ partsCount: parts.length,
1117
+ };
1118
+ const meta = await staged.commit(ctx.key, fields);
1119
+ await ctx.store.removeUpload(ctx.bucket, upload.uploadId);
1120
+
1121
+ ctx.emitEvent('ObjectCreated:CompleteMultipartUpload', meta);
1122
+ const host = ctx.req.headers.host || 'localhost';
1123
+ sendDoc(ctx.res, 200, 'CompleteMultipartUploadResult', {
1124
+ Location: `http://${host}/${ctx.bucket}/${awsUriEncode(ctx.key, false)}`,
1125
+ Bucket: ctx.bucket,
1126
+ Key: ctx.key,
1127
+ ETag: quoteEtag(etag),
1128
+ ...checksumXmlFields(checksums),
1129
+ ChecksumType: checksumType,
1130
+ });
1131
+ }
1132
+
1133
+ function stripPartSuffix(value) {
1134
+ return String(value).replace(/-\d+$/, '');
1135
+ }
1136
+
1137
+ // ---------------------------------------------------------------- DELETE
1138
+
1139
+ async function deleteObjectRoute(ctx) {
1140
+ const { query, res } = ctx;
1141
+ if (query.has('uploadId')) {
1142
+ await requireBucket(ctx);
1143
+ await requireUpload(ctx);
1144
+ await ctx.store.removeUpload(ctx.bucket, query.get('uploadId'));
1145
+ send(res, 204, {});
1146
+ return;
1147
+ }
1148
+ if (query.has('tagging')) {
1149
+ await requireBucket(ctx);
1150
+ const meta = await requireObject(ctx);
1151
+ meta.tagging = {};
1152
+ await ctx.store.updateObjectMeta(ctx.bucket, ctx.key, meta);
1153
+ send(res, 204, {});
1154
+ return;
1155
+ }
1156
+ await requireBucket(ctx);
1157
+ const meta = await ctx.store.getObject(ctx.bucket, ctx.key);
1158
+ await ctx.store.deleteObject(ctx.bucket, ctx.key);
1159
+ if (meta) {
1160
+ ctx.emitEvent('ObjectRemoved:Delete', {
1161
+ key: ctx.key,
1162
+ size: 0,
1163
+ etag: undefined,
1164
+ });
1165
+ }
1166
+ send(res, 204, {});
1167
+ }
1168
+
1169
+ async function deleteObjects(ctx) {
1170
+ await requireBucket(ctx);
1171
+ const body = await readBody(ctx.req);
1172
+ let parsed;
1173
+ try {
1174
+ parsed = parseXml(body.toString('utf8'));
1175
+ } catch {
1176
+ throw new S3Error(
1177
+ 'MalformedXML',
1178
+ 'The XML you provided was not well-formed or did not validate against our published schema'
1179
+ );
1180
+ }
1181
+ const deleteRequest = parsed.Delete;
1182
+ if (!deleteRequest) {
1183
+ throw new S3Error(
1184
+ 'MalformedXML',
1185
+ 'The XML you provided was not well-formed or did not validate against our published schema'
1186
+ );
1187
+ }
1188
+ const quiet = deleteRequest.Quiet === 'true';
1189
+ const objects = asArray(deleteRequest.Object);
1190
+ if (objects.length === 0 || objects.length > 1000) {
1191
+ throw new S3Error(
1192
+ 'MalformedXML',
1193
+ 'The XML you provided was not well-formed or did not validate against our published schema'
1194
+ );
1195
+ }
1196
+ const deleted = [];
1197
+ for (const object of objects) {
1198
+ const key = typeof object.Key === 'string' ? object.Key : '';
1199
+ const existed = await ctx.store.deleteObject(ctx.bucket, key);
1200
+ if (existed) {
1201
+ ctx.emitEvent('ObjectRemoved:Delete', { key, size: 0, etag: undefined });
1202
+ }
1203
+ deleted.push({ Key: key });
1204
+ }
1205
+ sendDoc(ctx.res, 200, 'DeleteResult', {
1206
+ Deleted: quiet ? [] : deleted,
1207
+ });
1208
+ }
1209
+
1210
+ module.exports = {
1211
+ getOrHeadObject,
1212
+ putObject,
1213
+ postObject,
1214
+ deleteObjectRoute,
1215
+ deleteObjects,
1216
+ };