Compare commits

...

1 Commits

Author SHA1 Message Date
Electra Chong 88099c35fb ft: extract s3 routes 2017-06-12 13:17:37 -07:00
10 changed files with 1498 additions and 0 deletions

View File

@ -49,6 +49,10 @@ module.exports = {
RESTClient: require('./lib/network/rest/RESTClient'),
},
},
s3routes: {
routes: require('./lib/s3routes/routes'),
routesUtils: require('./lib/s3routes/routesUtils'),
},
storage: {
metadata: {
MetadataFileServer:

141
lib/s3routes/routes.js Normal file
View File

@ -0,0 +1,141 @@
const errors = require('../errors');
const routeGET = require('./routes/routeGET');
const routePUT = require('./routes/routePUT');
const routeDELETE = require('./routes/routeDELETE');
const routeHEAD = require('./routes/routeHEAD');
const routePOST = require('./routes/routePOST');
const routeOPTIONS = require('./routes/routeOPTIONS');
const routesUtils = require('./routesUtils');
const routeWebsite = require('./routes/routeWebsite');
const routeMap = {
GET: routeGET,
PUT: routePUT,
POST: routePOST,
DELETE: routeDELETE,
HEAD: routeHEAD,
OPTIONS: routeOPTIONS,
};
function checkUnsupportedRoutes(reqMethod, reqQuery, unsupportedQueries, log) {
const method = routeMap[reqMethod];
if (!method) {
return { error: errors.MethodNotAllowed };
}
if (routesUtils.isUnsupportedQuery(reqQuery, unsupportedQueries)) {
log.debug('encountered unsupported query');
return { error: errors.NotImplemented };
}
return { method };
}
function checkBucketAndKey(bucketName, objectKey, method, reqQuery,
isValidBucketName, log) {
// if empty name and request not a list Buckets
if (!bucketName && !(method === 'GET' && !objectKey)) {
console.log('bucketName:', bucketName);
console.log('method:', method)
console.log('objectKey:', objectKey)
log.debug('empty bucket name', { method: 'routes' });
return (method !== 'OPTIONS') ?
errors.MethodNotAllowed : errors.AccessForbidden
.customizeDescription('CORSResponse: Bucket not found');
}
if (bucketName !== undefined &&
isValidBucketName(bucketName) === false) {
log.debug('invalid bucket name', { bucketName });
return errors.InvalidBucketName;
}
if ((reqQuery.partNumber || reqQuery.uploadId)
&& objectKey === undefined) {
return errors.InvalidRequest
.customizeDescription('A key must be specified');
}
return undefined;
}
function routes(req, res, params, logger) {
const {
api,
healthcheckHandler,
statsClient,
allEndpoints,
websiteEndpoints,
isValidBucketName,
unsupportedQueries,
// eslint-disable-next-line
dataRetrievalFn,
} = params;
const clientInfo = {
clientIP: req.socket.remoteAddress,
clientPort: req.socket.remotePort,
httpMethod: req.method,
httpURL: req.url,
endpoint: req.endpoint,
};
const log = logger.newRequestLogger();
log.info('received request', clientInfo);
log.end().addDefaultFields(clientInfo);
if (req.url === '/_/healthcheck') {
return healthcheckHandler(clientInfo.clientIP, false, req, res, log,
statsClient);
} else if (req.url === '/_/healthcheck/deep') {
return healthcheckHandler(clientInfo.clientIP, true, req, res, log);
}
if (statsClient) {
// report new request for stats
statsClient.reportNewRequest();
}
try {
const validHosts = allEndpoints.concat(websiteEndpoints);
routesUtils.normalizeRequest(req, validHosts);
} catch (err) {
console.log('err normalizing request', err);
log.trace('could not normalize request', { error: err.stack });
return routesUtils.responseXMLBody(
errors.InvalidURI, undefined, res, log);
}
log.addDefaultFields({
bucketName: req.bucketName,
objectKey: req.objectKey,
bytesReceived: req.parsedContentLength || 0,
bodyLength: parseInt(req.headers['content-length'], 10) || 0,
});
const reqMethod = req.method.toUpperCase();
const { error, method } = checkUnsupportedRoutes(reqMethod, req.query,
unsupportedQueries, statsClient, log);
if (error) {
log.trace('error validating route or uri params', { error });
return routesUtils.responseXMLBody(error, null, res, log);
}
const bucketOrKeyError = checkBucketAndKey(req.bucketName, req.objectKey,
reqMethod, req.query, isValidBucketName, log);
if (bucketOrKeyError) {
log.trace('error with bucket or key value',
{ error: bucketOrKeyError });
return routesUtils.responseXMLBody(bucketOrKeyError, null, res, log);
}
// bucket website request
if (websiteEndpoints && websiteEndpoints.indexOf(req.parsedHost) > -1) {
return routeWebsite(req, res, api, log, statsClient, dataRetrievalFn);
}
return method(req, res, api, log, statsClient, dataRetrievalFn);
}
module.exports = routes;

View File

@ -0,0 +1,71 @@
const routesUtils = require('../routesUtils');
const errors = require('../../errors');
function routeDELETE(request, response, api, log, statsClient) {
log.debug('routing request', { method: 'routeDELETE' });
if (request.query.uploadId) {
if (request.objectKey === undefined) {
return routesUtils.responseNoBody(
errors.InvalidRequest.customizeDescription('A key must be ' +
'specified'), null, response, 200, log);
}
api.callApiMethod('multipartDelete', request, response, log,
(err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, corsHeaders, response,
204, log);
});
} else {
if (request.objectKey === undefined) {
if (request.query.website !== undefined) {
return api.callApiMethod('bucketDeleteWebsite', request,
response, log, (err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, corsHeaders,
response, 204, log);
});
} else if (request.query.cors !== undefined) {
return api.callApiMethod('bucketDeleteCors', request, response,
log, (err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, corsHeaders,
response, 204, log);
});
}
api.callApiMethod('bucketDelete', request, response, log,
(err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, corsHeaders, response,
204, log);
});
} else {
if (request.query.tagging !== undefined) {
return api.callApiMethod('objectDeleteTagging', request,
response, log, (err, resHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, resHeaders,
response, 204, log);
});
}
api.callApiMethod('objectDelete', request, response, log,
(err, corsHeaders) => {
/*
* Since AWS expects a 204 regardless of the existence of
the object, the errors NoSuchKey and NoSuchVersion should not
* be sent back as a response.
*/
if (err && !err.NoSuchKey && !err.NoSuchVersion) {
return routesUtils.responseNoBody(err, corsHeaders,
response, null, log);
}
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(null, corsHeaders, response,
204, log);
});
}
}
return undefined;
}
module.exports = routeDELETE;

