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.
- package/LICENSE +21 -0
- package/README.md +258 -2
- package/bin/s3-test-server.js +4 -0
- package/lib/checksums.js +176 -0
- package/lib/chunked.js +118 -0
- package/lib/cli.js +142 -0
- package/lib/controllers/bucket.js +624 -0
- package/lib/controllers/object.js +1216 -0
- package/lib/controllers/post.js +150 -0
- package/lib/controllers/service.js +19 -0
- package/lib/index.d.ts +193 -0
- package/lib/index.js +241 -0
- package/lib/middleware/cors.js +107 -0
- package/lib/middleware/error-handler.js +31 -0
- package/lib/middleware/vhost.js +61 -0
- package/lib/middleware/website.js +108 -0
- package/lib/models/error.js +79 -0
- package/lib/models/response.js +157 -0
- package/lib/routes.js +155 -0
- package/lib/signature/common.js +28 -0
- package/lib/signature/index.js +49 -0
- package/lib/signature/v2.js +129 -0
- package/lib/signature/v4.js +227 -0
- package/lib/stores/filesystem.js +391 -0
- package/lib/utils.js +115 -0
- package/lib/xml.js +162 -0
- package/package.json +46 -4
|
@@ -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
|
+
};
|