s3-test-server 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +231 -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,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { S3Error } = require('../models/error');
|
|
4
|
+
const { send } = require('../models/response');
|
|
5
|
+
const { escapeRegExp } = require('../utils');
|
|
6
|
+
|
|
7
|
+
function matchOrigin(rule, origin) {
|
|
8
|
+
return rule.allowedOrigins.some((allowed) => {
|
|
9
|
+
if (allowed === '*') return true;
|
|
10
|
+
if (allowed.includes('*')) {
|
|
11
|
+
const pattern = new RegExp(
|
|
12
|
+
`^${allowed.split('*').map(escapeRegExp).join('.*')}$`
|
|
13
|
+
);
|
|
14
|
+
return pattern.test(origin);
|
|
15
|
+
}
|
|
16
|
+
return allowed === origin;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findCorsRule(corsConfig, origin, method, requestHeaders = []) {
|
|
21
|
+
if (!corsConfig) return null;
|
|
22
|
+
for (const rule of corsConfig.rules) {
|
|
23
|
+
if (!matchOrigin(rule, origin)) continue;
|
|
24
|
+
if (!rule.allowedMethods.includes(method)) continue;
|
|
25
|
+
const allowAllHeaders = rule.allowedHeaders.includes('*');
|
|
26
|
+
const headersOk = requestHeaders.every(
|
|
27
|
+
(header) =>
|
|
28
|
+
allowAllHeaders ||
|
|
29
|
+
rule.allowedHeaders.some(
|
|
30
|
+
(allowed) =>
|
|
31
|
+
allowed === header ||
|
|
32
|
+
(allowed.endsWith('*') && header.startsWith(allowed.slice(0, -1)))
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
if (!headersOk) continue;
|
|
36
|
+
return rule;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Sets simple CORS response headers on non-preflight requests. */
|
|
42
|
+
function applyCorsHeaders(ctx, corsConfig) {
|
|
43
|
+
const origin = ctx.req.headers.origin;
|
|
44
|
+
if (!origin || !corsConfig) return;
|
|
45
|
+
const rule = findCorsRule(corsConfig, origin, ctx.req.method);
|
|
46
|
+
if (!rule) return;
|
|
47
|
+
const wildcard = rule.allowedOrigins.includes('*');
|
|
48
|
+
ctx.res.setHeader('Access-Control-Allow-Origin', wildcard ? '*' : origin);
|
|
49
|
+
if (!wildcard) {
|
|
50
|
+
ctx.res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
51
|
+
ctx.res.setHeader(
|
|
52
|
+
'Vary',
|
|
53
|
+
'Origin, Access-Control-Request-Headers, Access-Control-Request-Method'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (rule.exposeHeaders.length > 0) {
|
|
57
|
+
ctx.res.setHeader(
|
|
58
|
+
'Access-Control-Expose-Headers',
|
|
59
|
+
rule.exposeHeaders.join(', ')
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Answers an OPTIONS preflight against the bucket's CORS configuration. */
|
|
65
|
+
function handlePreflight(ctx, corsConfig) {
|
|
66
|
+
const origin = ctx.req.headers.origin;
|
|
67
|
+
const method = ctx.req.headers['access-control-request-method'];
|
|
68
|
+
if (!origin || !method) {
|
|
69
|
+
throw new S3Error(
|
|
70
|
+
'BadRequest',
|
|
71
|
+
'Insufficient information. Origin request header needed.',
|
|
72
|
+
{ statusCode: 400 }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const requestHeaders = String(
|
|
76
|
+
ctx.req.headers['access-control-request-headers'] || ''
|
|
77
|
+
)
|
|
78
|
+
.split(',')
|
|
79
|
+
.map((header) => header.trim().toLowerCase())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
const rule = findCorsRule(corsConfig, origin, method, requestHeaders);
|
|
82
|
+
if (!rule) {
|
|
83
|
+
throw new S3Error(
|
|
84
|
+
'AccessForbidden',
|
|
85
|
+
corsConfig
|
|
86
|
+
? "CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec."
|
|
87
|
+
: 'CORSResponse: CORS is not enabled for this bucket.',
|
|
88
|
+
{ statusCode: 403, Method: method, ResourceType: 'OBJECT' }
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const wildcard = rule.allowedOrigins.includes('*');
|
|
92
|
+
const headers = {
|
|
93
|
+
'Access-Control-Allow-Origin': wildcard ? '*' : origin,
|
|
94
|
+
'Access-Control-Allow-Methods': rule.allowedMethods.join(', '),
|
|
95
|
+
Vary: 'Origin, Access-Control-Request-Headers, Access-Control-Request-Method',
|
|
96
|
+
};
|
|
97
|
+
if (!wildcard) headers['Access-Control-Allow-Credentials'] = 'true';
|
|
98
|
+
if (requestHeaders.length > 0) {
|
|
99
|
+
headers['Access-Control-Allow-Headers'] = requestHeaders.join(', ');
|
|
100
|
+
}
|
|
101
|
+
if (rule.maxAgeSeconds !== undefined) {
|
|
102
|
+
headers['Access-Control-Max-Age'] = rule.maxAgeSeconds;
|
|
103
|
+
}
|
|
104
|
+
send(ctx.res, 200, headers);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { applyCorsHeaders, handlePreflight, findCorsRule };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { S3Error } = require('../models/error');
|
|
4
|
+
const { sendXml } = require('../models/response');
|
|
5
|
+
|
|
6
|
+
/** Renders any thrown error as an S3-style XML error response. */
|
|
7
|
+
function handleError(server, req, res, err) {
|
|
8
|
+
const s3Error =
|
|
9
|
+
err instanceof S3Error
|
|
10
|
+
? err
|
|
11
|
+
: new S3Error(
|
|
12
|
+
'InternalError',
|
|
13
|
+
'We encountered an internal error. Please try again.'
|
|
14
|
+
);
|
|
15
|
+
if (!(err instanceof S3Error)) {
|
|
16
|
+
server.logger.error(err.stack || String(err));
|
|
17
|
+
}
|
|
18
|
+
if (res.headersSent) {
|
|
19
|
+
res.destroy();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// Drain any unread request body so keep-alive connections stay usable.
|
|
23
|
+
req.resume();
|
|
24
|
+
sendXml(
|
|
25
|
+
res,
|
|
26
|
+
s3Error.statusCode,
|
|
27
|
+
s3Error.toXml({ requestId: req.requestId, resource: req.s3Resource })
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { handleError };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { escapeRegExp } = require('../utils');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the bucket name from the Host header for virtual-host-style
|
|
7
|
+
* requests (bucket.localhost, bucket.s3.<region>.<serviceEndpoint>, ...).
|
|
8
|
+
* Returns null for path-style requests.
|
|
9
|
+
*/
|
|
10
|
+
function resolveVhostBucket(req, options) {
|
|
11
|
+
if (!options.vhostBuckets) return null;
|
|
12
|
+
const host = String(req.headers.host || '')
|
|
13
|
+
.replace(/:\d+$/, '')
|
|
14
|
+
.toLowerCase();
|
|
15
|
+
if (
|
|
16
|
+
!host ||
|
|
17
|
+
host === 'localhost' ||
|
|
18
|
+
/^[\d.]+$/.test(host) ||
|
|
19
|
+
host === '[::1]'
|
|
20
|
+
) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (host === options.address || host === options.serviceEndpoint) return null;
|
|
24
|
+
|
|
25
|
+
const patterns = [
|
|
26
|
+
// bucket.s3.region.amazonaws.com / bucket.s3-region.amazonaws.com
|
|
27
|
+
new RegExp(
|
|
28
|
+
`^(.+)\\.s3[.-][a-z0-9-]+\\.${escapeRegExp(options.serviceEndpoint)}$`
|
|
29
|
+
),
|
|
30
|
+
// bucket.s3.amazonaws.com
|
|
31
|
+
new RegExp(`^(.+)\\.s3\\.${escapeRegExp(options.serviceEndpoint)}$`),
|
|
32
|
+
// bucket.amazonaws.com-style custom endpoints
|
|
33
|
+
new RegExp(`^(.+)\\.${escapeRegExp(options.serviceEndpoint)}$`),
|
|
34
|
+
// bucket.localhost
|
|
35
|
+
/^(.+)\.localhost$/,
|
|
36
|
+
];
|
|
37
|
+
if (
|
|
38
|
+
options.address &&
|
|
39
|
+
options.address !== 'localhost' &&
|
|
40
|
+
!/^[\d.]+$/.test(options.address)
|
|
41
|
+
) {
|
|
42
|
+
patterns.push(new RegExp(`^(.+)\\.${escapeRegExp(options.address)}$`));
|
|
43
|
+
}
|
|
44
|
+
for (const pattern of patterns) {
|
|
45
|
+
const match = pattern.exec(host);
|
|
46
|
+
if (match) {
|
|
47
|
+
const prefix = match[1];
|
|
48
|
+
if (
|
|
49
|
+
prefix === 's3' ||
|
|
50
|
+
prefix.startsWith('s3.') ||
|
|
51
|
+
prefix.startsWith('s3-')
|
|
52
|
+
) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return prefix;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { resolveVhostBucket };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { send } = require('../models/response');
|
|
4
|
+
const { escapeXml } = require('../xml');
|
|
5
|
+
|
|
6
|
+
// Query parameters that indicate an S3 API call rather than a website fetch.
|
|
7
|
+
const API_SUBRESOURCES = [
|
|
8
|
+
'acl',
|
|
9
|
+
'cors',
|
|
10
|
+
'website',
|
|
11
|
+
'tagging',
|
|
12
|
+
'location',
|
|
13
|
+
'versioning',
|
|
14
|
+
'versions',
|
|
15
|
+
'uploads',
|
|
16
|
+
'uploadId',
|
|
17
|
+
'delete',
|
|
18
|
+
'policy',
|
|
19
|
+
'lifecycle',
|
|
20
|
+
'encryption',
|
|
21
|
+
'object-lock',
|
|
22
|
+
'publicAccessBlock',
|
|
23
|
+
'notification',
|
|
24
|
+
'requestPayment',
|
|
25
|
+
'logging',
|
|
26
|
+
'accelerate',
|
|
27
|
+
'attributes',
|
|
28
|
+
'list-type',
|
|
29
|
+
'replication',
|
|
30
|
+
'torrent',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** True when the request should be served in static-website mode. */
|
|
34
|
+
function isWebsiteRequest(ctx, bucketMeta) {
|
|
35
|
+
return Boolean(
|
|
36
|
+
ctx.bucket &&
|
|
37
|
+
bucketMeta &&
|
|
38
|
+
bucketMeta.configs.website &&
|
|
39
|
+
(ctx.req.method === 'GET' || ctx.req.method === 'HEAD') &&
|
|
40
|
+
ctx.auth.mode === 'anonymous' &&
|
|
41
|
+
!API_SUBRESOURCES.some((subresource) => ctx.query.has(subresource))
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function serveWebsite(ctx, website) {
|
|
46
|
+
if (website.redirectAllRequestsTo) {
|
|
47
|
+
const target = `${website.redirectAllRequestsTo.protocol}://${website.redirectAllRequestsTo.hostName}/${ctx.key}`;
|
|
48
|
+
send(ctx.res, 301, { Location: target });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let key = ctx.key;
|
|
52
|
+
if (key === '' || key.endsWith('/')) {
|
|
53
|
+
key += website.indexDocument;
|
|
54
|
+
}
|
|
55
|
+
const meta = await ctx.store.getObject(ctx.bucket, key);
|
|
56
|
+
if (!meta && !ctx.key.endsWith('/') && ctx.key !== '') {
|
|
57
|
+
// folder-style redirect: /foo → /foo/ when foo/index.html exists
|
|
58
|
+
const indexKey = `${ctx.key}/${website.indexDocument}`;
|
|
59
|
+
if (await ctx.store.getObject(ctx.bucket, indexKey)) {
|
|
60
|
+
send(ctx.res, 302, { Location: `/${ctx.key}/` });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (meta && meta.websiteRedirectLocation) {
|
|
65
|
+
send(ctx.res, 301, { Location: meta.websiteRedirectLocation });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!meta) {
|
|
69
|
+
if (website.errorDocument) {
|
|
70
|
+
const errorMeta = await ctx.store.getObject(
|
|
71
|
+
ctx.bucket,
|
|
72
|
+
website.errorDocument
|
|
73
|
+
);
|
|
74
|
+
if (errorMeta) {
|
|
75
|
+
ctx.res.writeHead(404, {
|
|
76
|
+
'Content-Type': errorMeta.contentType || 'text/html',
|
|
77
|
+
'Content-Length': errorMeta.size,
|
|
78
|
+
});
|
|
79
|
+
if (ctx.req.method === 'HEAD') {
|
|
80
|
+
ctx.res.end();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
ctx.store
|
|
84
|
+
.createObjectReadStream(ctx.bucket, website.errorDocument)
|
|
85
|
+
.pipe(ctx.res);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const html =
|
|
90
|
+
'<!DOCTYPE html><html><head><title>404 Not Found</title></head>' +
|
|
91
|
+
`<body><h1>404 Not Found</h1><ul><li>Code: NoSuchKey</li><li>Message: The specified key does not exist.</li><li>Key: ${escapeXml(key)}</li></ul></body></html>`;
|
|
92
|
+
send(ctx.res, 404, { 'Content-Type': 'text/html' }, html);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
ctx.res.writeHead(200, {
|
|
96
|
+
'Content-Type': meta.contentType || 'text/html',
|
|
97
|
+
'Content-Length': meta.size,
|
|
98
|
+
ETag: `"${meta.etag}"`,
|
|
99
|
+
'Last-Modified': new Date(meta.lastModified).toUTCString(),
|
|
100
|
+
});
|
|
101
|
+
if (ctx.req.method === 'HEAD') {
|
|
102
|
+
ctx.res.end();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
ctx.store.createObjectReadStream(ctx.bucket, key).pipe(ctx.res);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { isWebsiteRequest, serveWebsite };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const STATUS_CODES = {
|
|
4
|
+
AccessDenied: 403,
|
|
5
|
+
AuthorizationHeaderMalformed: 400,
|
|
6
|
+
AuthorizationQueryParametersError: 400,
|
|
7
|
+
BadDigest: 400,
|
|
8
|
+
BucketAlreadyExists: 409,
|
|
9
|
+
BucketAlreadyOwnedByYou: 409,
|
|
10
|
+
BucketNotEmpty: 409,
|
|
11
|
+
EntityTooSmall: 400,
|
|
12
|
+
EntityTooLarge: 400,
|
|
13
|
+
ExpiredToken: 400,
|
|
14
|
+
IncompleteBody: 400,
|
|
15
|
+
InternalError: 500,
|
|
16
|
+
InvalidAccessKeyId: 403,
|
|
17
|
+
InvalidArgument: 400,
|
|
18
|
+
InvalidBucketName: 400,
|
|
19
|
+
InvalidDigest: 400,
|
|
20
|
+
InvalidPart: 400,
|
|
21
|
+
InvalidPartOrder: 400,
|
|
22
|
+
InvalidRange: 416,
|
|
23
|
+
InvalidRequest: 400,
|
|
24
|
+
InvalidToken: 400,
|
|
25
|
+
InvalidURI: 400,
|
|
26
|
+
KeyTooLongError: 400,
|
|
27
|
+
MalformedPOSTRequest: 400,
|
|
28
|
+
MalformedXML: 400,
|
|
29
|
+
MethodNotAllowed: 405,
|
|
30
|
+
MissingContentLength: 411,
|
|
31
|
+
MissingRequestBodyError: 400,
|
|
32
|
+
MissingSecurityHeader: 400,
|
|
33
|
+
NoSuchBucket: 404,
|
|
34
|
+
NoSuchBucketPolicy: 404,
|
|
35
|
+
NoSuchCORSConfiguration: 404,
|
|
36
|
+
NoSuchKey: 404,
|
|
37
|
+
NoSuchLifecycleConfiguration: 404,
|
|
38
|
+
NoSuchTagSet: 404,
|
|
39
|
+
NoSuchUpload: 404,
|
|
40
|
+
NoSuchWebsiteConfiguration: 404,
|
|
41
|
+
NotImplemented: 501,
|
|
42
|
+
PreconditionFailed: 412,
|
|
43
|
+
RequestTimeTooSkewed: 403,
|
|
44
|
+
SignatureDoesNotMatch: 403,
|
|
45
|
+
XAmzContentSHA256Mismatch: 400,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* An error that renders as an S3-style XML error response.
|
|
50
|
+
* `detail` entries become extra XML elements in the <Error> document
|
|
51
|
+
* (e.g. StringToSign for SignatureDoesNotMatch).
|
|
52
|
+
*/
|
|
53
|
+
class S3Error extends Error {
|
|
54
|
+
constructor(code, message, detail = {}) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'S3Error';
|
|
57
|
+
this.code = code;
|
|
58
|
+
this.statusCode = detail.statusCode || STATUS_CODES[code] || 500;
|
|
59
|
+
this.detail = { ...detail };
|
|
60
|
+
delete this.detail.statusCode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
toXml({ requestId, resource } = {}) {
|
|
64
|
+
const { escapeXml } = require('../xml');
|
|
65
|
+
let body = '<?xml version="1.0" encoding="UTF-8"?>\n<Error>';
|
|
66
|
+
body += `<Code>${escapeXml(this.code)}</Code>`;
|
|
67
|
+
body += `<Message>${escapeXml(this.message)}</Message>`;
|
|
68
|
+
for (const [key, value] of Object.entries(this.detail)) {
|
|
69
|
+
if (value === undefined || value === null) continue;
|
|
70
|
+
body += `<${key}>${escapeXml(String(value))}</${key}>`;
|
|
71
|
+
}
|
|
72
|
+
if (resource) body += `<Resource>${escapeXml(resource)}</Resource>`;
|
|
73
|
+
body += `<RequestId>${escapeXml(requestId || '')}</RequestId>`;
|
|
74
|
+
body += '</Error>';
|
|
75
|
+
return body;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { S3Error, STATUS_CODES };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { buildXml, S3_XMLNS } = require('../xml');
|
|
4
|
+
const { S3Error } = require('./error');
|
|
5
|
+
const { compareKeys, awsUriEncode, isoDate, DUMMY_OWNER } = require('../utils');
|
|
6
|
+
|
|
7
|
+
function send(res, status, headers, body) {
|
|
8
|
+
if (res.headersSent) {
|
|
9
|
+
res.end();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
for (const [name, value] of Object.entries(headers || {})) {
|
|
13
|
+
if (value !== undefined && value !== null) res.setHeader(name, value);
|
|
14
|
+
}
|
|
15
|
+
const isHead = res.req && res.req.method === 'HEAD';
|
|
16
|
+
if (body !== undefined && body !== null) {
|
|
17
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
18
|
+
res.setHeader('Content-Length', buf.length);
|
|
19
|
+
res.writeHead(status);
|
|
20
|
+
res.end(isHead ? undefined : buf);
|
|
21
|
+
} else {
|
|
22
|
+
res.writeHead(status);
|
|
23
|
+
res.end();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sendXml(res, status, xmlString) {
|
|
28
|
+
send(res, status, { 'Content-Type': 'application/xml' }, xmlString);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sendDoc(res, status, rootName, content, { xmlns = S3_XMLNS } = {}) {
|
|
32
|
+
sendXml(res, status, buildXml(rootName, content, { xmlns }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shared pagination for ListObjects / ListObjectsV2 / ListObjectVersions.
|
|
37
|
+
* `metas` must be sorted by key. Returns included object metas, grouped
|
|
38
|
+
* common prefixes and the last emitted item (for Next* markers).
|
|
39
|
+
*/
|
|
40
|
+
function paginate(
|
|
41
|
+
metas,
|
|
42
|
+
{ prefix = '', delimiter = '', marker = '', maxKeys = 1000 }
|
|
43
|
+
) {
|
|
44
|
+
const contents = [];
|
|
45
|
+
const prefixes = [];
|
|
46
|
+
const seenPrefixes = new Set();
|
|
47
|
+
let truncated = false;
|
|
48
|
+
let lastItem = null;
|
|
49
|
+
|
|
50
|
+
if (maxKeys === 0) {
|
|
51
|
+
return { contents, prefixes, truncated: false, lastItem: null };
|
|
52
|
+
}
|
|
53
|
+
for (const meta of metas) {
|
|
54
|
+
if (prefix && !meta.key.startsWith(prefix)) continue;
|
|
55
|
+
let item = meta.key;
|
|
56
|
+
let isPrefix = false;
|
|
57
|
+
if (delimiter) {
|
|
58
|
+
const rest = meta.key.slice(prefix.length);
|
|
59
|
+
const idx = rest.indexOf(delimiter);
|
|
60
|
+
if (idx !== -1) {
|
|
61
|
+
item = prefix + rest.slice(0, idx + delimiter.length);
|
|
62
|
+
isPrefix = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (marker && compareKeys(item, marker) <= 0) continue;
|
|
66
|
+
if (isPrefix && seenPrefixes.has(item)) continue;
|
|
67
|
+
if (contents.length + prefixes.length >= maxKeys) {
|
|
68
|
+
truncated = true;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
if (isPrefix) {
|
|
72
|
+
seenPrefixes.add(item);
|
|
73
|
+
prefixes.push(item);
|
|
74
|
+
} else {
|
|
75
|
+
contents.push(meta);
|
|
76
|
+
}
|
|
77
|
+
lastItem = item;
|
|
78
|
+
}
|
|
79
|
+
return { contents, prefixes, truncated, lastItem };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseListParams(query) {
|
|
83
|
+
const maxKeysRaw = query.get('max-keys');
|
|
84
|
+
let maxKeys = 1000;
|
|
85
|
+
if (maxKeysRaw !== null) {
|
|
86
|
+
maxKeys = parseInt(maxKeysRaw, 10);
|
|
87
|
+
if (Number.isNaN(maxKeys) || maxKeys < 0) {
|
|
88
|
+
throw new S3Error(
|
|
89
|
+
'InvalidArgument',
|
|
90
|
+
'Argument max-keys must be an integer between 0 and 2147483647',
|
|
91
|
+
{
|
|
92
|
+
ArgumentName: 'max-keys',
|
|
93
|
+
ArgumentValue: maxKeysRaw,
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
maxKeys = Math.min(maxKeys, 1000);
|
|
98
|
+
}
|
|
99
|
+
const encodingType = query.get('encoding-type') || undefined;
|
|
100
|
+
if (encodingType && encodingType !== 'url') {
|
|
101
|
+
throw new S3Error(
|
|
102
|
+
'InvalidArgument',
|
|
103
|
+
'Invalid Encoding Method specified in Request',
|
|
104
|
+
{
|
|
105
|
+
ArgumentName: 'encoding-type',
|
|
106
|
+
ArgumentValue: encodingType,
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
prefix: query.get('prefix') || '',
|
|
112
|
+
delimiter: query.get('delimiter') || '',
|
|
113
|
+
maxKeys,
|
|
114
|
+
encodingType,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function encodeListValue(value, encodingType) {
|
|
119
|
+
if (value === undefined || value === null || value === '') return value;
|
|
120
|
+
return encodingType === 'url' ? awsUriEncode(value, false) : value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function contentsEntry(meta, encodingType, { owner = true } = {}) {
|
|
124
|
+
const entry = {
|
|
125
|
+
Key: encodeListValue(meta.key, encodingType),
|
|
126
|
+
LastModified: isoDate(meta.lastModified),
|
|
127
|
+
ETag: `"${meta.etag}"`,
|
|
128
|
+
Size: meta.size,
|
|
129
|
+
StorageClass: meta.storageClass || 'STANDARD',
|
|
130
|
+
};
|
|
131
|
+
if (meta.checksumAlgorithm) {
|
|
132
|
+
entry.ChecksumAlgorithm = meta.checksumAlgorithm;
|
|
133
|
+
if (meta.checksumType) entry.ChecksumType = meta.checksumType;
|
|
134
|
+
}
|
|
135
|
+
if (owner) entry.Owner = DUMMY_OWNER;
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function quoteEtag(etag) {
|
|
140
|
+
return `"${etag}"`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stripEtagQuotes(value) {
|
|
144
|
+
return String(value || '').replace(/^(W\/)?"?|"?$/g, '');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
send,
|
|
149
|
+
sendXml,
|
|
150
|
+
sendDoc,
|
|
151
|
+
paginate,
|
|
152
|
+
parseListParams,
|
|
153
|
+
encodeListValue,
|
|
154
|
+
contentsEntry,
|
|
155
|
+
quoteEtag,
|
|
156
|
+
stripEtagQuotes,
|
|
157
|
+
};
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { S3Error } = require('./models/error');
|
|
4
|
+
const { authenticate } = require('./signature');
|
|
5
|
+
const { generateRequestId, generateId2 } = require('./utils');
|
|
6
|
+
const { resolveVhostBucket } = require('./middleware/vhost');
|
|
7
|
+
const { applyCorsHeaders, handlePreflight } = require('./middleware/cors');
|
|
8
|
+
const { isWebsiteRequest, serveWebsite } = require('./middleware/website');
|
|
9
|
+
const { handleError } = require('./middleware/error-handler');
|
|
10
|
+
const serviceController = require('./controllers/service');
|
|
11
|
+
const bucketController = require('./controllers/bucket');
|
|
12
|
+
const objectController = require('./controllers/object');
|
|
13
|
+
const { postFormUpload } = require('./controllers/post');
|
|
14
|
+
|
|
15
|
+
/** Builds the `(req, res)` handler that routes requests to the controllers. */
|
|
16
|
+
function createRequestHandler(server) {
|
|
17
|
+
return function requestHandler(req, res) {
|
|
18
|
+
handleRequest(server, req, res).catch((err) => {
|
|
19
|
+
handleError(server, req, res, err);
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function handleRequest(server, req, res) {
|
|
25
|
+
const { options, store, logger } = server;
|
|
26
|
+
req.requestId = generateRequestId();
|
|
27
|
+
res.setHeader('x-amz-request-id', req.requestId);
|
|
28
|
+
res.setHeader('x-amz-id-2', generateId2());
|
|
29
|
+
|
|
30
|
+
res.on('finish', () => {
|
|
31
|
+
logger.info(`${req.method} ${req.url} ${res.statusCode}`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const queryIndex = req.url.indexOf('?');
|
|
35
|
+
const rawPath = queryIndex === -1 ? req.url : req.url.slice(0, queryIndex);
|
|
36
|
+
const rawQuery = queryIndex === -1 ? '' : req.url.slice(queryIndex + 1);
|
|
37
|
+
const query = new URLSearchParams(rawQuery);
|
|
38
|
+
|
|
39
|
+
const vhostBucket = resolveVhostBucket(req, options);
|
|
40
|
+
let bucket = vhostBucket;
|
|
41
|
+
let key = '';
|
|
42
|
+
try {
|
|
43
|
+
if (vhostBucket) {
|
|
44
|
+
key = decodeURIComponent(rawPath.slice(1));
|
|
45
|
+
} else {
|
|
46
|
+
const trimmed = rawPath.replace(/^\//, '');
|
|
47
|
+
const slash = trimmed.indexOf('/');
|
|
48
|
+
if (slash === -1) {
|
|
49
|
+
bucket = decodeURIComponent(trimmed) || null;
|
|
50
|
+
} else {
|
|
51
|
+
bucket = decodeURIComponent(trimmed.slice(0, slash));
|
|
52
|
+
key = decodeURIComponent(trimmed.slice(slash + 1));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
throw new S3Error('InvalidURI', "Couldn't parse the specified URI.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ctx = {
|
|
60
|
+
req,
|
|
61
|
+
res,
|
|
62
|
+
store,
|
|
63
|
+
options,
|
|
64
|
+
logger,
|
|
65
|
+
query,
|
|
66
|
+
rawPath,
|
|
67
|
+
bucket,
|
|
68
|
+
key,
|
|
69
|
+
auth: null,
|
|
70
|
+
requestId: req.requestId,
|
|
71
|
+
emitEvent: (eventName, objectMeta) =>
|
|
72
|
+
server._emitEvent(eventName, bucket, objectMeta),
|
|
73
|
+
};
|
|
74
|
+
req.s3Resource = bucket ? `/${bucket}${key ? `/${key}` : ''}` : '/';
|
|
75
|
+
|
|
76
|
+
// CORS
|
|
77
|
+
const bucketMeta = bucket ? await store.getBucket(bucket) : null;
|
|
78
|
+
const corsConfig = bucketMeta && bucketMeta.configs.cors;
|
|
79
|
+
if (req.method === 'OPTIONS') {
|
|
80
|
+
handlePreflight(ctx, corsConfig);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
applyCorsHeaders(ctx, corsConfig);
|
|
84
|
+
|
|
85
|
+
// Authentication (anonymous requests are allowed, like public buckets)
|
|
86
|
+
ctx.auth = authenticate({ req, rawPath, query, vhostBucket, options });
|
|
87
|
+
|
|
88
|
+
// Static website hosting
|
|
89
|
+
if (isWebsiteRequest(ctx, bucketMeta)) {
|
|
90
|
+
await serveWebsite(ctx, bucketMeta.configs.website);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!bucket) {
|
|
95
|
+
if (req.method === 'GET') {
|
|
96
|
+
await serviceController.listBuckets(ctx);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
throw methodNotAllowed(req.method, 'SERVICE');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!key) {
|
|
103
|
+
switch (req.method) {
|
|
104
|
+
case 'GET':
|
|
105
|
+
await bucketController.getBucket(ctx);
|
|
106
|
+
return;
|
|
107
|
+
case 'PUT':
|
|
108
|
+
await bucketController.putBucket(ctx);
|
|
109
|
+
return;
|
|
110
|
+
case 'DELETE':
|
|
111
|
+
await bucketController.deleteBucket(ctx);
|
|
112
|
+
return;
|
|
113
|
+
case 'HEAD':
|
|
114
|
+
await bucketController.headBucket(ctx);
|
|
115
|
+
return;
|
|
116
|
+
case 'POST':
|
|
117
|
+
if (query.has('delete')) {
|
|
118
|
+
await objectController.deleteObjects(ctx);
|
|
119
|
+
} else {
|
|
120
|
+
await postFormUpload(ctx);
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
default:
|
|
124
|
+
throw methodNotAllowed(req.method, 'BUCKET');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
switch (req.method) {
|
|
129
|
+
case 'GET':
|
|
130
|
+
case 'HEAD':
|
|
131
|
+
await objectController.getOrHeadObject(ctx);
|
|
132
|
+
return;
|
|
133
|
+
case 'PUT':
|
|
134
|
+
await objectController.putObject(ctx);
|
|
135
|
+
return;
|
|
136
|
+
case 'POST':
|
|
137
|
+
await objectController.postObject(ctx);
|
|
138
|
+
return;
|
|
139
|
+
case 'DELETE':
|
|
140
|
+
await objectController.deleteObjectRoute(ctx);
|
|
141
|
+
return;
|
|
142
|
+
default:
|
|
143
|
+
throw methodNotAllowed(req.method, 'OBJECT');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function methodNotAllowed(method, resourceType) {
|
|
148
|
+
return new S3Error(
|
|
149
|
+
'MethodNotAllowed',
|
|
150
|
+
'The specified method is not allowed against this resource.',
|
|
151
|
+
{ Method: method, ResourceType: resourceType }
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { createRequestHandler };
|