View File

@ -0,0 +1,112 @@
const errors = require('../../errors');
const routesUtils = require('../routesUtils');
function routerGET(request, response, api, log, statsClient, dataRetrievalFn) {
log.debug('routing request', { method: 'routerGET' });
if (request.bucketName === undefined && request.objectKey !== undefined) {
routesUtils.responseXMLBody(errors.NoSuchBucket, null, response, log);
} else if (request.bucketName === undefined
&& request.objectKey === undefined) {
// GET service
api.callApiMethod('serviceGet', request, response, log, (err, xml) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseXMLBody(err, xml, response, log);
});
} else if (request.objectKey === undefined) {
// GET bucket ACL
if (request.query.acl !== undefined) {
api.callApiMethod('bucketGetACL', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
} else if (request.query.cors !== undefined) {
api.callApiMethod('bucketGetCors', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
} else if (request.query.versioning !== undefined) {
api.callApiMethod('bucketGetVersioning', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
} else if (request.query.website !== undefined) {
api.callApiMethod('bucketGetWebsite', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
} else if (request.query.uploads !== undefined) {
// List MultipartUploads
api.callApiMethod('listMultipartUploads', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
} else if (request.query.location !== undefined) {
api.callApiMethod('bucketGetLocation', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
} else {
// GET bucket
api.callApiMethod('bucketGet', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
}
} else {
if (request.query.acl !== undefined) {
// GET object ACL
api.callApiMethod('objectGetACL', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
} else if (request.query.tagging !== undefined) {
// GET object Tagging
api.callApiMethod('objectGetTagging', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
// List parts of an open multipart upload
} else if (request.query.uploadId !== undefined) {
api.callApiMethod('listParts', request, response, log,
(err, xml, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders);
});
} else {
// GET object
api.callApiMethod('objectGet', request, response, log,
(err, dataGetInfo, resMetaHeaders, range) => {
let contentLength = 0;
if (resMetaHeaders && resMetaHeaders['Content-Length']) {
contentLength = resMetaHeaders['Content-Length'];
}
log.end().addDefaultFields({ contentLength });
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseStreamData(err, request.headers,
resMetaHeaders, dataGetInfo, dataRetrievalFn, response,
range, log);
});
}
}
}
module.exports = routerGET;

View File

@ -0,0 +1,29 @@
const errors = require('../../errors');
const routesUtils = require('../routesUtils');
function routeHEAD(request, response, api, log, statsClient) {
log.debug('routing request', { method: 'routeHEAD' });
if (request.bucketName === undefined) {
log.trace('head request without bucketName');
routesUtils.responseXMLBody(errors.MethodNotAllowed,
null, response, log);
} else if (request.objectKey === undefined) {
// HEAD bucket
api.callApiMethod('bucketHead', request, response, log,
(err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, corsHeaders, response,
200, log);
});
} else {
// HEAD object
api.callApiMethod('objectHead', request, response, log,
(err, resHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseContentHeaders(err, {}, resHeaders,
response, log);
});
}
}
module.exports = routeHEAD;

View File

@ -0,0 +1,31 @@
const errors = require('../../errors');
const routesUtils = require('../routesUtils');
function routeOPTIONS(request, response, api, log, statsClient) {
log.debug('routing request', { method: 'routeOPTION' });
const corsMethod = request.headers['access-control-request-method'] || null;
if (!request.headers.origin) {
const msg = 'Insufficient information. Origin request header needed.';
const err = errors.BadRequest.customizeDescription(msg);
log.debug('missing origin', { method: 'routeOPTIONS', error: err });
return routesUtils.responseXMLBody(err, undefined, response, log);
}
if (['GET', 'PUT', 'HEAD', 'POST', 'DELETE'].indexOf(corsMethod) < 0) {
const msg = `Invalid Access-Control-Request-Method: ${corsMethod}`;
const err = errors.BadRequest.customizeDescription(msg);
log.debug('invalid Access-Control-Request-Method',
{ method: 'routeOPTIONS', error: err });
return routesUtils.responseXMLBody(err, undefined, response, log);
}
return api.callApiMethod('corsPreflight', request, response, log,
(err, resHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, resHeaders, response, 200,
log);
});
}
module.exports = routeOPTIONS;

View File

@ -0,0 +1,54 @@
const errors = require('../../errors');
const routesUtils = require('../routesUtils');
/* eslint-disable no-param-reassign */
function routePOST(request, response, api, log) {
log.debug('routing request', { method: 'routePOST' });
const invalidMultiObjectDelReq = request.query.delete !== undefined
&& request.bucketName === undefined;
if (invalidMultiObjectDelReq) {
return routesUtils.responseNoBody(errors.MethodNotAllowed, null,
response, null, log);
}
request.post = '';
const invalidInitiateMpuReq = request.query.uploads !== undefined
&& request.objectKey === undefined;
const invalidCompleteMpuReq = request.query.uploadId !== undefined
&& request.objectKey === undefined;
if (invalidInitiateMpuReq || invalidCompleteMpuReq) {
return routesUtils.responseNoBody(errors.InvalidURI, null,
response, null, log);
}
// POST initiate multipart upload
if (request.query.uploads !== undefined) {
return api.callApiMethod('initiateMultipartUpload', request,
response, log, (err, result, corsHeaders) =>
routesUtils.responseXMLBody(err, result, response, log,
corsHeaders));
}
// POST complete multipart upload
if (request.query.uploadId !== undefined) {
return api.callApiMethod('completeMultipartUpload', request,
response, log, (err, result, resHeaders) =>
routesUtils.responseXMLBody(err, result, response, log,
resHeaders));
}
// POST multiObjectDelete
if (request.query.delete !== undefined) {
return api.callApiMethod('multiObjectDelete', request, response,
log, (err, xml, corsHeaders) =>
routesUtils.responseXMLBody(err, xml, response, log,
corsHeaders));
}
return routesUtils.responseNoBody(errors.NotImplemented, null, response,
200, log);
}
/* eslint-enable no-param-reassign */
module.exports = routePOST;

View File

@ -0,0 +1,182 @@
const errors = require('../../errors');
const routesUtils = require('../routesUtils');
const encryptionHeaders = [
'x-amz-server-side-encryption',
'x-amz-server-side-encryption-customer-algorithm',
'x-amz-server-side-encryption-aws-kms-key-id',
'x-amz-server-side-encryption-context',
'x-amz-server-side-encryption-customer-key',
'x-amz-server-side-encryption-customer-key-md5',
];
/* eslint-disable no-param-reassign */
function routePUT(request, response, api, log, statsClient) {
log.debug('routing request', { method: 'routePUT' });
if (request.objectKey === undefined) {
// PUT bucket - PUT bucket ACL
// content-length for object is handled separately below
const contentLength = request.headers['content-length'];
if ((contentLength && (isNaN(contentLength) || contentLength < 0)) ||
contentLength === '') {
log.debug('invalid content-length header');
return routesUtils.responseNoBody(
errors.BadRequest, null, response, null, log);
}
// PUT bucket ACL
if (request.query.acl !== undefined) {
api.callApiMethod('bucketPutACL', request, response, log,
(err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, corsHeaders,
response, 200, log);
});
} else if (request.query.versioning !== undefined) {
api.callApiMethod('bucketPutVersioning', request, response, log,
(err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
routesUtils.responseNoBody(err, corsHeaders, response, 200,
log);
});
} else if (request.query.website !== undefined) {
api.callApiMethod('bucketPutWebsite', request, response, log,
(err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, corsHeaders,
response, 200, log);
});
} else if (request.query.cors !== undefined) {
api.callApiMethod('bucketPutCors', request, response, log,
(err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, corsHeaders,
response, 200, log);
});
} else if (request.query.replication !== undefined) {
api.callApiMethod('bucketPutReplication', request, response, log,
(err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
routesUtils.responseNoBody(err, corsHeaders, response, 200,
log);
});
} else if (request.query.acl === undefined) {
// PUT bucket
return api.callApiMethod('bucketPut', request, response, log,
(err, corsHeaders) => {
routesUtils.statsReport500(err, statsClient);
const location = { Location: `/${request.bucketName}` };
const resHeaders = corsHeaders ?
Object.assign({}, location, corsHeaders) : location;
return routesUtils.responseNoBody(err, resHeaders,
response, 200, log);
});
}
} else {
// PUT object, PUT object ACL, PUT object multipart or
// PUT object copy
// if content-md5 is not present in the headers, try to
// parse content-md5 from meta headers
if (request.headers['content-md5'] === '') {
log.debug('empty content-md5 header', {
method: 'routePUT',
});
return routesUtils
.responseNoBody(errors.InvalidDigest, null, response, 200, log);
}
if (request.headers['content-md5']) {
request.contentMD5 = request.headers['content-md5'];
} else {
request.contentMD5 = routesUtils.parseContentMD5(request.headers);
}
if (request.contentMD5 && request.contentMD5.length !== 32) {
request.contentMD5 = Buffer.from(request.contentMD5, 'base64')
.toString('hex');
if (request.contentMD5 && request.contentMD5.length !== 32) {
log.debug('invalid md5 digest', {
contentMD5: request.contentMD5,
});
return routesUtils
.responseNoBody(errors.InvalidDigest, null, response, 200,
log);
}
}
// object level encryption
if (encryptionHeaders.some(i => request.headers[i] !== undefined)) {
return routesUtils.responseXMLBody(errors.NotImplemented, null,
response, log);
}
if (request.query.partNumber) {
if (request.headers['x-amz-copy-source']) {
api.callApiMethod('objectPutCopyPart', request, response, log,
(err, xml, additionalHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseXMLBody(err, xml, response, log,
additionalHeaders);
});
} else {
api.callApiMethod('objectPutPart', request, response, log,
(err, calculatedHash, corsHeaders) => {
if (err) {
return routesUtils.responseNoBody(err, corsHeaders,
response, 200, log);
}
// ETag's hex should always be enclosed in quotes
const resMetaHeaders = corsHeaders || {};
resMetaHeaders.ETag = `"${calculatedHash}"`;
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, resMetaHeaders,
response, 200, log);
});
}
} else if (request.query.acl !== undefined) {
api.callApiMethod('objectPutACL', request, response, log,
(err, resHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, resHeaders,
response, 200, log);
});
} else if (request.query.tagging !== undefined) {
api.callApiMethod('objectPutTagging', request, response, log,
(err, resHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, resHeaders,
response, 200, log);
});
} else if (request.headers['x-amz-copy-source']) {
return api.callApiMethod('objectCopy', request, response, log,
(err, xml, additionalHeaders) => {
routesUtils.statsReport500(err, statsClient);
routesUtils.responseXMLBody(err, xml, response, log,
additionalHeaders);
});
} else {
if (request.headers['content-length'] === undefined &&
request.headers['x-amz-decoded-content-length'] === undefined) {
return routesUtils.responseNoBody(errors.MissingContentLength,
null, response, 411, log);
}
if (Number.isNaN(request.parsedContentLength) ||
request.parsedContentLength < 0) {
return routesUtils.responseNoBody(errors.BadRequest,
null, response, 400, log);
}
log.end().addDefaultFields({
contentLength: request.parsedContentLength,
});
api.callApiMethod('objectPut', request, response, log,
(err, resHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, resHeaders,
response, 200, log);
});
}
}
return undefined;
}
/* eslint-enable no-param-reassign */
module.exports = routePUT;

