import { gunzipSync } from "zlib"; import { parse as queryParse } from "querystring"; import { BadRequestError, checkEnvVariables } from "../common.js"; import { Request, Response, Handler, SchemaProperties, ParserMode } from "../types.js"; import { Parser, ParserInterface } from "@miqro/parser"; /**CORS */ const DEFAULT_CORS_ORIGINS = "*"; const DEFAULT_CORS_METHODS = "GET,HEAD,PUT,PATCH,POST,DELETE"; const DEFAULT_CORS_PREFLIGHT_CONTINUE = false; export interface CORSOptions { origins: string[] | string | undefined; validate?: (origin: string | undefined, origins: string[] | string) => boolean; methods?: string; preflightContinue?: boolean; } const isOriginAllowed = (origin: string | undefined, origins: string[] | string): boolean => { if (origins instanceof Array) { for (const o of origins) { if (origin === o || o === "*") { return true; } } return false; } else { return origin === origins; } }; export const CORS = (options?: CORSOptions): Handler => { let origins: string[] | undefined | string; let methods: string; let preFlightContinue = false; if (options) { origins = options.origins instanceof Array ? options.origins : (options.origins ? [options.origins] : undefined); methods = options.methods ? options.methods : DEFAULT_CORS_METHODS; preFlightContinue = !!options.preflightContinue; } else { const [originsS, preFlightContinueS, methodsS] = checkEnvVariables(["CORS_ORIGINS", "CORS_PREFLIGHT_CONTINUE", "CORS_METHODS"], [DEFAULT_CORS_ORIGINS, String(DEFAULT_CORS_PREFLIGHT_CONTINUE), DEFAULT_CORS_METHODS]); origins = originsS.split(",").map(s => s.trim()); methods = methodsS; preFlightContinue = preFlightContinueS.toLocaleLowerCase() === "true"; } if (origins && origins.length === 1) { origins = origins[0]; } const validate: (origin: string | undefined, origins: string[] | string) => boolean = options && options.validate ? options && options.validate : isOriginAllowed; return async function CORS(req: Request, res: Response): Promise { // access-control-allow-origin if (!origins || origins === '*') { // allow any origin res.setHeader("Access-Control-Allow-Origin", "*"); res.addVaryHeader("Origin"); } else { if (req.headers.origin) { if (validate(req.headers.origin, origins)) { // reflect origin res.setHeader("Access-Control-Allow-Origin", String(req.headers.origin)); res.addVaryHeader("Origin"); } else { // not allowed throw new BadRequestError(`bad origin`); } } else { res.setHeader("Access-Control-Allow-Origin", origins instanceof Array ? origins[0] : origins); res.addVaryHeader("Origin"); } } if (String(req.method).toUpperCase() === "OPTIONS") { // access-control-request-headers if (methods) { res.setHeader("Access-Control-Allow-Methods", methods); } // reflect access-control-request-headers const allowedHeaders = req.headers['access-control-request-headers']; if (allowedHeaders) { res.addVaryHeader("Access-Control-Request-Headers"); res.setHeader("Access-Control-Allow-Headers", allowedHeaders); } // after preflight if (preFlightContinue) { return; } else { await res.asyncEnd({ headers: { "Content-Length": 0 }, status: 204, body: undefined }); } } } }; /** * ReadBuffer */ export const DEFAULT_READ_BUFFER_LIMIT = 1024 * 8 * 100; const DEFAULT_READ_BUFFER_TIMEOUT = 1000 * 40; export function ReadBuffer(options?: { limit?: number; timeout?: number; }): Handler { let limit = DEFAULT_READ_BUFFER_LIMIT; let timeout = DEFAULT_READ_BUFFER_TIMEOUT; if (options) { limit = options.limit !== undefined ? options.limit : limit; timeout = options.timeout !== undefined ? options.timeout : timeout; } else { const [limitS, timeoutS] = checkEnvVariables(["READ_BUFFER_LIMIT", "READ_BUFFER_TIMEOUT"], [String(DEFAULT_READ_BUFFER_LIMIT), String(DEFAULT_READ_BUFFER_TIMEOUT)]); limit = parseInt(limitS, 10); timeout = parseInt(timeoutS, 10); } return async function ReadBuffer(req: Request, res: Response): Promise { return new Promise((resolve, reject) => { try { const readTimeout = setTimeout(() => { clearTimeout(readTimeout); req.removeListener('error', errorListener); req.removeListener('data', chunkListener); req.removeListener('end', endListener); reject(new BadRequestError(`Read Timeout`)); return; }, timeout); let cLength = 0; const buffers: Buffer[] = []; const endListener = () => { clearTimeout(readTimeout); req.removeListener('error', errorListener); req.removeListener('data', chunkListener); req.removeListener('end', endListener); try { const concatBuffers = Buffer.concat(buffers); const responseBuffer: Buffer = req.headers["content-encoding"] === "gzip" ? gunzipSync(concatBuffers, { maxOutputLength: limit }) : concatBuffers; req.logger.trace("ctx.buffer %o", responseBuffer); req.buffer = responseBuffer; resolve(); } catch (e) { reject(e); } }; const errorListener = (err: Error) => { clearTimeout(readTimeout); req.removeListener('error', errorListener); req.removeListener('data', chunkListener); req.removeListener('end', endListener); reject(err); }; const chunkListener = (chunk: Buffer) => { cLength += chunk.length; if (cLength > limit) { clearTimeout(readTimeout); req.removeListener('error', errorListener); req.removeListener('data', chunkListener); req.removeListener('end', endListener); req.logger.error(`ctx.buffer.length ${cLength} > ${limit}. To accept this body set READ_BUFFER_LIMIT to a higher value.`); reject(new BadRequestError(`buffer.length ${cLength} > ${limit}`)); return; } buffers.push(chunk); }; req.on('error', errorListener); req.on('data', chunkListener); req.on('end', endListener); } catch (e) { reject(e); } }); } } /** * URLEncodedParser */ const DEFAULT_FORM_MAX_KEYS: undefined | number = undefined; export function URLEncodedParser(options?: { limit?: number; maxKeys?: number; type?: string; }): Handler { let maxKeys: undefined | number = DEFAULT_FORM_MAX_KEYS; let limit = DEFAULT_READ_BUFFER_LIMIT; let type = "application/x-www-form-urlencoded"; if (options) { limit = options.limit !== undefined ? options.limit : limit; type = options.type !== undefined ? options.type : type; maxKeys = options.maxKeys !== undefined ? options.maxKeys : maxKeys; } else { const [limitS, typeS, maxKeysS] = checkEnvVariables(["BODY_PARSER_URL_ENCODED_LIMIT", "BODY_PARSER_URL_ENCODED_TYPE", "BODY_PARSER_URL_ENCODED_MAX_KEYS"], [String(DEFAULT_READ_BUFFER_LIMIT), "application/x-www-form-urlencoded", String(DEFAULT_FORM_MAX_KEYS)]); limit = parseInt(limitS, 10); type = typeS; maxKeys = maxKeysS !== "undefined" ? parseInt(maxKeysS, 10) : DEFAULT_FORM_MAX_KEYS; } const readBuffer = ReadBuffer({ limit }); return async function URLEncodedParser(req: Request, res: Response): Promise { try { const isType = req.body === undefined && req.headers["content-type"] ? req.headers["content-type"].toLocaleLowerCase().indexOf(type.toLocaleLowerCase()) !== -1 : false; if (isType && !req.buffer) { await readBuffer(req, res); } if (isType && req.buffer && req.buffer.length <= limit) { const string = req.buffer.toString(); if (string) { const body = { ...queryParse(string, undefined, undefined, { maxKeys }) }; req.logger.debug("ctx.body = %s", body); req.body = body; } } else if (isType && req.buffer && req.buffer.length > limit) { req.logger.error(`ctx.buffer.length ${req.buffer.length} > ${limit}. To accept this body set BODY_PARSER_URL_ENCODED_LIMIT to a higher value.`); throw new BadRequestError(`buffer.length ${req.buffer.length} > ${limit}`); } } catch (e: any) { if (e && e.name === "BadRequestError") { throw e; } else { req.logger.error(e); throw new BadRequestError(); } } }; }; /** * JSONParser */ export function JSONParser(options?: { limit?: number; strict?: boolean; type?: string; }): Handler { let strict = true; let limit = DEFAULT_READ_BUFFER_LIMIT; let type = "application/json"; if (options) { strict = options.strict !== undefined ? options.strict : strict; limit = options.limit !== undefined ? options.limit : limit; type = options.type !== undefined ? options.type : type; } else { const [limitS, strictS, typeS] = checkEnvVariables(["BODY_PARSER_LIMIT", "BODY_PARSER_STRICT", "BODY_PARSER_TYPE"], [String(DEFAULT_READ_BUFFER_LIMIT), "true", "application/json"]) strict = strictS === "true"; limit = parseInt(limitS, 10); type = typeS; } const readBuffer = ReadBuffer({ limit }); return async function JSONParser(req, res): Promise { try { const isType = req.body === undefined && req.headers["content-type"] ? req.headers["content-type"].toLocaleLowerCase().indexOf(type.toLocaleLowerCase()) !== -1 : false; if (isType && !req.buffer) { await readBuffer(req, res); } if (isType && req.buffer && req.buffer.length <= limit) { const string = req.buffer.toString(); if (string) { const parsed = JSON.parse(string); if (parsed instanceof Array && strict) { throw new BadRequestError(`body cannot be an array`); } req.logger.debug("ctx.body = %o", parsed); req.body = parsed; } } else if (isType && req.buffer && req.buffer.length > limit) { req.logger.error(`ctx.buffer.length ${req.buffer.length} > ${limit}. To accept this body set BODY_PARSER_LIMIT to a higher value.`); throw new BadRequestError(`buffer.length ${req.buffer.length} > ${limit}`); } } catch (e: any) { if (e && e.name === "BadRequestError") { throw e; } else { req.logger.error(e); throw new BadRequestError(); } } }; }; /** * TextParser */ export function TextParser(options?: { limit?: number; type?: string; }): Handler { let limit = DEFAULT_READ_BUFFER_LIMIT; let type = "text/plain"; if (options) { limit = options.limit !== undefined ? options.limit : limit; type = options.type !== undefined ? options.type : type; } else { const [limitS, typeS] = checkEnvVariables(["BODY_TEXT_PARSER_LIMIT", "BODY_TEXT_PARSER_TYPE"], [String(DEFAULT_READ_BUFFER_LIMIT), "text/plain"]); limit = parseInt(limitS, 10); type = typeS; } const readBuffer = ReadBuffer({ limit }); return async function TextParser(req, res): Promise { try { const isType = req.body === undefined && req.headers["content-type"] ? req.headers["content-type"].toLocaleLowerCase().indexOf(type.toLocaleLowerCase()) !== -1 : false; if (isType && !req.buffer) { await readBuffer(req, res); } if (isType && req.buffer && req.buffer.length <= limit) { const string = req.buffer.toString(); req.logger.debug("ctx.body = %s", string); req.body = string; } else if (isType && req.buffer && req.buffer.length > limit) { req.logger.error(`ctx.buffer.length ${req.buffer.length} > ${limit}. To accept this body set BODY_TEXT_PARSER_LIMIT to a higher value.`); throw new BadRequestError(`buffer.length ${req.buffer.length} > ${limit}`); } } catch (e: any) { if (e && e.name === "BadRequestError") { throw e; } else { req.logger.error(e); throw new BadRequestError(); } } }; }; /** * ParseRequest */ export function assertNotUndefined(arg?: T, message?: string): T { if (arg === undefined) { throw new Error(message ? message : "arg undefined"); } return arg; } export const DEFAULT_PARSER = new Parser(); export interface ParseRequestOptions { headers?: string | SchemaProperties | SchemaProperties[]; headersMode?: ParserMode; query?: string | SchemaProperties | boolean | SchemaProperties[]; queryMode?: ParserMode; params?: string | SchemaProperties | boolean | SchemaProperties[]; paramsMode?: ParserMode; body?: string | SchemaProperties | boolean | SchemaProperties[]; bodyMode?: ParserMode; } const NO_OPTIONS: SchemaProperties[] = []; const ADD_EXTRA: SchemaProperties[] = []; function normalizeParseOptions(option?: string | SchemaProperties | SchemaProperties[] | boolean): { properties: Array, mode: ParserMode | undefined; } { const mode: ParserMode | undefined = typeof option === "boolean" && option ? "add_extra" : undefined; return { mode, properties: option !== undefined && typeof option !== "string" && typeof option !== "boolean" ? (option instanceof Array ? option : [option]) : (typeof option === "boolean" ? (option ? ADD_EXTRA : NO_OPTIONS) : (typeof option === "string" ? [option] : ADD_EXTRA)) } }; function parseRequestPart(part: "query" | "body" | "params" | "headers", req: Request, option: Array, mode?: ParserMode, parser?: ParserInterface): void { if (part === "headers" && mode === undefined) { mode = "add_extra"; } const value = req[part] === undefined ? {} : req[part]; for (let i = 0; i < option.length; i++) { const o = option[i]; try { req[part] = (parser ? parser : DEFAULT_PARSER).parse(value as any, typeof o === "string" ? o : { type: "object", properties: o, mode }, `${part}`) as any; req.logger.debug("req.%s parsed to [%o]", part, req[part]); return; } catch (e: any) { if (i === option.length - 1) { throw e; } } } } /** * ParseRequest * @param options * @param parser * @returns */ export function ParseRequest(options: ParseRequestOptions, parser?: ParserInterface): Handler { const query = normalizeParseOptions(options.query); const params = normalizeParseOptions(options.params); const body = normalizeParseOptions(options.body); const headers = normalizeParseOptions(options.headers); return function ParseRequest(req): void { try { try { if (options.headers !== undefined) { parseRequestPart("headers", req, headers.properties, options.headersMode ? options.headersMode : headers.mode, parser); } } catch (e) { req.logger.error("error parsing headers %o", req.headers); throw e; } try { if (options.query !== undefined) { parseRequestPart("query", req, query.properties, options.queryMode ? options.queryMode : query.mode, parser); } } catch (e) { req.logger.error("error parsing query %o", req.query); throw e; } try { if (options.params !== undefined) { parseRequestPart("params", req, params.properties, options.paramsMode ? options.paramsMode : params.mode, parser); } } catch (e) { req.logger.error("error parsing params %o", req.params); throw e; } try { if (options.body !== undefined) { parseRequestPart("body", req, body.properties, options.bodyMode ? options.bodyMode : body.mode, parser); } } catch (e: any) { req.logger.error("error parsing body %o", req.body); throw e; } } catch (e: any) { req.logger.warn("error parsing request: %s", e.message); throw e; } } }; /** * ResultParser * @param options * @param parser * @returns */ export function ResultParser(options: { status?: number | number[]; headers?: string | SchemaProperties | SchemaProperties[]; headersMode?: ParserMode; body?: string | boolean | SchemaProperties | SchemaProperties[]; bodyMode?: ParserMode; }, parser?: ParserInterface): Handler { const bodyParser = options.body instanceof Array ? options.body : typeof options.body === "boolean" ? options.body === true ? ["any?"] : [] : (typeof options.body === "string" ? [options.body] : options.body ? [options.body] : ["any?"]); const statusParserSet: Set | null = options.status instanceof Array ? new Set(options.status) : options.status !== undefined ? new Set([options.status]) : null; const headersParser = options.headers instanceof Array ? options.headers : typeof options.headers === "boolean" ? options.headers === true ? ["any?"] : [] : (typeof options.headers === "string" ? [options.headers] : options.headers ? [options.headers] : ["any?"]); return async function ResultParser(req, res): Promise { if (!req.results || req.results.length === 0) { req.logger.debug(`not parsing results`); return undefined; } else { const index = req.results.length - 1; const lastResult = req.results[index] ? req.results[index] : undefined; req.logger.trace(`parsing lastResult[%o]`, lastResult); try { const mappedLastResult = { ...lastResult }; const parsedStatus = (parser ? parser : DEFAULT_PARSER) .parse(lastResult.status, "number?", `ctx.results.status`) as number | undefined; if (statusParserSet) { if (parsedStatus === undefined) { throw new Error(`error parsing lastResult.status[${lastResult.status}] not defined as [${statusParserSet}]`); } else { if (statusParserSet.has(parsedStatus)) { mappedLastResult.status = parsedStatus; req.logger.trace(`ctx.results.status mapped to %o`, mappedLastResult.status); } else { throw new Error(`error parsing lastResult.status[${lastResult.status}] not defined as [${statusParserSet}]`); } } } else { mappedLastResult.status = parsedStatus; req.logger.debug(`ctx.results.status mapped to %o`, mappedLastResult.status); } mappedLastResult.body = parsePart(req, "body", lastResult.body, bodyParser, options.bodyMode, parser); mappedLastResult.headers = parsePart(req, "headers", lastResult.headers, headersParser, options.headersMode, parser); return mappedLastResult; } catch (e) { req.logger.error(`error parsing lastResult[%o]`, lastResult); req.logger.error(e); throw e; } } } } export interface ResponseHandlerOptions { etag?: boolean | ((lastResult?: { headers?: any; body?: any; status?: any; }) => Promise | string | false | null | undefined); } /** * ResponseHandler * @param req * @param res */ export function ResponseHandler(options?: ResponseHandlerOptions): Handler { const isEtagEnabled = options?.etag !== undefined; return async function ResponseHandler(req: Request, res: Response): Promise { req.logger.trace("results [%o]", req.results); if (!req.results || req.results.length === 0) { throw new Error(`no response to send. Is your handler returning a value differente than boolean 'true|false' ?`); } else { const lastResult = req.results[req.results.length - 1]; req.logger.debug("response [%o]", lastResult); if (lastResult !== undefined) { let headers = lastResult.headers; const body = lastResult.body; const status = lastResult.status; const noContentType = (headers && !headers["Content-Type"]) || !headers; if (options?.etag !== undefined) { res.setETag(options?.etag); } if (noContentType) { await res.json(body, headers, status); } else { await res.asyncEnd({ body, headers, status }); } } else { throw new Error(`no response to send. Is your handler returning a value different than boolean 'true|false' ?`); } } }; } function parsePart(req: Request, part: "body" | "headers", value: any, args: Array, mode?: ParserMode, parser?: ParserInterface) { for (let i = 0; i < args.length; i++) { const o = args[i]; try { const mappedResultBody = (parser ? parser : DEFAULT_PARSER).parse(value, typeof o === "string" ? o : { type: "object", properties: o, mode }, `ctx.results.${part}`); req.logger.debug(`ctx.results.%s mapped to %o`, part, mappedResultBody); return mappedResultBody; } catch (e) { if (i === args.length - 1) { throw e; } } } }