actionhero
Version:
actionhero.js is a multi-transport API Server with integrated cluster capabilities and delayed tasks
659 lines (579 loc) • 24.1 kB
JavaScript
const url = require('url')
const qs = require('qs')
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
const formidable = require('formidable')
const browserFingerprint = require('browser_fingerprint')
const Mime = require('mime')
const uuid = require('uuid')
const etag = require('etag')
const initialize = function (api, options, next) {
// ////////
// INIT //
// ////////
const type = 'web'
const attributes = {
canChat: false,
logConnections: false,
logExits: false,
sendWelcomeMessage: false,
verbs: [
// no verbs for connections of this type, as they are to be very short-lived
]
}
const server = new api.GenericServer(type, options, attributes)
if (['api', 'file'].indexOf(api.config.servers.web.rootEndpointType) < 0) {
throw new Error('api.config.servers.web.rootEndpointType can only be \'api\' or \'file\'')
}
// ////////////////////
// REQUIRED METHODS //
// ////////////////////
server.start = function (next) {
if (options.secure === false) {
const http = require('http')
server.server = http.createServer((req, res) => {
handleRequest(req, res)
})
} else {
const https = require('https')
server.server = https.createServer(api.config.servers.web.serverOptions, (req, res) => {
handleRequest(req, res)
})
}
let bootAttempts = 0
server.server.on('error', (e) => {
bootAttempts++
if (bootAttempts < api.config.servers.web.bootAttempts) {
server.log('cannot boot web server; trying again [' + String(e) + ']', 'error')
if (bootAttempts === 1) { cleanSocket(options.bindIP, options.port) }
setTimeout(() => {
server.log('attempting to boot again..')
server.server.listen(options.port, options.bindIP)
}, 1000)
} else {
return next(new Error('cannot start web server @ ' + options.bindIP + ':' + options.port + ' => ' + e.message))
}
})
server.server.listen(options.port, options.bindIP, () => {
chmodSocket(options.bindIP, options.port)
next()
})
}
server.stop = function (next) {
if (server.server) { server.server.close() }
process.nextTick(next)
}
server.sendMessage = function (connection, message) {
let stringResponse = ''
if (connection.rawConnection.method !== 'HEAD') {
stringResponse = String(message)
}
cleanHeaders(connection)
const headers = connection.rawConnection.responseHeaders
const responseHttpCode = parseInt(connection.rawConnection.responseHttpCode)
server.sendWithCompression(connection, responseHttpCode, headers, stringResponse)
}
server.sendFile = function (connection, error, fileStream, mime, length, lastModified) {
let foundCacheControl = false
let ifModifiedSince
let reqHeaders
connection.rawConnection.responseHeaders.forEach((pair) => {
if (pair[0].toLowerCase() === 'cache-control') { foundCacheControl = true }
})
connection.rawConnection.responseHeaders.push(['Content-Type', mime])
if (fileStream) {
if (!foundCacheControl) { connection.rawConnection.responseHeaders.push(['Cache-Control', 'max-age=' + api.config.servers.web.flatFileCacheDuration + ', must-revalidate, public']) }
}
if (fileStream && !api.config.servers.web.enableEtag) {
if (lastModified) { connection.rawConnection.responseHeaders.push(['Last-Modified', new Date(lastModified).toUTCString()]) }
}
cleanHeaders(connection)
const headers = connection.rawConnection.responseHeaders
reqHeaders = connection.rawConnection.req.headers
let sendRequestResult = function () {
let responseHttpCode = parseInt(connection.rawConnection.responseHttpCode, 10)
if (error) {
server.sendWithCompression(connection, responseHttpCode, headers, String(error))
} else if (responseHttpCode !== 304) {
server.sendWithCompression(connection, responseHttpCode, headers, null, fileStream, length)
} else {
connection.rawConnection.res.writeHead(responseHttpCode, transformHeaders(headers))
connection.rawConnection.res.end()
connection.destroy()
}
}
if (error) {
connection.rawConnection.responseHttpCode = 404
return sendRequestResult()
}
if (reqHeaders['if-modified-since']) {
ifModifiedSince = new Date(reqHeaders['if-modified-since'])
lastModified.setMilliseconds(0)
if (lastModified <= ifModifiedSince) { connection.rawConnection.responseHttpCode = 304 }
return sendRequestResult()
}
if (api.config.servers.web.enableEtag && fileStream && fileStream.path) {
fs.stat(fileStream.path, (error, filestats) => {
if (error) {
server.log('Error receving file statistics: ' + String(error), 'error')
return sendRequestResult()
}
const fileEtag = etag(filestats, {weak: true})
connection.rawConnection.responseHeaders.push(['ETag', fileEtag])
let noneMatchHeader = reqHeaders['if-none-match']
let cacheCtrlHeader = reqHeaders['cache-control']
let noCache = false
let etagMatches
// check for no-cache cache request directive
if (cacheCtrlHeader && cacheCtrlHeader.indexOf('no-cache') !== -1) {
noCache = true
}
// parse if-none-match
if (noneMatchHeader) { noneMatchHeader = noneMatchHeader.split(/ *, */) }
// if-none-match
if (noneMatchHeader) {
etagMatches = noneMatchHeader.some((match) => {
return match === '*' || match === fileEtag || match === 'W/' + fileEtag
})
}
if (etagMatches && !noCache) {
connection.rawConnection.responseHttpCode = 304
}
sendRequestResult()
})
} else {
sendRequestResult()
}
}
server.sendWithCompression = function (connection, responseHttpCode, headers, stringResponse, fileStream, fileLength) {
let acceptEncoding = connection.rawConnection.req.headers['accept-encoding']
let compressor
let stringEncoder
if (!acceptEncoding) { acceptEncoding = '' }
// Note: this is not a conformant accept-encoding parser.
// https://nodejs.org/api/zlib.html#zlib_zlib_createinflate_options
// See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
if (api.config.servers.web.compress === true) {
let gzipMatch = acceptEncoding.match(/\bgzip\b/)
let deflateMatch = acceptEncoding.match(/\bdeflate\b/)
if ((gzipMatch && !deflateMatch) || (gzipMatch && deflateMatch && gzipMatch.index < deflateMatch.index)) {
headers.push(['Content-Encoding', 'gzip'])
compressor = zlib.createGzip()
stringEncoder = zlib.gzip
} else if ((!gzipMatch && deflateMatch) || (gzipMatch && deflateMatch && deflateMatch.index < gzipMatch.index)) {
headers.push(['Content-Encoding', 'deflate'])
compressor = zlib.createDeflate()
stringEncoder = zlib.deflate
}
}
// the 'finish' event deontes a successful transfer
connection.rawConnection.res.on('finish', () => {
connection.destroy()
})
// the 'close' event deontes a failed transfer, but it is probably the client's fault
connection.rawConnection.res.on('close', () => {
connection.destroy()
})
if (fileStream) {
if (compressor) {
// headers.push(['Content-Length', fileLength]); // TODO
connection.rawConnection.res.writeHead(responseHttpCode, transformHeaders(headers))
fileStream.pipe(compressor).pipe(connection.rawConnection.res)
} else {
// file length might be null if we don't know how long the stream is
if (fileLength) {
headers.push(['Content-Length', fileLength])
}
connection.rawConnection.res.writeHead(responseHttpCode, transformHeaders(headers))
fileStream.pipe(connection.rawConnection.res)
}
} else {
if (stringEncoder) {
stringEncoder(stringResponse, (error, zippedString) => {
if (error) { console.error(error) }
headers.push(['Content-Length', zippedString.length])
connection.rawConnection.res.writeHead(responseHttpCode, transformHeaders(headers))
connection.rawConnection.res.end(zippedString)
})
} else {
headers.push(['Content-Length', Buffer.byteLength(stringResponse)])
connection.rawConnection.res.writeHead(responseHttpCode, transformHeaders(headers))
connection.rawConnection.res.end(stringResponse)
}
}
}
server.goodbye = function () {
// disconnect handlers
}
server.connectionCustomMethods = {
setHeader: function (connection, key, value) {
connection.rawConnection.res.setHeader(key, value)
}
}
// //////////
// EVENTS //
// //////////
server.on('connection', (connection) => {
determineRequestParams(connection, (requestMode) => {
if (requestMode === 'api') {
server.processAction(connection)
} else if (requestMode === 'file') {
server.processFile(connection)
} else if (requestMode === 'options') {
respondToOptions(connection)
} else if (requestMode === 'trace') {
respondToTrace(connection)
}
})
})
server.on('actionComplete', (data) => {
completeResponse(data)
})
// ///////////
// HELPERS //
// ///////////
const handleRequest = function (req, res) {
browserFingerprint.fingerprint(req, api.config.servers.web.fingerprintOptions, (error, fingerprint, elementHash, cookieHash) => {
if (error) throw error
let responseHeaders = []
let cookies = api.utils.parseCookies(req)
let responseHttpCode = 200
let method = req.method.toUpperCase()
let parsedURL = url.parse(req.url, true)
let i
for (i in cookieHash) {
responseHeaders.push([i, cookieHash[i]])
}
// https://github.com/actionhero/actionhero/issues/189
responseHeaders.push(['Content-Type', 'application/json; charset=utf-8'])
for (i in api.config.servers.web.httpHeaders) {
if (api.config.servers.web.httpHeaders[i]) {
responseHeaders.push([i, api.config.servers.web.httpHeaders[i]])
}
}
let remoteIP = req.connection.remoteAddress
let remotePort = req.connection.remotePort
// helpers for unix socket bindings with no forward
if (!remoteIP && !remotePort) {
remoteIP = '0.0.0.0'
remotePort = '0'
}
if (req.headers['x-forwarded-for']) {
let parts
let forwardedIp = req.headers['x-forwarded-for'].split(',')[0]
if (forwardedIp.indexOf('.') >= 0 || (forwardedIp.indexOf('.') < 0 && forwardedIp.indexOf(':') < 0)) {
// IPv4
forwardedIp = forwardedIp.replace('::ffff:', '') // remove any IPv6 information, ie: '::ffff:127.0.0.1'
parts = forwardedIp.split(':')
if (parts[0]) { remoteIP = parts[0] }
if (parts[1]) { remotePort = parts[1] }
} else {
// IPv6
parts = api.utils.parseIPv6URI(forwardedIp)
if (parts.host) { remoteIP = parts.host }
if (parts.port) { remotePort = parts.port }
}
if (req.headers['x-forwarded-port']) {
remotePort = req.headers['x-forwarded-port']
}
}
if (api.config.servers.web.allowedRequestHosts && api.config.servers.web.allowedRequestHosts.length > 0) {
let guess = 'http://'
if (options.secure) { guess = 'https://' }
let fullRequestHost = (req.headers['x-forwarded-proto'] ? req.headers['x-forwarded-proto'] + '://' : guess) + req.headers.host
if (api.config.servers.web.allowedRequestHosts.indexOf(fullRequestHost) < 0) {
let newHost = api.config.servers.web.allowedRequestHosts[0]
res.statusCode = 302
res.setHeader('Location', newHost + req.url)
return res.end(`You are being redirected to ${newHost + req.url}\r\n`)
}
}
server.buildConnection({
// will emit 'connection'
rawConnection: {
req: req,
res: res,
params: {},
method: method,
cookies: cookies,
responseHeaders: responseHeaders,
responseHttpCode: responseHttpCode,
parsedURL: parsedURL
},
id: fingerprint + '-' + uuid.v4(),
fingerprint: fingerprint,
remoteAddress: remoteIP,
remotePort: remotePort
})
})
}
const completeResponse = function (data) {
if (data.toRender !== true) {
if (data.connection.rawConnection.res.finished) {
data.connection.destroy()
} else {
data.connection.rawConnection.res.on('finish', () => data.connection.destroy())
data.connection.rawConnection.res.on('close', () => data.connection.destroy())
}
return
}
if (api.config.servers.web.metadataOptions.serverInformation && typeof data.response !== 'string') {
const stopTime = new Date().getTime()
data.response.serverInformation = {
serverName: api.config.general.serverName,
apiVersion: api.config.general.apiVersion,
requestDuration: (stopTime - data.connection.connectedAt),
currentTime: stopTime
}
}
if (api.config.servers.web.metadataOptions.requesterInformation && typeof data.response !== 'string') {
data.response.requesterInformation = buildRequesterInformation(data.connection)
}
if (data.response.error) {
if (api.config.servers.web.returnErrorCodes === true && data.connection.rawConnection.responseHttpCode === 200) {
if (data.actionStatus === 'unknown_action') {
data.connection.rawConnection.responseHttpCode = 404
} else if (data.actionStatus === 'missing_params') {
data.connection.rawConnection.responseHttpCode = 422
} else if (data.actionStatus === 'server_error') {
data.connection.rawConnection.responseHttpCode = 500
} else {
data.connection.rawConnection.responseHttpCode = 400
}
}
}
if (
!data.response.error &&
data.action &&
data.params.apiVersion &&
api.actions.actions[data.params.action][data.params.apiVersion].matchExtensionMimeType === true &&
data.connection.extension
) {
data.connection.rawConnection.responseHeaders.push(['Content-Type', Mime.lookup(data.connection.extension)])
}
if (data.response.error) {
data.response.error = api.config.errors.serializers.servers.web(data.response.error)
}
let stringResponse = ''
if (extractHeader(data.connection, 'Content-Type').match(/json/)) {
stringResponse = JSON.stringify(data.response, null, api.config.servers.web.padding)
if (data.params.callback) {
data.connection.rawConnection.responseHeaders.push(['Content-Type', 'application/javascript'])
stringResponse = callbackHtmlEscape(data.connection.params.callback) + '(' + stringResponse + ');'
}
} else {
stringResponse = data.response
}
server.sendMessage(data.connection, stringResponse)
}
const extractHeader = function (connection, match) {
let i = connection.rawConnection.responseHeaders.length - 1
while (i >= 0) {
if (connection.rawConnection.responseHeaders[i][0].toLowerCase() === match.toLowerCase()) {
return connection.rawConnection.responseHeaders[i][1]
}
i--
}
return null
}
const respondToOptions = function (connection) {
if (!api.config.servers.web.httpHeaders['Access-Control-Allow-Methods'] && !extractHeader(connection, 'Access-Control-Allow-Methods')) {
const methods = 'HEAD, GET, POST, PUT, DELETE, OPTIONS, TRACE'
connection.rawConnection.responseHeaders.push(['Access-Control-Allow-Methods', methods])
}
if (!api.config.servers.web.httpHeaders['Access-Control-Allow-Origin'] && !extractHeader(connection, 'Access-Control-Allow-Origin')) {
const origin = '*'
connection.rawConnection.responseHeaders.push(['Access-Control-Allow-Origin', origin])
}
server.sendMessage(connection, '')
}
const respondToTrace = function (connection) {
const data = buildRequesterInformation(connection)
const stringResponse = JSON.stringify(data, null, api.config.servers.web.padding)
server.sendMessage(connection, stringResponse)
}
const determineRequestParams = function (connection, callback) {
// determine file or api request
let requestMode = api.config.servers.web.rootEndpointType
let pathname = connection.rawConnection.parsedURL.pathname
let pathParts = pathname.split('/')
let matcherLength
let i
while (pathParts[0] === '') { pathParts.shift() }
if (pathParts[pathParts.length - 1] === '') { pathParts.pop() }
if (pathParts[0] && pathParts[0] === api.config.servers.web.urlPathForActions) {
requestMode = 'api'
pathParts.shift()
} else if (pathParts[0] && pathParts[0] === api.config.servers.web.urlPathForFiles) {
requestMode = 'file'
pathParts.shift()
} else if (pathParts[0] && pathname.indexOf(api.config.servers.web.urlPathForActions) === 0) {
requestMode = 'api'
matcherLength = api.config.servers.web.urlPathForActions.split('/').length
for (i = 0; i < (matcherLength - 1); i++) { pathParts.shift() }
} else if (pathParts[0] && pathname.indexOf(api.config.servers.web.urlPathForFiles) === 0) {
requestMode = 'file'
matcherLength = api.config.servers.web.urlPathForFiles.split('/').length
for (i = 0; i < (matcherLength - 1); i++) { pathParts.shift() }
}
let extensionParts = connection.rawConnection.parsedURL.pathname.split('.')
if (extensionParts.length > 1) {
connection.extension = extensionParts[(extensionParts.length - 1)]
}
// OPTIONS
if (connection.rawConnection.method === 'OPTIONS') {
requestMode = 'options'
callback(requestMode)
// API
} else if (requestMode === 'api') {
if (connection.rawConnection.method === 'TRACE') { requestMode = 'trace' }
let search = ''
if (connection.rawConnection.parsedURL.search) {
search = connection.rawConnection.parsedURL.search.slice(1)
}
fillParamsFromWebRequest(connection, qs.parse(search, api.config.servers.web.queryParseOptions))
connection.rawConnection.params.query = connection.rawConnection.parsedURL.query
if (
connection.rawConnection.method !== 'GET' &&
connection.rawConnection.method !== 'HEAD' &&
(
connection.rawConnection.req.headers['content-type'] ||
connection.rawConnection.req.headers['Content-Type']
)
) {
connection.rawConnection.form = new formidable.IncomingForm()
for (i in api.config.servers.web.formOptions) {
connection.rawConnection.form[i] = api.config.servers.web.formOptions[i]
}
let rawBody = Buffer.alloc(0)
if (api.config.servers.web.saveRawBody) {
connection.rawConnection.req.on('data', (chunk) => { rawBody = Buffer.concat([rawBody, chunk]) })
}
connection.rawConnection.form.parse(connection.rawConnection.req, (error, fields, files) => {
if (error) {
server.log('error processing form: ' + String(error), 'error')
connection.error = new Error('There was an error processing this form.')
} else {
connection.rawConnection.params.body = fields
connection.rawConnection.params.rawBody = rawBody
connection.rawConnection.params.files = files
fillParamsFromWebRequest(connection, files)
fillParamsFromWebRequest(connection, fields)
}
if (api.config.servers.web.queryRouting !== true) { connection.params.action = null }
api.routes.processRoute(connection, pathParts)
callback(requestMode)
})
} else {
if (api.config.servers.web.queryRouting !== true) { connection.params.action = null }
api.routes.processRoute(connection, pathParts)
callback(requestMode)
}
// FILE
} else if (requestMode === 'file') {
api.routes.processRoute(connection, pathParts)
if (!connection.params.file) {
connection.params.file = pathParts.join(path.sep)
}
if (connection.params.file === '' || connection.params.file[connection.params.file.length - 1] === '/') {
connection.params.file = connection.params.file + api.config.general.directoryFileType
}
try {
connection.params.file = decodeURIComponent(connection.params.file)
} catch (e) {
connection.error = new Error('There was an error decoding URI: ' + e)
}
callback(requestMode)
}
}
const fillParamsFromWebRequest = function (connection, varsHash) {
// helper for JSON posts
let collapsedVarsHash = api.utils.collapseObjectToArray(varsHash)
if (collapsedVarsHash !== false) {
varsHash = {payload: collapsedVarsHash} // post was an array, lets call it "payload"
}
for (let v in varsHash) {
connection.params[v] = varsHash[v]
}
}
const transformHeaders = function (headersArray) {
return headersArray.reduce((headers, currentHeader) => {
let currentHeaderKey = currentHeader[0].toLowerCase()
// we have a set-cookie, let's see what we have to do
if (currentHeaderKey === 'set-cookie') {
if (headers[currentHeaderKey]) {
headers[currentHeaderKey].push(currentHeader[1])
} else {
headers[currentHeaderKey] = [currentHeader[1]]
}
} else {
headers[currentHeaderKey] = currentHeader[1]
}
return headers
}, {})
}
const buildRequesterInformation = function (connection) {
let requesterInformation = {
id: connection.id,
fingerprint: connection.fingerprint,
remoteIP: connection.remoteIP,
receivedParams: {}
}
for (let p in connection.params) {
if (api.config.general.disableParamScrubbing === true || api.params.postVariables.indexOf(p) >= 0) {
requesterInformation.receivedParams[p] = connection.params[p]
}
}
return requesterInformation
}
const cleanHeaders = function (connection) {
const originalHeaders = connection.rawConnection.responseHeaders.reverse()
let foundHeaders = []
let cleanedHeaders = []
for (let i in originalHeaders) {
let key = originalHeaders[i][0]
let value = originalHeaders[i][1]
if (foundHeaders.indexOf(key.toLowerCase()) >= 0 && key.toLowerCase().indexOf('set-cookie') < 0) {
// ignore, it's a duplicate
} else if (connection.rawConnection.method === 'HEAD' && key === 'Transfer-Encoding') {
// ignore, we can't send this header for HEAD requests
} else {
foundHeaders.push(key.toLowerCase())
cleanedHeaders.push([key, value])
}
}
connection.rawConnection.responseHeaders = cleanedHeaders
}
const cleanSocket = function (bindIP, port) {
if (!bindIP && typeof port === 'string' && port.indexOf('/') >= 0) {
fs.unlink(port, (error) => {
if (error) {
server.log('cannot remove stale socket @' + port + ' : ' + error)
} else {
server.log('removed stale unix socket @ ' + port)
}
})
}
}
const chmodSocket = function (bindIP, port) {
if (!bindIP && typeof port === 'string' && port.indexOf('/') >= 0) {
fs.chmodSync(port, '0777')
}
}
next(server)
}
function callbackHtmlEscape (str) {
return str
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\)/g, '')
.replace(/\(/g, '')
}
// ///////////////////////////////////////////////////////////////////
// exports
exports.initialize = initialize