View File

@ -0,0 +1,65 @@
const errors = require('../../errors');
const routesUtils = require('../routesUtils');
function routerWebsite(request, response, api, log, statsClient,
dataRetrievalFn) {
log.debug('routing request', { method: 'routerWebsite' });
// website endpoint only supports GET and HEAD and must have a bucket
// http://docs.aws.amazon.com/AmazonS3/latest/dev/WebsiteEndpoints.html
if ((request.method !== 'GET' && request.method !== 'HEAD')
|| !request.bucketName) {
return routesUtils.errorHtmlResponse(errors.MethodNotAllowed,
false, request.bucketName, response, null, log);
}
if (request.method === 'GET') {
return api.callApiMethod('websiteGet', request, response, log,
(err, userErrorPageFailure, dataGetInfo, resMetaHeaders,
redirectInfo, key) => {
routesUtils.statsReport500(err, statsClient);
// request being redirected
if (redirectInfo) {
// note that key might have been modified in websiteGet
// api to add index document
return routesUtils.redirectRequest(redirectInfo,
key, request.connection.encrypted,
response, request.headers.host, resMetaHeaders, log);
}
// user has their own error page
if (err && dataGetInfo) {
return routesUtils.streamUserErrorPage(err, dataGetInfo,
dataRetrievalFn, response, resMetaHeaders, log);
}
// send default error html response
if (err) {
return routesUtils.errorHtmlResponse(err,
userErrorPageFailure, request.bucketName,
response, resMetaHeaders, log);
}
// no error, stream data
return routesUtils.responseStreamData(null, request.headers,
resMetaHeaders, dataGetInfo, dataRetrievalFn, response,
null, log);
});
}
if (request.method === 'HEAD') {
return api.callApiMethod('websiteHead', request, response, log,
(err, resMetaHeaders, redirectInfo, key) => {
routesUtils.statsReport500(err, statsClient);
if (redirectInfo) {
return routesUtils.redirectRequest(redirectInfo,
key, request.connection.encrypted,
response, request.headers.host, resMetaHeaders, log);
}
// could redirect on err so check for redirectInfo first
if (err) {
return routesUtils.errorHeaderResponse(err, response,
resMetaHeaders, log);
}
return routesUtils.responseContentHeaders(err, {}, resMetaHeaders,
response, log);
});
}
return undefined;
}
module.exports = routerWebsite;

