UNPKG

@axway/api-builder-runtime

Version:

API Builder Runtime

431 lines (420 loc) 14.3 kB
/** * @class APIBuilder.Middleware */ const express = require('express'), path = require('path'), fs = require('fs'), async = require('async'), crypto = require('crypto'), cookieParser = require('cookie-parser'), bodyParser = require('body-parser'), compression = require('compression'), sessions = require('client-sessions'), busboy = require('connect-busboy'), _ = require('lodash'), APIBuilder = require('./apibuilder'), apiBuilderConfig = require('@axway/api-builder-config'), tmpdir = require('os').tmpdir(); function Middleware(app, dirname, sessionConfig, config = {}) { this.app = app; app.set('x-powered-by', false); // sets the body-parser limit for maximum request body size var limit = (config.bodyParser || {}).limit || '1mb'; var secret = config.cookieSecret; app.use(cookieParser(secret)); app.use((req, resp, next) => { resp.setHeader('Start-Time', `${Date.now()}`); next(); }); app.use(bodyParser.text({ limit, type: [ 'text/*', 'application/xml', 'application/xml-dtd', 'application/xml-external-parsed-entity', 'application/*+xml', 'image/svg+xml', 'model/x3d+xml' ] })); app.use(bodyParser.json({ limit, type: [ 'application/json', 'application/*+json' ] })); app.use(bodyParser.urlencoded({ extended: false, limit })); // Handles multipart/form-data // Busboy is bypassed for GET and HEAD requests as well as non-multipart // or form data requests app.use(busboy(config.busboy || {})); // Handle streaming multipart file uploads to disk and any per-part limits, // and non-file multipart fields app.use(function (req, resp, next) { if (req.busboy && req.headers['content-type'] && req.headers['content-type'].indexOf('multipart/form-data') === 0) { req.files = {}; req.fields = {}; let total = 0; let count = 0; let finished; let sent = false; req.busboy.on('file', function (fieldname, file, info) { req.logger.trace('multipart file event:', { fieldname, info }); file.on('limit', () => { req.logger.trace('multipart limit reached:', fieldname); if (sent) { return; } // Multipart part size limit hit // Prevent middleware from continuing and send 413 response sent = true; onMultipartLimit(req, resp, config); }); // Write a unique file to disk const tmpfileName = `${req.getId()}_${total}_${fieldname}_${info.filename || ''}`; const tmpfile = path.join(tmpdir, tmpfileName); total++; req.files[fieldname] = { file: tmpfile, filename: info.filename, encoding: info.encoding, mimetype: info.mimeType }; const stream = fs.createWriteStream(tmpfile); req.logger.debug('multipart file dest:', fieldname, tmpfile); // handle stream error here, for example, // the file could not be created(eg. disk full) stream.on('error', err => { req.logger.error('multipart file error:', fieldname, tmpfile, err); req.unpipe(stream); if (sent) { return; } sent = true; resp.status(500).send({ success: false, code: 500, message: 'Server Error' }); }); stream.on('close', function () { req.logger.trace('multipart file closed:', fieldname, tmpfile); // we really hope that the last stream closes after busboy finishes // this whole middleware can be refactored to guarantee this if (++count === total && finished) { total = -1; req.logger.trace('multipart files received:', req.files); // Prevent next being called if the request already ended if (!sent) { process.nextTick(next); } } }); file.pipe(stream); }); req.busboy.on('field', (fieldname, value, info) => { req.logger.trace('multipart field event:', { fieldname, value, info }); if (sent) { return; } if (info.valueTruncated) { // Multipart part size limit hit // Prevent middleware from continuing and send 413 response sent = true; onMultipartLimit(req, resp, config); return; } req.fields[fieldname] = { value }; }); req.busboy.on('finish', () => { req.logger.trace('multipart finished:', { files: Object.keys(req.files).length, fields: Object.keys(req.fields).length }); finished = true; // the request could have already ended (with 413) before busboy finished // in this case, don't continue to the next middleware. if (!sent && total === 0) { // there's no files being uploaded so call next. // if there are files then once the last one finishes writing, next // will be called when the last file stream closes instead. process.nextTick(next); } }); req.busboy.on('error', err => { const genErr = 'Failed to parse multipart/form-data body'; req.logger.error(genErr, err); req.unpipe(req.busboy); if (sent) { return; } sent = true; // set the response status to 400 only for multipart form data resp.status(400).send({ success: false, code: 400, message: genErr }); }); // If the request is terminated early, be sure to clean up because // after won't be emitted. const deleteRequestFiles = () => { // Timeout is in place because this after is emitted before the // request terminates. file uploads could actually still be in // operation (i.e. busboy could emit a file event after) setTimeout(function () { async.each(req.files, function (entry, cb) { req.logger.trace('multipart removing file:', entry.file); fs.unlink(entry.file, cb); }); req.files = null; }, 500); }; req.once('close', deleteRequestFiles); app.once('after', deleteRequestFiles); req.pipe(req.busboy); } else { next(); } }); app.use(bodyParser.raw({ limit, // bodyParser will set the Buffer to the body if the "type" function // returns a truthy value. type: req => { const contentType = req.header('content-type'); // don't process the body when content type is missing. // multipart/form-data content type is handled by busboy so we use // the same regex that busboy is using to detect supported types. const isHandledByBusboy = /^multipart\/form-data/i.test(contentType); if (!contentType || isHandledByBusboy) { return false; } return true; } })); app.use(function (req, resp, next) { if (req.body && _.isObject(req.body) && !(req.body instanceof Buffer)) { // we add to _params because express changes for each next req.params = req._params = _.merge(req.params, req.body); } else { req._params = req.params; } req.files && Object.keys(req.files).forEach(function (name) { req.params[name] = req.files[name].file; }); req.fields && Object.keys(req.fields).forEach(function (name) { req.params[name] = req.fields[name].value; }); next(); }); if (apiBuilderConfig.flags.enableStrictBodyPayloads) { app.use(function (req, resp, next) { if (parseInt(req.headers['content-length'], 10) > 0 && [ 'GET', 'HEAD' ].includes(req.method)) { // setting these prevents swagger-tools from handling the body in // swagger-metadata because the various middleware for parsing body // checks to see that these are undefined. req.files = {}; req.body = {}; req.logger.warn('Request with GET/HEAD method cannot have body, the body will be discarded.'); } next(); }); } app.use(compression()); app.on('after', function (req) { if (req && req.res) { req.res.req = null; } }); if (sessionConfig && sessionConfig.encryptionKey && sessionConfig.signatureKey) { setupSession(app, sessionConfig); } var writeHead = express.response._originalWriteHead || express.response.writeHead; if (!express.response._originalWriteHead) { express.response._originalWriteHead = writeHead; } express.response.writeHead = function () { // if the user by passes this class and does their own writeHead, end, etc. // we need to catch it since it's out of order the way we do it in response.js // so just flag it and when we see this below, just assume we're good to go var called = this.bodyFlushed; this.bodyFlushed = this._flushBodyCalled = true; writeHead.apply(this, arguments); if (!called) { // if we get here it's because we're doing a non-API Route // in which case we need to trigger after event app.emit('after', this.req, this.req.res); } }; // called by the response.js express.response._flushBody = function (content) { if (this.bodyFlushed) { return; } addEndHeaders(this.req, this, content); this.bodyFlushed = this._flushBodyCalled = true; this.end(content); // for us to log the response body, we have to store it. this.req.res.body = content; app.emit('after', this.req, this.req.res); }; var setHeader = express.response._originalSetHeader || express.response.setHeader; if (!express.response._originalSetHeader) { express.response._originalSetHeader = setHeader; } express.response.setHeader = function (k, v) { this._headers = this._headers || {}; if (v) { setHeader.apply(this, arguments); this._headers[k.toLowerCase()] = v; } else { this.removeHeader(k); } }; var removeHeader = express.response._originalRemoveHeader || express.response.removeHeader; if (!express.response._originalRemoveHeader) { express.response._originalRemoveHeader = removeHeader; } express.response.removeHeader = function (k) { removeHeader.apply(this, arguments); if (this._headers) { delete this._headers[k.toLowerCase()]; } }; express.response.headers = function () { return this._headers; }; Object.defineProperty(express.response, 'contentType', { set: function (value) { this.setHeader('Content-Type', value); }, get: function () { return this._headers['content-type'] || this._headers['Content-Type']; } }); var render = express.response._originalRender || express.response.render; if (!express.response._originalRender) { express.response._originalRender = render; } express.response.render = function (name, locals) { if (locals && _.isObject(locals)) { // make sure that we render any ORM objects as JSON instead of their real classes locals = _.cloneDeepWith(locals, objectCustomizer); } return render.apply(this, arguments); }; } /** * Handles a multipart limit when it occurs and returns a 413 response. * @param {object} req - request object * @param {object} resp - response object * @param {object} config - api builder config */ function onMultipartLimit(req, resp, config) { if (config.limits.multipartPartSize !== Infinity) { // It's possible to hit here if config.busboy is configured independently. // Can remove this check when we remove the deprecated config.busboy req.logger.info(`Multipart limit hit (${config.limits.multipartPartSize}B)`); } resp.status(413).send({ success: false, code: 413, 'request-id': req.getId(), message: 'Payload too large' }); } /* * Wrap response.send to add end headers. */ var expressResponseSend = express.response.send; express.response.send = function wrappedResponseSend(content) { if (_.isNumber(arguments[0])) { throw new Error('don\'t use resp.send with number. call resp.status instead'); } addEndHeaders(this.req, this, content); expressResponseSend.call(this, content); }; /* * convert our value into a valid JSON object */ function objectCustomizer(value) { if (value === undefined || value === null) { return value; } // return serialized JSON versions of our ORM data objects if (value instanceof APIBuilder.Instance || value instanceof APIBuilder.Collection || _.isFunction(value.toJSON)) { return value.toJSON(); } else if (Array.isArray(value)) { var array = []; value.forEach(function (o) { array.push(_.cloneDeepWith(o, objectCustomizer)); }); return array; } /* don't serialize functions*/ else if (_.isFunction(value)) { return undefined; } else if (_.isObject(value)) { var obj = {}; Object.keys(value).forEach(function (k) { var v = _.cloneDeepWith(value[k], objectCustomizer); obj[k] = v; }); return obj; } return value; } function addEndHeaders(req, resp, content) { const startTime = resp.getHeader('start-time'); if (startTime) { const started = parseInt(startTime, 10); resp.setHeader('Response-Time', `${Date.now() - started}`); } // req.server is undefined for both healthcheck APIs and requests that happen // when the server is shutting down. if (content && req.server && req.server.config.http.headers['content-md5']) { const hash = crypto.createHash('md5'); // REFACTOR: Has issues if content is object ([Object object]). Consider a future // breaking change to support more types i.e. https://github.com/puleos/object-hash // Also entirely broken for legacy APIs as the response contains the request ID im // the body, causing a new hash on every response. hash.update(String(content)); resp.setHeader('Content-MD5', hash.digest('hex')); } } function setupSession(app, sessionConfig) { app.use( sessions({ // load from configuration encryptionAlgorithm: sessionConfig.encryptionAlgorithm || 'aes256', encryptionKey: Buffer.from(sessionConfig.encryptionKey, 'base64'), signatureAlgorithm: sessionConfig.signatureAlgorithm || 'sha512-drop256', signatureKey: Buffer.from(sessionConfig.signatureKey, 'base64'), // cookie name dictates the key name added to the request object - do not changed this // or you will get an error cookieName: 'APIBuilder', // should be a large unguessable string secret: sessionConfig.secret || crypto.randomBytes(24).toString('base64'), // how long the session will stay valid in ms duration: sessionConfig.duration || 24 * 60 * 60 * 1000, // if expiresIn < activeDuration, the session will be extended by activeDuration // milliseconds activeDuration: sessionConfig.activeDuration || 1000 * 60 * 5, cookie: sessionConfig.cookie || {} }) ); app.use(function sessionMapper(req, resp, next) { // set the session value to the name of our sessions object req.session = req.APIBuilder; // alias destroy to reset req.session.destroy = req.APIBuilder.reset; try { next(); } catch (e) { // Ignore errors } }); } module.exports = Middleware;