UNPKG

@adonisjs/bodyparser

Version:

BodyParser middleware for AdonisJS http server to read and parse request body

1,171 lines (1,170 loc) 35.2 kB
import { a as supportMagicFileTypes, i as getFileType, n as computeFileTypeFromName, r as formBodyNormalizers, t as MultipartFile } from "./file-CX_Xk_Sw.js"; import { extname, isAbsolute, join } from "node:path"; import string from "@poppinss/utils/string"; import { Exception, RuntimeException } from "@poppinss/utils/exception"; import { unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { debuglog } from "node:util"; import multiparty from "@poppinss/multiparty"; import lodash from "@poppinss/utils/lodash"; import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; import inflate from "inflation"; import raw from "raw-body"; import { safeParse } from "@poppinss/utils/json"; import qs from "@poppinss/qs"; import { HttpRequest } from "@adonisjs/http-server"; //#region src/debug.ts /** * Debug logger instance for the bodyparser package. Use the DEBUG=adonisjs:bodyparser * environment variable to enable debug logging. * * @example * ```sh * DEBUG=adonisjs:bodyparser node ace serve * ``` */ var debug_default = debuglog("adonisjs:bodyparser"); //#endregion //#region src/form_fields.ts const PROTOTYPE_POLLUTING_KEYS = new Set([ "__proto__", "prototype", "constructor" ]); /** * A collection of form fields that stores form data while handling * arrays gracefully */ var FormFields = class { /** * Internal storage for form fields */ #fields = Object.create(null); #normalizer; /** * Creates a new FormFields instance for collecting form data. * * @param normalizer - Optional normalizer function to process string values */ constructor(normalizer) { this.#normalizer = normalizer; } /** * Add a new key/value pair. The keys with array-like * expressions are handled properly. * * @param key - The field name, can include array notation * @param value - The field value * * @example * ``` * formfields.add('username', 'virk') * * // array * formfields.add('username[]', 'virk') * formfields.add('username[]', 'nikk') * * // Indexed keys are ordered properly * formfields.add('username[1]', 'virk') * formfields.add('username[0]', 'nikk') * ``` */ add(key, value) { let isArray = false; /** * Convert empty strings to null */ if (this.#normalizer && typeof value === "string") value = this.#normalizer(value); /** * Drop `[]` without indexes, since lodash `_.set` and * `_.get` methods needs the index or plain key. */ key = key.replace(/\[]$/, () => { isArray = true; return ""; }); const keyPath = lodash.toPath(key); if (keyPath.some((segment) => PROTOTYPE_POLLUTING_KEYS.has(segment))) return; /** * Check to see if value exists or set it (if missing) */ const existingValue = lodash.get(this.#fields, keyPath); if (!existingValue) { lodash.set(this.#fields, keyPath, isArray ? [value] : value); return; } /** * Mutate existing value if it's an array */ if (Array.isArray(existingValue)) { existingValue.push(value); return; } /** * Set new value + existing value */ lodash.set(this.#fields, keyPath, [existingValue, value]); } /** * Returns the collected form fields as an object. * * @example * ```ts * const fields = new FormFields() * fields.add('username', 'virk') * fields.add('tags[]', 'node') * fields.add('tags[]', 'typescript') * * console.log(fields.get()) * // { username: 'virk', tags: ['node', 'typescript'] } * ``` */ get() { return this.#fields; } }; //#endregion //#region src/multipart/part_handler.ts /** * Part handler handles the progress of a stream and also internally validates * its size and extension. * * This class offloads the task of validating a file stream, regardless of how * the stream is consumed. For example: * * In classic scenario, we will process the file stream and write files to the * tmp directory and in more advanced cases, the end user can handle the * stream by themselves and report each chunk to this class. */ var PartHandler = class { /** * The multipart stream part being handled */ #part; /** * Configuration options for file validation and processing */ #options; /** * The stream buffer reported by the stream consumer. We hold the buffer until are * able to detect the file extension and then buff memory is released */ #buff; /** * A boolean to know, if we have emitted the error event after one or * more validation errors. We need this flag, since the race conditions * between `data` and `error` events will trigger multiple `error` * emit. */ #emittedValidationError = false; /** * A boolean to know if we can use the magic number to detect the file type. This is how it * works. * * - We begin by extracting the file extension from the file name * - If the file has no extension, we try to inspect the buffer * - If the extension is something we support via magic numbers, then we ignore the extension * and inspect the buffer * - Otherwise, we have no other option than to trust the extension * * Think of this as using the optimal way for validating the file type */ get #canFileTypeBeDetected() { const fileExtension = extname(this.#part.filename).replace(/^\./, ""); return fileExtension ? supportMagicFileTypes.has(fileExtension) : true; } /** * Creating a new file object for each part inside the multipart * form data */ file; /** * Creates a new part handler instance for processing multipart stream parts * * @param part - The multipart stream to handle * @param options - Validation options and configuration */ constructor(part, options) { this.#part = part; this.#options = options; this.file = new MultipartFile({ clientName: part.filename, fieldName: part.name, headers: part.headers }, { size: options.size, extnames: options.extnames }); } /** * Detects the file type and extension and also validates it when validations * are not deferred. */ async #detectFileTypeAndExtension() { if (!this.#buff) return; let fileType = this.#canFileTypeBeDetected ? await getFileType(this.#buff) : computeFileTypeFromName(this.file.clientName, this.file.headers); /** * If magic number detection failed after enough bytes have been * buffered, fall back to detecting the file type from the * filename and headers to prevent unbounded memory growth. */ if (!fileType && this.#buff.length >= 4100) fileType = computeFileTypeFromName(this.file.clientName, this.file.headers); if (fileType) { this.file.extname = fileType.ext; this.file.type = fileType.type; this.file.subtype = fileType.subtype; } } /** * Skip the stream or end it forcefully. This is invoked when the * streaming consumer reports an error */ #skipEndStream() { this.#part.emit("close"); } /** * Finish the process of listening for any more events and mark the * file state as consumed. */ #finish() { this.file.state = "consumed"; if (!this.#options.deferValidations) this.file.validate(); } /** * Marks the file as being in streaming mode. Should be called before * processing the stream. */ begin() { this.file.state = "streaming"; } /** * Handles the file upload progress by validating the file size and * extension. * * @param line - Buffer chunk from the stream * @param bufferLength - Length of the buffer chunk in bytes */ async reportProgress(line, bufferLength) { /** * Do not consume stream data when file state is not `streaming`. Stream * events race conditions may emit the `data` event after the `error` * event in some cases, so we have to restrict it here. */ if (this.file.state !== "streaming") return; /** * Detect the file type and extension when extname is null, otherwise * empty out the buffer. We only need the buffer to find the * file extension from it's content. */ if (this.file.extname === void 0) { this.#buff = this.#buff ? Buffer.concat([this.#buff, line]) : line; await this.#detectFileTypeAndExtension(); } else this.#buff = void 0; /** * The length of stream buffer */ this.file.size = this.file.size + bufferLength; /** * Validate the file on every chunk, unless validations have been deferred. */ if (this.#options.deferValidations) return; /** * Attempt to validate the file after every chunk and report error * when it has one or more failures. After this the consumer must * call `reportError`. */ this.file.validate(); if (!this.file.isValid && !this.#emittedValidationError) { this.#emittedValidationError = true; this.#part.emit("error", new Exception("one or more validations failed", { code: "E_STREAM_VALIDATION_FAILURE", status: 400 })); } } /** * Report errors encountered while processing the stream. These can be errors * apart from the one reported by this class. For example: The `s3` failure * due to some bad credentials. * * @param error - The error encountered during stream processing */ async reportError(error) { if (this.file.state !== "streaming") return; this.#skipEndStream(); this.#finish(); if (error.code === "E_STREAM_VALIDATION_FAILURE") return; /** * Push to the array of file errors */ this.file.errors.push({ fieldName: this.file.fieldName, clientName: this.file.clientName, type: "fatal", message: error.message }); } /** * Report success data about the file. * * @param data - Success data containing file paths and metadata */ async reportSuccess(data) { if (this.file.state !== "streaming") return; /** * Re-attempt to detect the file extension after we are done * consuming the stream */ if (this.file.extname === void 0) await this.#detectFileTypeAndExtension(); if (data) { const { filePath, tmpPath, ...meta } = data; if (filePath) this.file.filePath = filePath; if (tmpPath) this.file.tmpPath = tmpPath; this.file.meta = meta || {}; } this.#finish(); } }; //#endregion //#region src/multipart/main.ts /** * Multipart class offers a low level API to interact with the incoming * HTTP request data as a stream. This makes it super easy to * write files to S3 without saving them to the disk first. */ var Multipart = class { /** * The HTTP context for the current request */ #ctx; /** * Configuration options for multipart processing */ #config; /** * The registered handlers to handle the file uploads */ #handlers = {}; /** * Collected fields from the multipart stream */ #fields; /** * Collected files from the multipart stream. Files are only collected * when there is an attached listener for a given file. */ #files; /** * We track the finishing of `this.onFile` async handlers * to make sure that `process` promise resolves for all * handlers to finish. */ #pendingHandlers = 0; /** * The reference to underlying multiparty form */ #form; /** * Total size limit of the multipart stream. If it goes beyond * the limit, then an exception will be raised. */ #upperLimit; /** * A track of total number of file bytes processed so far */ #processedBytes = 0; /** * The current state of the multipart form handler */ state = "idle"; /** * Creates a new Multipart instance for processing multipart form data * * @param ctx - The HTTP context * @param config - Configuration options for multipart processing * @param _featureFlags - Feature flags (unused) */ constructor(ctx, config = {}, _featureFlags = {}) { this.#ctx = ctx; this.#config = config; this.#fields = new FormFields(config.normalizer); this.#files = new FormFields(config.normalizer); this.#upperLimit = config.limit; } /** * Returns a boolean telling whether all streams have been * consumed along with all handlers execution */ #isClosed() { return this.#form["flushing"] <= 0 && this.#pendingHandlers <= 0; } /** * Removes array like expression from the part name to * find the handler */ #getHandlerName(name) { return name.replace(/\[\d*\]/, ""); } /** * Validates and returns an error when upper limit is defined and * processed bytes is over the upper limit */ #validateProcessedBytes(chunkLength) { if (!this.#upperLimit) return; this.#processedBytes += chunkLength; if (this.#processedBytes > this.#upperLimit) return new Exception("request entity too large", { code: "E_REQUEST_ENTITY_TOO_LARGE", status: 413 }); } /** * Handles a given part by invoking it's handler or * by resuming the part, if there is no defined * handler */ async #handlePart(part) { /** * Skip parts with empty name or empty filenames. The empty * filenames takes place when user doesn't upload a file * and empty name is more of a bad client scanerio. */ if (!part.name || !part.filename) { part.resume(); return; } const name = this.#getHandlerName(part.name); /** * Skip, if their is no handler to consume the part. */ const handler = this.#handlers[name] || this.#handlers["*"]; if (!handler) { debug_default("skipping multipart part as there are no handlers \"%s\"", name); part.resume(); return; } debug_default("processing multipart part \"%s\"", name); this.#pendingHandlers++; /** * Instantiate the part handler */ const partHandler = new PartHandler(part, handler.options); partHandler.begin(); /** * Track the file instance created by the part handler. The end user * must be able to access these files. */ this.#files.add(partHandler.file.fieldName, partHandler.file); this.#fields.add(partHandler.file.fieldName, partHandler.file); part.file = partHandler.file; try { const response = await handler.handler(part, async (line) => { if (this.state !== "processing") return; const lineLength = line.length; /** * Keeping an eye on total bytes processed so far and shortcircuit * request when more than expected bytes have been received. */ const error = this.#validateProcessedBytes(lineLength); if (error) { part.emit("error", error); this.abort(error); return; } try { await partHandler.reportProgress(line, lineLength); } catch (err) { part.emit("error", err); this.abort(err); } }); /** * Stream consumed successfully */ await partHandler.reportSuccess(response || {}); } catch (error) { /** * The stream handler reported an exception */ await partHandler.reportError(error); } this.#pendingHandlers--; } /** * Record the fields inside multipart contract */ #handleField(key, value) { if (!key) return; this.#fields.add(key, value); } /** * Mark the process as finished */ #finish(newState) { if (this.state === "idle" || this.state === "processing") { this.state = newState; this.#ctx.request["__raw_files"] = this.#files.get(); this.#ctx.request.setInitialBody(this.#fields.get()); } } /** * Attach handler for a given file. To handle all files, you * can attach a wildcard handler. * * @param name - The field name to handle, or '*' for wildcard * @param options - Validation options and configuration * @param handler - The handler function to process the file * * @example * ```ts * multipart.onFile('package', {}, async (stream) => { * }) * * multipart.onFile('*', {}, async (stream) => { * }) * ``` */ onFile(name, options, handler) { this.#handlers[name] = { handler, options }; return this; } /** * Aborts the multipart stream processing by emitting an error event. * This will stop all file processing and reject the process promise. * * @param error - The error that caused the abort * * @example * ```ts * multipart.onFile('*', {}, async (part) => { * if (part.file.size > MAX_SIZE) { * multipart.abort(new Error('File too large')) * } * }) * ``` */ abort(error) { this.#form.emit("error", error); } /** * Processes the multipart request by parsing all file and field streams. * Must be called after registering all file handlers with onFile. * * @param config - Optional configuration overrides for this specific request * * @example * ```ts * const multipart = ctx.request.multipart * * multipart.onFile('avatar', {}, async (part, reportChunk) => { * await streamFile(part, '/tmp/avatar.jpg', reportChunk) * }) * * await multipart.process() * ``` */ process(config) { return new Promise((resolve, reject) => { if (this.state !== "idle") { reject(new Exception("multipart stream has already been consumed", { code: "E_RUNTIME_EXCEPTION" })); return; } this.state = "processing"; /** * Use local upperlimit */ if (config && config.limit) this.#upperLimit = string.bytes.parse(config.limit); this.#form = new multiparty.Form({ maxFields: config?.maxFields ?? this.#config.maxFields, maxFieldsSize: this.#config.fieldsLimit }); debug_default("processing multipart body"); /** * Raise error when form encounters an * error */ this.#form.on("error", (error) => { this.#finish("error"); process.nextTick(() => { if (this.#ctx.request.request.readable) this.#ctx.request.request.resume(); if (error.message.match(/stream ended unexpectedly/)) reject(new Exception("Invalid multipart request", { status: 400, code: "E_INVALID_MULTIPART_REQUEST" })); else if (error.message.match(/maxFields [0-9]+ exceeded/)) reject(new Exception("Fields length limit exceeded", { status: 413, code: "E_REQUEST_ENTITY_TOO_LARGE" })); else if (error.message.match(/maxFieldsSize [0-9]+ exceeded/)) reject(new Exception("Fields size in bytes exceeded", { status: 413, code: "E_REQUEST_ENTITY_TOO_LARGE" })); else reject(error); }); }); /** * Process each part at a time and also resolve the * promise when all parts are consumed and processed * by their handlers */ this.#form.on("part", async (part) => { await this.#handlePart(part); /** * When a stream finishes before the handler, the close `event` * will not resolve the current Promise. So in that case, we * check and resolve from here */ if (this.#isClosed()) { this.#finish("success"); resolve(); } }); /** * Listen for fields */ this.#form.on("field", (key, value) => { try { this.#handleField(key, value); } catch (error) { this.abort(error); } }); /** * Resolve promise on close, when all internal * file handlers are done processing files */ this.#form.on("close", () => { if (this.#isClosed()) { this.#finish("success"); resolve(); } }); this.#form.parse(this.#ctx.request.request); }); } }; //#endregion //#region src/multipart/stream_file.ts /** * Streams a file from a readable stream to a file system location. Automatically * cleans up on errors and optionally reports data chunks to a listener. * * @param readStream - The source readable stream * @param location - The destination file path * @param dataListener - Optional callback to receive data chunks * * @example * ```ts * await streamFile(part, '/tmp/upload.jpg', (chunk) => { * console.log('Received', chunk.length, 'bytes') * }) * ``` */ async function streamFile(readStream, location, dataListener) { if (typeof dataListener === "function") { readStream.pause(); readStream.on("data", dataListener); } const writeStream = createWriteStream(location); try { await pipeline(readStream, writeStream); } catch (error) { unlink(writeStream.path).catch(() => {}); throw error; } } //#endregion //#region src/parsers/text.ts /** * Prepares parser options for raw text body parsing by applying defaults * for encoding and size limits. * * @param options - Raw body parser configuration */ function prepareTextParserOptions(options) { return { encoding: options.encoding ?? "utf8", limit: options.limit ?? "56kb" }; } /** * Parses and inflates the raw request body as text. Automatically handles * compressed request bodies (gzip, deflate, etc.). * * @param req - The incoming HTTP request * @param options - Parser options including encoding and size limits */ function parseText(req, options) { /** * Mimicing behavior of * https://github.com/poppinss/co-body/blob/master/lib/text.js#L30 */ const contentLength = req.headers["content-length"]; const encoding = req.headers["content-encoding"] || "identity"; if (contentLength && encoding === "identity") options = { ...options, length: ~~contentLength }; return raw(inflate(req), options); } //#endregion //#region src/parsers/json.ts /** * Allowed whitespace is defined in RFC 7159 * http://www.rfc-editor.org/rfc/rfc7159.txt */ const strictJSONReg = /^[\x20\x09\x0a\x0d]*(\[|\{)/; /** * Prepares parser options for JSON body parsing by configuring strict mode * and value normalization through a reviver function. * * @param options - JSON body parser configuration */ function prepareJSONParserOptions(options) { let normalizer; if (options.convertEmptyStringsToNull && options.trimWhitespaces) normalizer = formBodyNormalizers.trimWhitespacesAndConvertToNull; else if (options.convertEmptyStringsToNull) normalizer = formBodyNormalizers.convertToNull; else if (options.trimWhitespaces) normalizer = formBodyNormalizers.trimWhitespaces; return { ...prepareTextParserOptions(options), strict: options.strict !== false, reviver: normalizer ? function JSONReviver(key, value) { if (key === "") return value; return typeof value === "string" ? normalizer(value) : value; } : void 0 }; } /** * Parses JSON request body with optional strict mode that enforces only * objects and arrays as valid JSON. Returns both parsed and raw representations. * * @param req - The incoming HTTP request * @param options - Parser options including encoding, limits, and strict mode * * @example * ```ts * const { parsed, raw } = await parseJSON(request, options) * // parsed: { username: 'virk', age: 28 } * // raw: '{"username":"virk","age":28}' * ``` */ async function parseJSON(req, options) { const requestBody = await parseText(req, options); /** * Do not parse body when request body is empty */ if (!requestBody) return options.strict ? { parsed: {}, raw: requestBody } : { parsed: requestBody, raw: requestBody }; /** * Test JSON body to ensure it is valid JSON in strict mode */ if (options.strict && !strictJSONReg.test(requestBody)) throw new Exception("Invalid JSON, only supports object and array", { status: 422 }); try { return { parsed: safeParse(requestBody, options.reviver), raw: requestBody }; } catch (error) { error.status = 400; error.body = requestBody; throw error; } } //#endregion //#region src/parsers/form.ts /** * Prepares parser options for URL-encoded form data by configuring * query string parsing and value normalization. * * @param options - Form body parser configuration */ function prepareFormParserOptions(options) { /** * Shallow clone query string options */ const queryStringOptions = { ...options.queryString }; if (queryStringOptions.allowDots === void 0) queryStringOptions.allowDots = true; let normalizer; if (options.convertEmptyStringsToNull && options.trimWhitespaces) normalizer = formBodyNormalizers.trimWhitespacesAndConvertToNull; else if (options.convertEmptyStringsToNull) normalizer = formBodyNormalizers.convertToNull; else if (options.trimWhitespaces) normalizer = formBodyNormalizers.trimWhitespaces; /** * Convert empty strings to null */ if (normalizer) queryStringOptions.decoder = function(str, defaultDecoder, charset, type) { let value = defaultDecoder(str, defaultDecoder, charset); if (type === "value") return normalizer(value); return value; }; /** * Shallow clone of provided options */ return { ...prepareTextParserOptions(options), qs: queryStringOptions }; } /** * Parses URL-encoded form data (application/x-www-form-urlencoded) from * the request body and returns both parsed and raw representations. * * @param req - The incoming HTTP request * @param options - Parser options including encoding, limits, and query string config * * @example * ```ts * const { parsed, raw } = await parseForm(request, options) * // parsed: { username: 'virk', tags: ['node', 'typescript'] } * // raw: 'username=virk&tags=node&tags=typescript' * ``` */ async function parseForm(req, options) { const requestBody = await parseText(req, options); return { parsed: qs.parse(requestBody, options.qs), raw: requestBody }; } //#endregion //#region src/bindings/request.ts /** * Updates the validation options on a file instance if they haven't been set already. * * @param file - The multipart file instance to update * @param options - Validation options including size limit and allowed extensions */ function setFileOptions(file, options) { if (file.sizeLimit === void 0 && options && options.size) file.sizeLimit = options.size; if (file.allowedExtensions === void 0 && options && options.extnames) file.allowedExtensions = options.extnames; } /** * Type guard to check if a value is an instance of MultipartFile. * * @param file - The value to check */ function isInstanceOfFile(file) { return file && file instanceof MultipartFile; } debug_default("extending request class with \"file\", \"files\" and \"allFiles\" macros"); /** * Extends the Request class toJSON method to serialize files alongside * the rest of the request data. */ HttpRequest.macro("toJSON", function() { return { ...this.serialize(), files: this["__raw_files"] || {} }; }); /** * Extends the Request class with a method to fetch a single uploaded file. * When an array of files exists for the key, returns the first file. * * @example * ```ts * const avatar = request.file('avatar', { * size: '2mb', * extnames: ['jpg', 'png', 'jpeg'] * }) * * if (avatar && avatar.isValid) { * await avatar.move(app.publicPath('uploads')) * } * ``` */ HttpRequest.macro("file", function getFile(key, options) { let file = lodash.get(this.allFiles(), key); file = Array.isArray(file) ? file[0] : file; if (!isInstanceOfFile(file)) return null; setFileOptions(file, options); file.validate(); return file; }); /** * Extends the Request class with a method to fetch all uploaded files for * a given field name. Always returns an array, even if a single file was uploaded. * * @example * ```ts * const documents = request.files('documents', { * size: '5mb', * extnames: ['pdf', 'doc', 'docx'] * }) * * for (const doc of documents) { * if (doc.isValid) { * await doc.move(app.publicPath('uploads')) * } * } * ``` */ HttpRequest.macro("files", function getFiles(key, options) { let files = lodash.get(this.allFiles(), key); files = Array.isArray(files) ? files : files ? [files] : []; return files.filter(isInstanceOfFile).map((file) => { setFileOptions(file, options); file.validate(); return file; }); }); /** * Extends the Request class with a method to fetch all uploaded files * from the request. Throws an error if the bodyparser middleware is not registered. * * @example * ```ts * const allFiles = request.allFiles() * // { avatar: MultipartFile, documents: [MultipartFile, MultipartFile] } * ``` */ HttpRequest.macro("allFiles", function allFiles() { if (!this.__raw_files) throw new RuntimeException("Cannot read files. Make sure the bodyparser middleware is registered"); return this["__raw_files"]; }); //#endregion //#region src/parsers/multipart.ts /** * Prepares configuration for multipart form data parsing by converting * size limits from strings to bytes and configuring value normalization. * * @param config - Multipart body parser configuration */ function prepareMultipartConfig(config) { let normalizer; if (config.convertEmptyStringsToNull && config.trimWhitespaces) normalizer = formBodyNormalizers.trimWhitespacesAndConvertToNull; else if (config.convertEmptyStringsToNull) normalizer = formBodyNormalizers.convertToNull; else if (config.trimWhitespaces) normalizer = formBodyNormalizers.trimWhitespaces; return { limit: config.limit ? string.bytes.parse(config.limit) : void 0, fieldsLimit: config.fieldsLimit ? string.bytes.parse(config.fieldsLimit) : void 0, maxFields: config.maxFields, normalizer }; } //#endregion //#region src/bodyparser_middleware.ts /** * Bindings to extend request */ /** * BodyParser middleware parses the incoming request body and sets it as * request body to be read later in the request lifecycle. */ var BodyParserMiddleware = class { /** * Body parser configuration */ #config; #parsersConfig; /** * Creates a new BodyParserMiddleware instance * * @param config - The body parser configuration * @param _featureFlags - Feature flags (unused) */ constructor(config, _featureFlags) { this.#config = config; this.#parsersConfig = { raw: prepareTextParserOptions(this.#config.raw), form: prepareFormParserOptions(this.#config.form), json: prepareJSONParserOptions(this.#config.json), multipart: prepareMultipartConfig(this.#config.multipart) }; debug_default("using config %O", this.#config); } /** * Checks if the request content-type header matches any of the expected types. * * @param request - The HTTP request object * @param types - Array of MIME types to check against */ #isType(request, types) { return !!(types && types.length && request.is(types)); } /** * Converts raw-body errors into AdonisJS exceptions with appropriate * status codes and error codes. * * @param error - The raw-body error object */ #getExceptionFor(error) { switch (error.type) { case "encoding.unsupported": return new Exception(error.message, { status: error.status, code: "E_ENCODING_UNSUPPORTED" }); case "entity.too.large": return new Exception(error.message, { status: error.status, code: "E_REQUEST_ENTITY_TOO_LARGE" }); case "request.aborted": return new Exception(error.message, { status: error.status, code: "E_REQUEST_ABORTED" }); default: return error; } } /** * Generates a temporary file path for storing uploaded files. Uses the * configured tmpFileName function if provided, otherwise generates a UUID. * * @param config - The multipart configuration */ #getTmpPath(config) { if (typeof config.tmpFileName === "function") { const tmpPath = config.tmpFileName(); return isAbsolute(tmpPath) ? tmpPath : join(tmpdir(), tmpPath); } return join(tmpdir(), string.uuid()); } /** * Handle HTTP request body by parsing it according to the user * configuration * * @param ctx - The HTTP context * @param next - The next middleware function */ async handle(ctx, next) { /** * Initiating the `__raw_files` property as an object */ ctx.request["__raw_files"] = {}; const requestUrl = ctx.request.url(); const requestMethod = ctx.request.method(); /** * Only process for whitelisted nodes */ if (!this.#config.allowedMethods.includes(requestMethod)) { debug_default("skipping HTTP request \"%s:%s\"", requestMethod, requestUrl); return next(); } /** * Return early when request body is empty. Many clients set the `Content-length = 0` * when request doesn't have any body, which is not handled by the below method. * * The main point of `hasBody` is to early return requests with empty body created by * clients with missing headers. */ if (!ctx.request.hasBody()) { debug_default("skipping as request has no body \"%s:%s\"", requestMethod, requestUrl); return next(); } /** * Handle multipart form */ const multipartConfig = this.#config["multipart"]; if (this.#isType(ctx.request, multipartConfig.types)) { debug_default("detected multipart request \"%s:%s\"", requestMethod, requestUrl); ctx.request.multipart = new Multipart(ctx, this.#parsersConfig.multipart, {}); ctx.request.bodyType = "multipart"; /** * Skip parsing when `autoProcess` is disabled */ if (multipartConfig.autoProcess === false) { debug_default("skipping auto processing of multipart request \"%s:%s\"", requestMethod, requestUrl); return next(); } /** * Skip parsing when the current route matches one of the defined * processManually route patterns. */ if (ctx.route && multipartConfig.processManually.includes(ctx.route.pattern)) { debug_default("skipping auto processing of multipart request \"%s:%s\"", requestMethod, requestUrl); return next(); } /** * Skip parsing when the current route matches one of the "autoProcess" * patterns */ if (ctx.route && Array.isArray(multipartConfig.autoProcess) && !multipartConfig.autoProcess.includes(ctx.route.pattern)) { debug_default("skipping auto processing of multipart request \"%s:%s\"", requestMethod, requestUrl); return next(); } /** * Make sure we are not running any validations on the uploaded files. They are * deferred for the end user when they will access file using `request.file` * method. */ debug_default("auto processing multipart request \"%s:%s\"", requestMethod, requestUrl); ctx.request.multipart.onFile("*", { deferValidations: true }, async (part, reporter) => { /** * We need to abort the main request when we are unable to process any * file. Otherwise the error will endup on the file object, which * is incorrect. */ try { const tmpPath = this.#getTmpPath(multipartConfig); await streamFile(part, tmpPath, reporter); return { tmpPath }; } catch (error) { ctx.request.multipart.abort(error); } }); try { await ctx.request.multipart.process(); return next(); } catch (error) { throw error; } } /** * Handle url-encoded form data */ const formConfig = this.#config["form"]; if (this.#isType(ctx.request, formConfig.types)) { debug_default("detected urlencoded request \"%s:%s\"", requestMethod, requestUrl); try { const { parsed, raw } = await parseForm(ctx.request.request, this.#parsersConfig.form); ctx.request.setInitialBody(parsed); ctx.request.updateRawBody(raw); ctx.request.bodyType = "urlencoded"; return next(); } catch (error) { throw this.#getExceptionFor(error); } } /** * Handle content with JSON types */ const jsonConfig = this.#config["json"]; if (this.#isType(ctx.request, jsonConfig.types)) { debug_default("detected JSON request \"%s:%s\"", requestMethod, requestUrl); try { const { parsed, raw } = await parseJSON(ctx.request.request, this.#parsersConfig.json); ctx.request.setInitialBody(parsed); ctx.request.updateRawBody(raw); ctx.request.bodyType = "json"; return next(); } catch (error) { throw this.#getExceptionFor(error); } } /** * Handles raw request body */ const rawConfig = this.#config["raw"]; if (this.#isType(ctx.request, rawConfig.types)) { debug_default("parsing raw body \"%s:%s\"", requestMethod, requestUrl); try { ctx.request.setInitialBody({}); ctx.request.updateRawBody(await parseText(ctx.request.request, this.#parsersConfig.raw)); ctx.request.bodyType = "raw"; return next(); } catch (error) { throw this.#getExceptionFor(error); } } ctx.request.bodyType = "unknown"; await next(); } }; //#endregion export { BodyParserMiddleware as t };