809
lib/s3routes/routesUtils.js Normal file
View File

@ -0,0 +1,809 @@
const url = require('url');
const ipCheck = require('../ipCheck');
/**
* setCommonResponseHeaders - Set HTTP response headers
* @param {object} headers - key and value of new headers to add
* @param {object} response - http response object
* @param {object} log - Werelogs logger
* @return {object} response - response object with additional headers
*/
function setCommonResponseHeaders(headers, response, log) {
if (headers && typeof headers === 'object') {
log.trace('setting response headers', { headers });
Object.keys(headers).forEach(key => {
if (headers[key] !== undefined) {
try {
response.setHeader(key, headers[key]);
} catch (e) {
log.debug('header can not be added ' +
'to the response', { header: headers[key],
error: e.stack, method: 'setCommonResponseHeaders' });
}
}
});
}
response.setHeader('server', 'S3 Server');
// to be expanded in further implementation of logging of requests
response.setHeader('x-amz-id-2', log.getSerializedUids());
response.setHeader('x-amz-request-id', log.getSerializedUids());
return response;
}
/**
* okHeaderResponse - Response with only headers, no body
* @param {object} headers - key and value of new headers to add
* @param {object} response - http response object
* @param {number} httpCode -- http response code
* @param {object} log - Werelogs logger
* @return {object} response - response object with additional headers
*/
function okHeaderResponse(headers, response, httpCode, log) {
log.trace('sending success header response');
setCommonResponseHeaders(headers, response, log);
log.debug('response http code', { httpCode });
response.writeHead(httpCode);
return response.end(() => {
log.end().info('responded to request', {
httpCode: response.statusCode,
});
});
}
/**
* okXMLResponse - Response with XML body
* @param {string} xml - XML body as string
* @param {object} response - http response object
* @param {object} log - Werelogs logger
* @param {object} additionalHeaders -- additional headers to add to response
* @return {object} response - response object with additional headers
*/
function okXMLResponse(xml, response, log, additionalHeaders) {
const bytesSent = Buffer.byteLength(xml);
log.trace('sending success xml response');
log.addDefaultFields({
bytesSent,
});
setCommonResponseHeaders(additionalHeaders, response, log);
response.writeHead(200, { 'Content-type': 'application/xml' });
log.debug('response http code', { httpCode: 200 });
log.trace('xml response', { xml });
return response.end(xml, 'utf8', () => {
log.end().info('responded with XML', {
httpCode: response.statusCode,
});
});
}
function errorXMLResponse(errCode, response, log, corsHeaders) {
log.trace('sending error xml response', { errCode });
/*
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>NoSuchKey</Code>
<Message>The resource you requested does not exist</Message>
<Resource>/mybucket/myfoto.jpg</Resource>
<RequestId>4442587FB7D0A2F9</RequestId>
</Error>
*/
const xml = [];
xml.push(
'<?xml version="1.0" encoding="UTF-8"?>',
'<Error>',
`<Code>${errCode.message}</Code>`,
`<Message>${errCode.description}</Message>`,
'<Resource></Resource>',
`<RequestId>${log.getSerializedUids()}</RequestId>`,
'</Error>'
);
const xmlStr = xml.join('');
const bytesSent = Buffer.byteLength(xmlStr);
log.addDefaultFields({
bytesSent,
});
if (corsHeaders) {
// eslint-disable-next-line no-param-reassign
corsHeaders['Content-Type'] = 'application/xml';
// eslint-disable-next-line no-param-reassign
corsHeaders['Content-Length'] = xmlStr.length;
}
setCommonResponseHeaders(corsHeaders, response, log);
response.writeHead(errCode.code, { 'Content-type': 'application/xml' });
return response.end(xmlStr, 'utf8', () => {
log.end().info('responded with error XML', {
httpCode: response.statusCode,
});
});
}
/**
* Modify response headers for an objectGet or objectHead request
* @param {object} overrideHeaders - headers in this object override common
* headers. These are extracted from the request object
* @param {object} resHeaders - object with common response headers
* @param {object} response - router's response object
* @param {array | undefined} range - range in form of [start, end]
* or undefined if no range header
* @param {object} log - Werelogs logger
* @return {object} response - modified response object
*/
function okContentHeadersResponse(overrideHeaders, resHeaders,
response, range, log) {
const addHeaders = {};
if (process.env.ALLOW_INVALID_META_HEADERS) {
const headersArr = Object.keys(resHeaders);
const length = headersArr.length;
for (let i = 0; i < length; i++) {
const headerName = headersArr[i];
if (headerName.startsWith('x-amz-')) {
const translatedHeaderName = headerName.replace(/\//g, '|+2f');
// eslint-disable-next-line no-param-reassign
resHeaders[translatedHeaderName] =
resHeaders[headerName];
if (translatedHeaderName !== headerName) {
// eslint-disable-next-line no-param-reassign
delete resHeaders[headerName];
}
}
}
}
Object.assign(addHeaders, resHeaders);
if (overrideHeaders['response-content-type']) {
addHeaders['Content-Type'] = overrideHeaders['response-content-type'];
}
if (overrideHeaders['response-content-language']) {
addHeaders['Content-Language'] =
overrideHeaders['response-content-language'];
}
if (overrideHeaders['response-expires']) {
addHeaders.Expires = overrideHeaders['response-expires'];
}
if (overrideHeaders['response-cache-control']) {
addHeaders['Cache-Control'] = overrideHeaders['response-cache-control'];
}
if (overrideHeaders['response-content-disposition']) {
addHeaders['Content-Disposition'] =
overrideHeaders['response-content-disposition'];
}
if (overrideHeaders['response-content-encoding']) {
addHeaders['Content-Encoding'] =
overrideHeaders['response-content-encoding'];
}
setCommonResponseHeaders(addHeaders, response, log);
const httpCode = range ? 206 : 200;
log.debug('response http code', { httpCode });
response.writeHead(httpCode);
return response;
}
function retrieveData(locations, dataRetrievalFn,
response, logger, errorHandlerFn) {
if (locations.length === 0) {
return response.end();
}
if (errorHandlerFn === undefined) {
// eslint-disable-next-line
errorHandlerFn = () => { response.connection.destroy(); };
}
const current = locations.shift();
return dataRetrievalFn(current, logger,
(err, readable) => {
if (err) {
logger.error('failed to get object', {
error: err,
method: 'retrieveData',
});
return errorHandlerFn(err);
}
readable.on('error', err => {
logger.error('error piping data from source');
errorHandlerFn(err);
});
readable.on('end', () => {
process.nextTick(retrieveData,
locations, dataRetrievalFn, response, logger);
});
readable.pipe(response, { end: false });
return undefined;
});
}
const routesUtils = {
/**
* @param {string} errCode - S3 error Code
* @param {string} xml - xml body as string conforming to S3's spec.
* @param {object} response - router's response object
* @param {object} log - Werelogs logger
* @param {object} [additionalHeaders] - additionalHeaders to add
* to response
* @return {function} - error or success response utility
*/
responseXMLBody(errCode, xml, response, log, additionalHeaders) {
if (errCode && !response.headersSent) {
return errorXMLResponse(errCode, response, log, additionalHeaders);
}
if (!response.headersSent) {
return okXMLResponse(xml, response, log, additionalHeaders);
}
return undefined;
},
/**
* @param {string} errCode - S3 error Code
* @param {string} resHeaders - headers to be set for the response
* @param {object} response - router's response object
* @param {number} httpCode - httpCode to set in response
* If none provided, defaults to 200.
* @param {object} log - Werelogs logger
* @return {function} - error or success response utility
*/
responseNoBody(errCode, resHeaders, response, httpCode = 200, log) {
if (errCode && !response.headersSent) {
return errorXMLResponse(errCode, response, log, resHeaders);
}
if (!response.headersSent) {
return okHeaderResponse(resHeaders, response, httpCode, log);
}
return undefined;
},
/**
* @param {string} errCode - S3 error Code
* @param {object} overrideHeaders - headers in this object override common
* headers. These are extracted from the request object
* @param {string} resHeaders - headers to be set for the response
* @param {object} response - router's response object
* @param {object} log - Werelogs logger
* @return {object} - router's response object
*/
responseContentHeaders(errCode, overrideHeaders, resHeaders, response,
log) {
if (errCode && !response.headersSent) {
return errorXMLResponse(errCode, response, log, resHeaders);
}
if (!response.headersSent) {
// Undefined added as an argument since need to send range to
// okContentHeadersResponse in responseStreamData
okContentHeadersResponse(overrideHeaders, resHeaders, response,
undefined, log);
}
return response.end(() => {
log.end().info('responded with content headers', {
httpCode: response.statusCode,
});
});
},
/**
* @param {array} dataLocations - all data locations
* @param {array} outerRange - range from request
* @return {array} parsedLocations - dataLocations filtered for
* what needed and ranges added for particular parts as needed
*/
setPartRanges(dataLocations, outerRange) {
const parsedLocations = [];
const begin = outerRange[0];
const end = outerRange[1];
// If have single location, do not need to break up range among parts
// and might not have a start and size property
// on the dataLocation (because might be pre- md-model-version 2),
// so just set range as property
if (dataLocations.length === 1) {
const soleLocation = dataLocations[0];
soleLocation.range = [begin, end];
// If missing size, does not impact get range.
// We modify size here in case this function is used for
// object put part copy where will need size.
// If pre-md-model-version 2, object put part copy will not
// be allowed, so not an issue that size not modified here.
if (dataLocations[0].size) {
const partSize = parseInt(dataLocations[0].size, 10);
soleLocation.size =
Math.min(partSize, end - begin + 1).toString();
}
parsedLocations.push(soleLocation);
return parsedLocations;
}
// Range is inclusive of endpoint so need plus 1
const max = end - begin + 1;
let total = 0;
for (let i = 0; i < dataLocations.length; i++) {
if (total >= max) {
break;
}
const partStart = parseInt(dataLocations[i].start, 10);
const partSize = parseInt(dataLocations[i].size, 10);
if (partStart + partSize <= begin) {
continue;
}
if (partStart >= begin) {
// If the whole part is in the range, just include it
if (partSize + total <= max) {
const partWithoutRange = dataLocations[i];
partWithoutRange.size = partSize.toString();
parsedLocations.push(partWithoutRange);
total += partSize;
// Otherwise set a range limit on the part end
// and we're done
} else {
const partWithRange = dataLocations[i];
// Need to subtract one from endPart since range
// includes endPart in byte count
const endPart = Math.min(partSize - 1, max - total - 1);
partWithRange.range = [0, endPart];
// modify size to be stored for object put part copy
partWithRange.size = (endPart + 1).toString();
parsedLocations.push(dataLocations[i]);
break;
}
} else {
// Offset start (and end if necessary)
const partWithRange = dataLocations[i];
const startOffset = begin - partStart;
// Use full remaining part if remaining partSize is less
// than byte range we need to satisfy. Or use byte range
// we need to satisfy taking into account any startOffset
const endPart = Math.min(partSize - 1,
max - total + startOffset - 1);
partWithRange.range = [startOffset, endPart];
// modify size to be stored for object put part copy
partWithRange.size = (endPart - startOffset + 1).toString();
parsedLocations.push(partWithRange);
// Need to add byte back since with total we are counting
// number of bytes while the endPart and startOffset
// are in terms of range which include the endpoint
total += endPart - startOffset + 1;
}
}
return parsedLocations;
},
/**
* @param {string} errCode - S3 error Code
* @param {object} overrideHeaders - headers in this object override common
* headers. These are extracted from the request object
* @param {string} resHeaders - headers to be set for the response
* @param {array | null} dataLocations --
* - array of locations to get streams from sproxyd
* - null if no data for object and only metadata
* @param {function} dataRetrievalFn - function to handle streaming data
* @param {http.ServerResponse} response - response sent to the client
* @param {array | undefined} range - range in format of [start, end]
* if range header contained in request or undefined if not
* @param {object} log - Werelogs logger
* @return {undefined}
*/
responseStreamData(errCode, overrideHeaders, resHeaders, dataLocations,
dataRetrievalFn, response, range, log) {
if (errCode && !response.headersSent) {
return errorXMLResponse(errCode, response, log, resHeaders);
}
if (!response.headersSent) {
okContentHeadersResponse(overrideHeaders, resHeaders, response,
range, log);
}
if (dataLocations === null) {
return response.end(() => {
log.end().info('responded with only metadata', {
httpCode: response.statusCode,
});
});
}
// TODO: may need to extract this logic to make this streaming method
// general enough for azure
const parsedLocations = range ? routesUtils
.setPartRanges(dataLocations, range) : dataLocations.slice();
response.on('finish', () => {
log.end().info('responded with streamed content', {
httpCode: response.statusCode,
});
});
return retrieveData(parsedLocations, dataRetrievalFn, response, log);
},
/**
* @param {object} err -- arsenal error object
* @param {array} dataLocations --
* - array of locations to get streams from backend
* @param {function} dataRetrievalFn - function to handle streaming data
* @param {http.ServerResponse} response - response sent to the client
* @param {object} corsHeaders - CORS-related response headers
* @param {object} log - Werelogs logger
* @return {undefined}
*/
streamUserErrorPage(err, dataLocations, dataRetrievalFn, response,
corsHeaders, log) {
setCommonResponseHeaders(corsHeaders, response, log);
response.writeHead(err.code, { 'Content-type': 'text/html' });
response.on('finish', () => {
log.end().info('responded with streamed content', {
httpCode: response.statusCode,
});
});
return retrieveData(dataLocations, dataRetrievalFn, response, log);
},
/**
* @param {object} err - arsenal error object
* @param {boolean} userErrorPageFailure - whether there was a failure
* retrieving the user's error page
* @param {string} bucketName - bucketName from request
* @param {http.ServerResponse} response - response sent to the client
* @param {object} corsHeaders - CORS-related response headers
* @param {object} log - Werelogs logger
* @return {undefined}
*/
errorHtmlResponse(err, userErrorPageFailure, bucketName, response,
corsHeaders, log) {
log.trace('sending generic html error page',
{ err });
setCommonResponseHeaders(corsHeaders, response, log);
response.writeHead(err.code, { 'Content-type': 'text/html' });
const html = [];
// response.statusMessage will provide standard message for status
// code so much set response status code before creating html
html.push(
'<html>',
'<head>',
`<title>${err.code} ${response.statusMessage}</title>`,
'</head>',
'<body>',
`<h1>${err.code} ${response.statusMessage}</h1>`,
'<ul>',
`<li>Code: ${err.message}</li>`,
`<li>Message: ${err.description}</li>`
);
if (!userErrorPageFailure && bucketName) {
html.push(`<li>BucketName: ${bucketName}</li>`);
}
html.push(
`<li>RequestId: ${log.getSerializedUids()}</li>`,
// AWS response contains HostId here.
// TODO: consider adding
'</ul>'
);
if (userErrorPageFailure) {
html.push(
'<h3>An Error Occurred While Attempting ',
'to Retrieve a Custom ',
'Error Document</h3>',
'<ul>',
`<li>Code: ${err.message}</li>`,
`<li>Message: ${err.description}</li>`,
'</ul>'
);
}
html.push(
'<hr/>',
'</body>',
'</html>'
);
return response.end(html.join(''), 'utf8', () => {
log.end().info('responded with error html', {
httpCode: response.statusCode,
});
});
},
/**
* @param {object} err - arsenal error object
* @param {http.ServerResponse} response - response sent to the client
* @param {object} corsHeaders - CORS-related response headers
* @param {object} log - Werelogs logger
* @return {undefined}
*/
errorHeaderResponse(err, response, corsHeaders, log) {
log.trace('sending error header response',
{ err });
setCommonResponseHeaders(corsHeaders, response, log);
response.setHeader('x-amz-error-code', err.message);
response.setHeader('x-amz-error-message', err.description);
response.writeHead(err.code);
return response.end(() => {
log.end().info('responded with error headers', {
httpCode: response.statusCode,
});
});
},
/**
* Get bucket name and object name from the request
* @param {object} request - http request object
* @param {string} pathname - http request path parsed from request url
* @param {string[]} validHosts - all region endpoints + websiteEndpoints
* @returns {object} result - returns object containing bucket
* name and objectKey as key
*/
getResourceNames(request, pathname, validHosts) {
return this.getNamesFromReq(request, pathname,
routesUtils.getBucketNameFromHost(request, validHosts));
},
/**
* Get bucket name and/or object name from the path of a request
* @param {object} request - http request object
* @param {string} pathname - http request path parsed from request url
* @param {string} bucketNameFromHost - name of bucket from host name
* @returns {object} resources - returns object w. bucket and object as keys
*/
getNamesFromReq(request, pathname,
bucketNameFromHost) {
const resources = {
bucket: undefined,
object: undefined,
host: undefined,
gotBucketNameFromHost: undefined,
path: undefined,
};
// If there are spaces in a key name, s3cmd sends them as "+"s.
// Actual "+"s are uri encoded as "%2B" so by switching "+"s to
// spaces here, you still retain any "+"s in the final decoded path
const pathWithSpacesInsteadOfPluses = pathname.replace(/\+/g, ' ');
const path = decodeURIComponent(pathWithSpacesInsteadOfPluses);
resources.path = path;
let fullHost;
if (request.headers && request.headers.host) {
const reqHost = request.headers.host;
const bracketIndex = reqHost.indexOf(']');
const colonIndex = reqHost.lastIndexOf(':');
const hostLength = colonIndex > bracketIndex ?
colonIndex : reqHost.length;
fullHost = reqHost.slice(0, hostLength);
} else {
fullHost = undefined;
}
if (bucketNameFromHost) {
resources.bucket = bucketNameFromHost;
const bucketNameLength = bucketNameFromHost.length;
resources.host = fullHost.slice(bucketNameLength + 1);
// Slice off leading '/'
resources.object = path.slice(1);
resources.gotBucketNameFromHost = true;
} else {
resources.host = fullHost;
const urlArr = path.split('/');
if (urlArr.length > 1) {
resources.bucket = urlArr[1];
resources.object = urlArr.slice(2).join('/');
} else if (urlArr.length === 1) {
resources.bucket = urlArr[0];
}
}
// remove any empty strings or nulls
if (resources.bucket === '' || resources.bucket === null) {
resources.bucket = undefined;
}
if (resources.object === '' || resources.object === null) {
resources.object = undefined;
}
return resources;
},
/**
* Get bucket name from the request of a virtually hosted bucket
* @param {object} request - HTTP request object
* @return {string|undefined} - returns bucket name if dns-style query
* returns undefined if path-style query
* @param {string[]} validHosts - all region endpoints + websiteEndpoints
* @throws {Error} in case the type of query could not be infered
*/
getBucketNameFromHost(request, validHosts) {
const headers = request.headers;
if (headers === undefined || headers.host === undefined) {
throw new Error('bad request: no host in headers');
}
const reqHost = headers.host;
const bracketIndex = reqHost.indexOf(']');
const colonIndex = reqHost.lastIndexOf(':');
const hostLength = colonIndex > bracketIndex ?
colonIndex : reqHost.length;
// If request is made using IPv6 (indicated by presence of brackets),
// surrounding brackets should not be included in host var
const host = bracketIndex > -1 ?
reqHost.slice(1, hostLength - 1) : reqHost.slice(0, hostLength);
// parseIp returns empty object if host is not valid IP
// If host is an IP address, it's path-style
if (Object.keys(ipCheck.parseIp(host)).length !== 0) {
return undefined;
}
let bucketName;
for (let i = 0; i < validHosts.length; ++i) {
if (host === validHosts[i]) {
// It's path-style
return undefined;
} else if (host.endsWith(`.${validHosts[i]}`)) {
const potentialBucketName = host.split(`.${validHosts[i]}`)[0];
if (!bucketName) {
bucketName = potentialBucketName;
} else {
// bucketName should be shortest so that takes into account
// most specific potential hostname
bucketName =
potentialBucketName.length < bucketName.length ?
potentialBucketName : bucketName;
}
}
}
if (bucketName) {
return bucketName;
}
throw new Error(
`bad request: hostname ${host} is not in valid endpoints`
);
},
/**
* Modify http request object
* @param {object} request - http request object
* @param {string[]} validHosts - all region endpoints + websiteEndpoints
* @return {object} request object with additional attributes
*/
normalizeRequest(request, validHosts) {
/* eslint-disable no-param-reassign */
const parsedUrl = url.parse(request.url, true);
request.query = parsedUrl.query;
// TODO: make the namespace come from a config variable.
request.namespace = 'default';
// Parse bucket and/or object names from request
const resources = this.getResourceNames(request, parsedUrl.pathname,
validHosts);
request.gotBucketNameFromHost = resources.gotBucketNameFromHost;
request.bucketName = resources.bucket;
request.objectKey = resources.object;
request.parsedHost = resources.host;
request.path = resources.path;
// For streaming v4 auth, the total body content length
// without the chunk metadata is sent as
// the x-amz-decoded-content-length
const contentLength = request.headers['x-amz-decoded-content-length'] ?
request.headers['x-amz-decoded-content-length'] :
request.headers['content-length'];
request.parsedContentLength =
Number.parseInt(contentLength, 10);
if (process.env.ALLOW_INVALID_META_HEADERS) {
const headersArr = Object.keys(request.headers);
const length = headersArr.length;
if (headersArr.indexOf('x-invalid-metadata') > 1) {
for (let i = 0; i < length; i++) {
const headerName = headersArr[i];
if (headerName.startsWith('x-amz-')) {
const translatedHeaderName =
headerName.replace(/\|\+2f/g, '/');
request.headers[translatedHeaderName] =
request.headers[headerName];
if (translatedHeaderName !== headerName) {
delete request.headers[headerName];
}
}
}
}
}
return request;
},
/**
* Parse content-md5 from meta headers
* @param {string} headers - request headers
* @return {string} - returns content-md5 string
*/
parseContentMD5(headers) {
if (headers['x-amz-meta-s3cmd-attrs']) {
const metaHeadersArr = headers['x-amz-meta-s3cmd-attrs'].split('/');
for (let i = 0; i < metaHeadersArr.length; i++) {
const tmpArr = metaHeadersArr[i].split(':');
if (tmpArr[0] === 'md5') {
return tmpArr[1];
}
}
}
return '';
},
/**
* Check if request query contains unsupported query
* @param {object} queryObj - parsed http request query
* @param {object} unsupportedQueries - true/false value for whether queries
* are supported
* @return {boolean} true/false - whether query contains unsupported query
*/
isUnsupportedQuery(queryObj, unsupportedQueries) {
return Object.keys(queryObj)
.some(key => unsupportedQueries[key]);
},
/**
* Report 500 to stats when an Internal Error occurs
* @param {object} err - Arsenal error
* @param {object} statsClient - StatsClient instance
* @returns {undefined}
*/
statsReport500(err, statsClient) {
if (statsClient && err && err.code === 500) {
statsClient.report500();
}
return undefined;
},
/**
* redirectRequest - redirectRequest based on rule
* @param {object} routingInfo - info for routing
* @param {string} [routingInfo.hostName] - redirect host
* @param {string} [routingInfo.protocol] - protocol for redirect
* (http or https)
* @param {number} [routingInfo.httpRedirectCode] - redirect http code
* @param {string} [routingInfo.replaceKeyPrefixWith] - repalcement prefix
* @param {string} [routingInfo.replaceKeyWith] - replacement key
* @param {string} [routingInfo.prefixFromRule] - key prefix to be replaced
* @param {boolean} [routingInfo.justPath] - whether to just send the
* path as the redirect location header rather than full protocol plus
* hostname plus path (AWS only sends path when redirect is based on
* x-amz-website-redirect-location header and redirect is to key in
* same bucket)
* @param {boolean} [routingInfo.redirectLocationHeader] - whether redirect
* rule came from an x-amz-website-redirect-location header
* @param {string} objectKey - key name (may have been modified in
* websiteGet api to include index document)
* @param {boolean} encrypted - whether request was https
* @param {object} response - response object
* @param {string} hostHeader - host sent in original request.headers
* @param {object} corsHeaders - CORS-related response headers
* @param {object} log - Werelogs instance
* @return {undefined}
*/
redirectRequest(routingInfo, objectKey, encrypted, response, hostHeader,
corsHeaders, log) {
const { justPath, redirectLocationHeader, hostName, protocol,
httpRedirectCode, replaceKeyPrefixWith,
replaceKeyWith, prefixFromRule } = routingInfo;
const redirectProtocol = protocol || encrypted ? 'https' : 'http';
const redirectCode = httpRedirectCode || 301;
const redirectHostName = hostName || hostHeader;
setCommonResponseHeaders(corsHeaders, response, log);
let redirectKey = objectKey;
// will only have either replaceKeyWith defined or replaceKeyPrefixWith
// defined. not both and might have neither
if (replaceKeyWith !== undefined) {
redirectKey = replaceKeyWith;
}
if (replaceKeyPrefixWith !== undefined) {
if (prefixFromRule !== undefined) {
// if here with prefixFromRule defined, means that
// passed condition
// and objectKey starts with this prefix. replace just first
// instance in objectKey with the replaceKeyPrefixWith value
redirectKey = objectKey.replace(prefixFromRule,
replaceKeyPrefixWith);
} else {
redirectKey = replaceKeyPrefixWith + objectKey;
}
}
let redirectLocation = justPath ? `/${redirectKey}` :
`${redirectProtocol}://${redirectHostName}/${redirectKey}`;
if (!redirectKey && redirectLocationHeader) {
// remove hanging slash
redirectLocation = redirectLocation.slice(0, -1);
}
log.end().info('redirecting request', {
httpCode: redirectCode,
redirectLocation: hostName,
});
response.writeHead(redirectCode, {
Location: redirectLocation,
});
response.end();
return undefined;
},
};
module.exports = routesUtils;