UNPKG

actionhero

Version:

The reusable, scalable, and quick node.js API server for stateless and stateful applications

920 lines (831 loc) 28.4 kB
import * as http from "http"; import * as https from "https"; import * as url from "url"; import * as qs from "qs"; import * as fs from "fs"; import * as zlib from "zlib"; import * as path from "path"; import * as formidable from "formidable"; import * as Mime from "mime"; import * as uuid from "uuid"; import * as etag from "etag"; import { BrowserFingerprint } from "browser_fingerprint"; import { api, config, utils, Server, Connection } from "../index"; import { ActionsStatus, ActionProcessor } from "../classes/actionProcessor"; export class WebServer extends Server { server: http.Server | https.Server; fingerPrinter: BrowserFingerprint; sockets: { [id: string]: any }; constructor() { super(); this.type = "web"; this.sockets = {}; this.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 }; this.connectionCustomMethods = { setHeader: ( connection: Connection, key: string, value: string | number, ) => { connection.rawConnection.res.setHeader(key, value); }, setStatusCode: (connection: Connection, value: number) => { connection.rawConnection.responseHttpCode = value; }, pipe: ( connection: Connection, buffer: string | Buffer, headers: Record<string, string>, ) => { for (const k in headers) { connection.setHeader(k, headers[k]); } if (typeof buffer === "string") { buffer = Buffer.from(buffer); } connection.rawConnection.res.end(buffer); }, }; } async initialize() { if (["api", "file"].indexOf(this.config.rootEndpointType) < 0) { throw new Error("rootEndpointType can only be 'api' or 'file'"); } if ( !this.config.urlPathForFiles && this.config.rootEndpointType === "file" ) { throw new Error( 'rootEndpointType cannot be "file" without a urlPathForFiles', ); } this.fingerPrinter = new BrowserFingerprint(this.config.fingerprintOptions); } async start() { let bootAttempts = 0; if (this.config.secure === false) { this.server = http.createServer((req, res) => { this.handleRequest(req, res); }); } else { this.server = https.createServer( this.config.serverOptions, (req, res) => { this.handleRequest(req, res); }, ); } this.server.on("error", (error) => { bootAttempts++; if (bootAttempts < this.config.bootAttempts) { this.log(`cannot boot web server; trying again [${error}]`, "error"); if (bootAttempts === 1) { this.cleanSocket(this.config.bindIP, this.config.port); } setTimeout(() => { this.log("attempting to boot again.."); this.server.listen(this.config.port, this.config.bindIP); }, 1000); } else { throw new Error( `cannot start web server @ ${this.config.bindIP}:${this.config.port} => ${error}`, ); } }); let socketCounter = 0; this.server.on("connection", (socket) => { const id = socketCounter; this.sockets[id] = socket; socket.on("close", () => delete this.sockets[id]); socketCounter++; }); await new Promise((resolve) => { this.server.listen(this.config.port, this.config.bindIP, () => { this.chmodSocket(this.config.bindIP, this.config.port); resolve(null); }); }); this.on("connection", async (connection: Connection) => { const requestMode = await this.determineRequestParams(connection); if (requestMode === "api") { this.processAction(connection); } else if (requestMode === "file") { this.processFile(connection); } else if (requestMode === "options") { this.respondToOptions(connection); } else if (requestMode === "trace") { this.respondToTrace(connection); } }); this.on("actionComplete", this.completeResponse); } async stop() { if (!this.server) return; await new Promise((resolve) => { this.server.close(resolve); for (const socket of Object.values(this.sockets)) { socket.destroy(); } }); } async sendMessage(connection: Connection, message: string) { let stringResponse = ""; if (connection.rawConnection.method !== "HEAD") { stringResponse = String(message); } this.cleanHeaders(connection); const headers = connection.rawConnection.responseHeaders; const responseHttpCode = parseInt( connection.rawConnection.responseHttpCode, ); this.sendWithCompression( connection, responseHttpCode, headers, stringResponse, ); } async sendFile( connection: Connection, error: NodeJS.ErrnoException, fileStream: any, mime: string, length: number, lastModified: Date, ) { let foundCacheControl = false; let ifModifiedSince; connection.rawConnection.responseHeaders.forEach((pair: string[]) => { 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=" + this.config.flatFileCacheDuration + ", must-revalidate, public", ]); } } if (fileStream && !this.config.enableEtag) { if (lastModified) { connection.rawConnection.responseHeaders.push([ "Last-Modified", new Date(lastModified).toUTCString(), ]); } } this.cleanHeaders(connection); const headers = connection.rawConnection.responseHeaders; const reqHeaders = connection.rawConnection.req.headers; const sendRequestResult = () => { const responseHttpCode = parseInt( connection.rawConnection.responseHttpCode, 10, ); if (error) { this.sendWithCompression( connection, responseHttpCode, headers, String(error), ); } else if (responseHttpCode !== 304) { this.sendWithCompression( connection, responseHttpCode, headers, null, fileStream, length, ); } else { connection.rawConnection.res.writeHead( responseHttpCode, this.transformHeaders(headers), ); connection.rawConnection.res.end(); connection.destroy(); fileStream.close(); } }; 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 (this.config.enableEtag && fileStream && fileStream.path) { const fileStats: fs.Stats = await new Promise((resolve) => { fs.stat(fileStream.path, (error, fileStats) => { if (error || !fileStats) { this.log( "Error receving file statistics: " + String(error), "error", ); } return resolve(fileStats); }); }); if (!fileStats) return sendRequestResult(); const fileEtag = etag(fileStats, { weak: true }); connection.rawConnection.responseHeaders.push(["ETag", fileEtag]); let noneMatchHeader = reqHeaders["if-none-match"]; const 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: string) => { return ( match === "*" || match === fileEtag || match === "W/" + fileEtag ); }); } if (etagMatches && !noCache) { connection.rawConnection.responseHttpCode = 304; } sendRequestResult(); } else { sendRequestResult(); } } sendWithCompression( connection: Connection, responseHttpCode: number, headers: Array<[string, string | number]>, stringResponse: string, fileStream?: any, fileLength?: number, ) { let acceptEncoding = connection.rawConnection.req.headers["accept-encoding"]; let compressor; let stringEncoder; if (!acceptEncoding) { acceptEncoding = ""; } // Note: this is not a conforming 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 (this.config.compress === true) { const gzipMatch = acceptEncoding.match(/\bgzip\b/); const 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 denotes a successful transfer connection.rawConnection.res.on("finish", () => { connection.destroy(); }); // the 'close' event denotes a failed transfer, but it is probably the client's fault connection.rawConnection.res.on("close", () => { connection.destroy(); }); if (fileStream) { if (compressor) { connection.rawConnection.res.writeHead( responseHttpCode, this.transformHeaders(headers), ); fileStream.pipe(compressor).pipe(connection.rawConnection.res); } else { if (fileLength) { headers.push(["Content-Length", fileLength]); } connection.rawConnection.res.writeHead( responseHttpCode, this.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, this.transformHeaders(headers), ); connection.rawConnection.res.end(zippedString); }); } else { headers.push(["Content-Length", Buffer.byteLength(stringResponse)]); connection.rawConnection.res.writeHead( responseHttpCode, this.transformHeaders(headers), ); connection.rawConnection.res.end(stringResponse); } } } handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { const { fingerprint, headersHash, }: { fingerprint: string; headersHash: Record<string, string> } = this.fingerPrinter.fingerprint(req); const responseHeaders = []; const cookies = utils.parseCookies(req); const responseHttpCode = 200; const method = req.method.toUpperCase(); // waiting until URL() can handle relative paths // https://github.com/nodejs/node/issues/12682 const parsedURL = url.parse(req.url, true); let i; for (i in headersHash) { responseHeaders.push([i, headersHash[i]]); } // https://github.com/actionhero/actionhero/issues/189 responseHeaders.push(["Content-Type", "application/json; charset=utf-8"]); for (i in this.config.httpHeaders) { if (this.config.httpHeaders[i]) { responseHeaders.push([i, this.config.httpHeaders[i]]); } } // check if this request (http://other-host.com) is in allowedRequestHosts ([https://host.com]) if ( this.config.allowedRequestHosts && this.config.allowedRequestHosts.length > 0 ) { const requestHost = req.headers["x-forwarded-proto"] ? req.headers["x-forwarded-proto"] + "://" + req.headers.host : (this.config.secure ? "https://" : "http://") + req.headers.host; if (!this.config.allowedRequestHosts.includes(requestHost)) { const newHost = this.config.allowedRequestHosts[0]; res.statusCode = 302; res.setHeader("Location", newHost + req.url); return res.end(`You are being redirected to ${newHost + req.url}\r\n`); } } const { ip, port } = utils.parseHeadersForClientAddress(req.headers); const messageId = uuid.v4(); this.buildConnection({ rawConnection: { req: req, res: res, params: {}, method: method, cookies: cookies, responseHeaders: responseHeaders, responseHttpCode: responseHttpCode, parsedURL: parsedURL, }, id: `${fingerprint}-${messageId}`, messageId: messageId, fingerprint: fingerprint, remoteAddress: ip || req.connection.remoteAddress || "0.0.0.0", remotePort: port || req.connection.remotePort || "0", }); } async completeResponse(data: ActionProcessor<any>) { 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 ( this.config.metadataOptions.serverInformation && typeof data.response !== "string" ) { data.response.serverInformation = this.buildServerInformation( data.connection.connectedAt, ); } if ( this.config.metadataOptions.requesterInformation && typeof data.response !== "string" ) { data.response.requesterInformation = this.buildRequesterInformation( data.connection, ); } if (data.response.error) { if ( this.config.returnErrorCodes === true && data.connection.rawConnection.responseHttpCode === 200 ) { const customErrorCode = parseInt(data.response.error.code, 10); const isValidCustomResponseCode = customErrorCode >= 100 && customErrorCode < 600; if (isValidCustomResponseCode) { data.connection.rawConnection.responseHttpCode = customErrorCode; } else if (data.actionStatus === ActionsStatus.UnknownAction) { data.connection.rawConnection.responseHttpCode = 404; } else if (data.actionStatus === ActionsStatus.MissingParams) { data.connection.rawConnection.responseHttpCode = 422; } else { data.connection.rawConnection.responseHttpCode = this.config.defaultErrorStatusCode ?? 500; } } } if ( !data.response.error && data.action && data.params.apiVersion && api.actions.actions[data.params.action][data.params.apiVersion] .matchExtensionMimeType === true && data.connection.extension ) { const mime = Mime.getType(data.connection.extension); if (mime) { data.connection.rawConnection.responseHeaders.push([ "Content-Type", mime, ]); } } if (data.response.error) { data.response.error = await config.errors.serializers.servers.web( data.response.error, ); } let stringResponse = ""; if (this.extractHeader(data.connection, "Content-Type").match(/json/)) { stringResponse = JSON.stringify(data.response, null, this.config.padding); if (data.params.callback) { data.connection.rawConnection.responseHeaders.push([ "Content-Type", "application/javascript", ]); stringResponse = this.callbackHtmlEscape(data.connection.params.callback) + "(" + stringResponse + ");"; } } else { stringResponse = data.response as unknown as string; } this.sendMessage(data.connection, stringResponse); } extractHeader(connection: Connection, match: string) { 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; } respondToOptions(connection: Connection) { if ( !this.config.httpHeaders["Access-Control-Allow-Methods"] && !this.extractHeader(connection, "Access-Control-Allow-Methods") ) { const methods = "HEAD, GET, POST, PATCH, PUT, DELETE, OPTIONS, TRACE"; connection.rawConnection.responseHeaders.push([ "Access-Control-Allow-Methods", methods, ]); } if ( !this.config.httpHeaders["Access-Control-Allow-Origin"] && !this.extractHeader(connection, "Access-Control-Allow-Origin") ) { const origin = "*"; connection.rawConnection.responseHeaders.push([ "Access-Control-Allow-Origin", origin, ]); } this.sendMessage(connection, ""); } respondToTrace(connection: Connection) { const data = this.buildRequesterInformation(connection); const stringResponse = JSON.stringify(data, null, this.config.padding); this.sendMessage(connection, stringResponse); } async determineRequestParams(connection: Connection) { // determine file or api request let requestMode = this.config.rootEndpointType; const pathname = connection.rawConnection.parsedURL.pathname; const pathParts = pathname.split("/"); let i; while (pathParts[0] === "") { pathParts.shift(); } if (pathParts[pathParts.length - 1] === "") { pathParts.pop(); } let urlPathForActionsParts = []; if (this.config.urlPathForActions) { urlPathForActionsParts = this.config.urlPathForActions.split("/"); while (urlPathForActionsParts[0] === "") { urlPathForActionsParts.shift(); } } let urlPathForFilesParts = []; if (this.config.urlPathForFiles) { urlPathForFilesParts = this.config.urlPathForFiles.split("/"); while (urlPathForFilesParts[0] === "") { urlPathForFilesParts.shift(); } } if ( pathParts[0] && utils.arrayStartingMatch(urlPathForActionsParts, pathParts) ) { requestMode = "api"; for (i = 0; i < urlPathForActionsParts.length; i++) { pathParts.shift(); } } else if ( pathParts[0] && utils.arrayStartingMatch(urlPathForFilesParts, pathParts) ) { requestMode = "file"; for (i = 0; i < urlPathForFilesParts.length; i++) { pathParts.shift(); } } const extensionParts = connection.rawConnection.parsedURL.pathname.split("."); if (extensionParts.length > 1) { connection.extension = extensionParts[extensionParts.length - 1]; } // OPTIONS if (connection.rawConnection.method === "OPTIONS") { requestMode = "options"; return requestMode; } // API if (requestMode === "api") { if (connection.rawConnection.method === "TRACE") { requestMode = "trace"; } let search = ""; if (connection.rawConnection.parsedURL.search) { search = connection.rawConnection.parsedURL.search.slice(1); } this.fillParamsFromWebRequest( connection, qs.parse(search, this.config.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(); if (this.config?.formOptions) { for (i in this.config.formOptions) { connection.rawConnection.form.options[i] = this.config.formOptions[i]; } } let rawBody = Promise.resolve(Buffer.alloc(0)); if (this.config.saveRawBody) { rawBody = new Promise((resolve, reject) => { let fullBody = Buffer.alloc(0); connection.rawConnection.req .on("data", (chunk: Uint8Array) => { fullBody = Buffer.concat([fullBody, chunk]); }) .on("end", () => { resolve(fullBody); }); }); } const { fields, files } = (await new Promise((resolve) => { connection.rawConnection.form.parse( connection.rawConnection.req, ( error: NodeJS.ErrnoException, fields: string[], files: string[], ) => { if (error) { this.log("error processing form: " + String(error), "error"); connection.error = new Error( "There was an error processing this form.", ); } // this is for backward compatibility formidable v3 and v2, // because in v3 was deleted `multiples` option and mechanism const isMultiples = Boolean(this.config?.formOptions?.multiples); if (isMultiples) { resolve({ fields, files }); } else { // reimplementing firstValues values helper // @see https://github.com/node-formidable/formidable/blob/master/src/helpers/firstValues.js // but instead of first we are taking last values, mimicking v2 behavior const lastValues = (val: Record<string, any>) => { return Object.fromEntries( Object.entries(val).map(([key, value]) => { return [key, Array.isArray(value) ? value.at(-1) : value]; }), ); }; resolve({ // @ts-expect-error wrong result type fields: lastValues(fields), // @ts-expect-error wrong result type files: lastValues(files), }); } }, ); // looks like wrong types here })) as { fields: string[]; files: string[] }; connection.rawConnection.params.body = fields; connection.rawConnection.params.rawBody = await rawBody; connection.rawConnection.params.files = files; this.fillParamsFromWebRequest(connection, files); this.fillParamsFromWebRequest(connection, fields); connection.params.action = null; api.routes.processRoute(connection, pathParts); return requestMode; } else { connection.params.action = null; api.routes.processRoute(connection, pathParts); return requestMode; } } // FILE 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 + config.general.directoryFileType; } try { connection.params.file = decodeURIComponent(connection.params.file); } catch (e) { connection.error = new Error("There was an error decoding URI: " + e); } return requestMode; } } fillParamsFromWebRequest( connection: Connection, varsHash: Record<string, any>, ) { // helper for JSON posts const collapsedVarsHash = utils.collapseObjectToArray(varsHash); if (collapsedVarsHash !== false) { varsHash = { payload: collapsedVarsHash }; // post was an array, lets call it "payload" } for (const v in varsHash) { connection.params[v] = varsHash[v]; } } transformHeaders(headersArray: Array<[string, string | number]>) { return headersArray.reduce( (headers: Record<string, string[]>, currentHeader) => { const 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].toString()); } else { headers[currentHeaderKey] = [currentHeader[1].toString()]; } } else { headers[currentHeaderKey] = [currentHeader[1].toString()]; } return headers; }, {}, ); } buildServerInformation(connectedAt: number) { const stopTime = new Date().getTime(); return { serverName: config.general.serverName, apiVersion: config.general.apiVersion, requestDuration: stopTime - connectedAt, currentTime: stopTime, }; } buildRequesterInformation(connection: Connection) { const requesterInformation = { id: connection.id, fingerprint: connection.fingerprint, messageId: connection.messageId, remoteIP: connection.remoteIP, receivedParams: {} as { [key: string]: any }, }; for (const p in connection.params) { if ( config.general.disableParamScrubbing === true || api.params.postVariables.indexOf(p) >= 0 ) { requesterInformation.receivedParams[p] = connection.params[p]; } } return requesterInformation; } cleanHeaders(connection: Connection) { const originalHeaders = connection.rawConnection.responseHeaders.reverse(); const foundHeaders = []; const cleanedHeaders = []; for (const i in originalHeaders) { const key = originalHeaders[i][0]; const 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; } cleanSocket(bindIP: string | number, port: string | number) { if (!bindIP && typeof port === "string" && port.indexOf("/") >= 0) { fs.unlink(port, (error) => { if (error) { this.log(`cannot remove stale socket @ ${port}: ${error}`, "error"); } else { this.log(`removed stale unix socket @ ${port}`); } }); } } chmodSocket(bindIP: string | number, port: string | number) { if (!bindIP && typeof port === "string" && port.indexOf("/") >= 0) { fs.chmodSync(port, "0777"); } } callbackHtmlEscape(s: string) { return s .replace(/&/g, "&amp;") .replace(/"/g, "&quot;") .replace(/'/g, "&#39;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/\)/g, "") .replace(/\(/g, ""); } }