UNPKG

@adonisjs/bodyparser

Version:

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

606 lines (605 loc) 15.6 kB
import { dirname, extname, join } from "node:path"; import Macroable from "@poppinss/macroable"; import string from "@poppinss/utils/string"; import { Exception, RuntimeException } from "@poppinss/utils/exception"; import mediaTyper from "media-typer"; import { fileTypeFromBuffer, supportedExtensions } from "file-type"; import { access, copyFile, mkdir, rename, unlink } from "node:fs/promises"; //#region src/utils.ts /** * We can detect file types for these files using the magic * number */ const supportMagicFileTypes = supportedExtensions; /** * Attempts to parse the file MIME type into its type and subtype components. * Returns null if the MIME type cannot be parsed. * * @param mime - The MIME type string to parse (e.g., "image/png") */ function parseMimeType(mime) { try { const { type, subtype } = mediaTyper.parse(mime); return { type, subtype }; } catch (error) { return null; } } /** * Detects the file type, extension, and MIME type/subtype by analyzing * the file's magic number (binary signature). * * @param fileContents - Buffer containing the file contents to analyze * * @example * ```ts * const buffer = await fs.readFile('image.png') * const fileType = await getFileType(buffer) * // { ext: 'png', type: 'image', subtype: 'png' } * ``` */ async function getFileType(fileContents) { /** * Attempt to detect file type from it's content */ const magicType = await fileTypeFromBuffer(fileContents); if (magicType) return Object.assign({ ext: magicType.ext.toLowerCase() }, parseMimeType(magicType.mime)); return null; } /** * Computes the file extension and MIME type from the filename and headers * when magic number detection is not available or applicable. * * @param clientName - The original filename provided by the client * @param headers - Headers object containing the content-type * * @example * ```ts * const fileType = computeFileTypeFromName('document.pdf', { * 'content-type': 'application/pdf' * }) * // { ext: 'pdf', type: 'application', subtype: 'pdf' } * ``` */ function computeFileTypeFromName(clientName, headers) { /** * Otherwise fallback to file extension from it's client name * and pull type/subtype from the headers content type. */ return Object.assign({ ext: extname(clientName).replace(/^\./, "").toLowerCase() }, parseMimeType(headers["content-type"])); } /** * Checks if a file or directory exists at the specified path. * * @param filePath - The path to check for existence * * @example * ```ts * if (await pathExists('/tmp/upload.jpg')) { * console.log('File exists') * } * ``` */ async function pathExists(filePath) { try { await access(filePath); return true; } catch { return false; } } /** * Moves a file from source to destination, automatically handling cross-device * moves by falling back to copy+delete. Creates the destination directory if * it doesn't exist. * * @param sourcePath - The current path of the file * @param destinationPath - The target path for the file * @param options - Move options including overwrite flag and directory permissions * * @example * ```ts * await moveFile('/tmp/upload.jpg', '/app/public/images/photo.jpg', { * overwrite: true * }) * ``` */ async function moveFile(sourcePath, destinationPath, options = { overwrite: true }) { if (!sourcePath || !destinationPath) throw new RuntimeException("\"sourcePath\" and \"destinationPath\" required"); if (!options.overwrite && await pathExists(destinationPath)) throw new RuntimeException(`The destination file already exists: "${destinationPath}"`); await mkdir(dirname(destinationPath), { recursive: true, mode: options.directoryMode }); try { await rename(sourcePath, destinationPath); } catch (error) { if (error.code === "EXDEV") { await copyFile(sourcePath, destinationPath); await unlink(sourcePath); } else throw error; } } /** * Collection of normalizer functions for processing form body values. * These functions can trim whitespace and/or convert empty strings to null. */ const formBodyNormalizers = { /** * Trims leading and trailing whitespace from a string value. * * @param value - The string to trim */ trimWhitespaces(value) { return value.trim(); }, /** * Trims whitespace and converts empty strings to null. * * @param value - The string to process */ trimWhitespacesAndConvertToNull(value) { value = value.trim(); return value === "" ? null : value; }, /** * Converts empty strings to null without trimming. * * @param value - The string to process */ convertToNull(value) { return value === "" ? null : value; } }; //#endregion //#region src/multipart/validators/size.ts /** * Size validator validates the file size against configured limits */ var SizeValidator = class { /** * Reference to the multipart file being validated */ #file; /** * Maximum allowed file size limit */ #maximumAllowedLimit; /** * Parsed bytes limit from the maximum allowed limit */ #bytesLimit = 0; /** * Whether the file has been validated */ validated = false; /** * Defining the maximum bytes the file can have */ get maxLimit() { return this.#maximumAllowedLimit; } set maxLimit(limit) { if (this.#maximumAllowedLimit !== void 0) throw new Error("Cannot reset sizeLimit after file has been validated"); this.validated = false; this.#maximumAllowedLimit = limit; if (this.#maximumAllowedLimit) this.#bytesLimit = string.bytes.parse(this.#maximumAllowedLimit); } /** * Creates a new SizeValidator instance * * @param file - The multipart file to validate */ constructor(file) { this.#file = file; } /** * Reporting error to the file */ #reportError() { this.#file.errors.push({ fieldName: this.#file.fieldName, clientName: this.#file.clientName, message: `File size should be less than ${string.bytes.format(this.#bytesLimit)}`, type: "size" }); } /** * Validating file size while it is getting streamed. We only mark * the file as `validated` when it's validation fails. Otherwise * we keep re-validating the file as we receive more data. */ #validateWhenGettingStreamed() { if (this.#file.size > this.#bytesLimit) { this.validated = true; this.#reportError(); } } /** * We have the final file size after the stream has been consumed. At this * stage we always mark `validated = true`. */ #validateAfterConsumed() { this.validated = true; if (this.#file.size > this.#bytesLimit) this.#reportError(); } /** * Validates the file size against configured limits. Can be called multiple * times during streaming; validation is marked complete only when the file * exceeds the limit or after the stream is consumed. * * @example * ```ts * const validator = new SizeValidator(file) * validator.maxLimit = '2mb' * validator.validate() * * if (!file.isValid) { * console.log(file.errors) // Size validation errors * } * ``` */ validate() { if (this.validated) return; /** * Do not attempt to validate when `maximumAllowedLimit` is not * defined. */ if (this.#maximumAllowedLimit === void 0) { this.validated = true; return; } if (this.#file.state === "streaming") { this.#validateWhenGettingStreamed(); return; } if (this.#file.state === "consumed") { this.#validateAfterConsumed(); return; } } }; //#endregion //#region src/multipart/validators/extensions.ts /** * Validates the file extension against a list of allowed extensions */ var ExtensionValidator = class { /** * Reference to the multipart file being validated */ #file; /** * Array of allowed file extensions */ #allowedExtensions = []; /** * Whether the file has been validated */ validated = false; /** * Update the expected file extensions */ get extensions() { return this.#allowedExtensions; } set extensions(extnames) { if (this.#allowedExtensions && this.#allowedExtensions.length) throw new Error("Cannot update allowed extension names after file has been validated"); this.validated = false; this.#allowedExtensions = extnames; } /** * Creates a new ExtensionValidator instance * * @param file - The multipart file to validate */ constructor(file) { this.#file = file; } /** * Report error to the file */ #reportError() { /** * File is invalid, so report the error */ const suffix = this.#allowedExtensions.length === 1 ? "is" : "are"; const message = [`Invalid file extension ${this.#file.extname}.`, `Only ${this.#allowedExtensions.join(", ")} ${suffix} allowed`].join(" "); this.#file.errors.push({ fieldName: this.#file.fieldName, clientName: this.#file.clientName, message, type: "extname" }); } /** * Validating the file in the streaming mode. During this mode * we defer the validation, until we get the file extname. */ #validateWhenGettingStreamed() { if (!this.#file.extname) return; this.validated = true; /** * Valid extension type */ if (this.#allowedExtensions.includes(this.#file.extname)) return; this.#reportError(); } /** * Validate the file extension after it has been streamed */ #validateAfterConsumed() { this.validated = true; /** * Valid extension type */ if (this.#allowedExtensions.includes(this.#file.extname || "")) return; this.#reportError(); } /** * Validates the file extension against the list of allowed extensions. * During streaming, validation waits until the extension is detected. * * @example * ```ts * const validator = new ExtensionValidator(file) * validator.extensions = ['jpg', 'png', 'gif'] * validator.validate() * * if (!file.isValid) { * console.log(file.errors) // Extension validation errors * } * ``` */ validate() { /** * Do not validate if already validated */ if (this.validated) return; /** * Do not run validations, when constraints on the extension are not set */ if (!Array.isArray(this.#allowedExtensions) || this.#allowedExtensions.length === 0) { this.validated = true; return; } if (this.#file.state === "streaming") { this.#validateWhenGettingStreamed(); return; } if (this.#file.state === "consumed") this.#validateAfterConsumed(); } }; //#endregion //#region src/multipart/file.ts const STORE_IN_FLASH = Symbol.for("store_in_flash"); /** * The file holds the meta/data for an uploaded file, along with * any errors that occurred during the upload process. */ var MultipartFile = class extends Macroable { [STORE_IN_FLASH] = false; /** * File validators for size and extension validation */ sizeValidator = new SizeValidator(this); extensionValidator = new ExtensionValidator(this); /** * A boolean to know if file is an instance of this class * or not */ isMultipartFile = true; /** * Field name is the name of the field */ fieldName; /** * Client name is the file name on the user client */ clientName; /** * The headers sent as part of the multipart request */ headers; /** * File size in bytes */ size = 0; /** * The extension for the file */ extname; /** * Upload errors that occurred during processing */ errors = []; /** * Type and subtype are extracted from the `content-type` * header or from the file magic number */ type; subtype; /** * File path is only set after the move operation */ filePath; /** * File name is only set after the move operation. It is the relative * path of the moved file */ fileName; /** * Temporary path, only exists when file is uploaded using the * classic mode */ tmpPath; /** * The file metadata */ meta = {}; /** * The current state of the file */ state = "idle"; /** * Whether or not the validations have been executed */ get validated() { return this.sizeValidator.validated && this.extensionValidator.validated; } /** * A boolean to know if file has one or more errors */ get isValid() { return this.errors.length === 0; } /** * Opposite of [[this.isValid]] */ get hasErrors() { return !this.isValid; } /** * The maximum file size limit */ get sizeLimit() { return this.sizeValidator.maxLimit; } set sizeLimit(limit) { this.sizeValidator.maxLimit = limit; } /** * Extensions allowed */ get allowedExtensions() { return this.extensionValidator.extensions; } set allowedExtensions(extensions) { this.extensionValidator.extensions = extensions; } /** * Creates a new MultipartFile instance * * @param data - Object containing field name, client name, and headers * @param validationOptions - Validation options for the file */ constructor(data, validationOptions) { super(); this.sizeLimit = validationOptions.size; this.allowedExtensions = validationOptions.extnames; this.fieldName = data.fieldName; this.clientName = data.clientName; this.headers = data.headers; } /** * Runs all configured validators (size and extension) on the file. * Validation results are stored in the errors array. * * @example * ```ts * file.sizeLimit = '2mb' * file.allowedExtensions = ['jpg', 'png'] * file.validate() * * if (!file.isValid) { * console.log(file.errors) * } * ``` */ validate() { this.extensionValidator.validate(); this.sizeValidator.validate(); } /** * Mark file as moved to its final destination * * @param fileName - The name of the moved file * @param filePath - The full path where the file was moved */ markAsMoved(fileName, filePath) { this.filePath = filePath; this.fileName = fileName; this.state = "moved"; } /** * Moves the file from its temporary location to a permanent destination. * Can be called multiple times to copy the file to multiple locations. * * @param location - The destination directory path * @param options - Move options including custom filename and overwrite flag * * @example * ```ts * const avatar = request.file('avatar') * * if (avatar) { * await avatar.move(app.publicPath('uploads'), { * name: `${Date.now()}.${avatar.extname}`, * overwrite: true * }) * } * ``` */ async move(location, options) { if (!this.tmpPath) throw new Exception("property \"tmpPath\" must be set on the file before moving it", { status: 500, code: "E_MISSING_FILE_TMP_PATH" }); options = Object.assign({ name: `${string.uuid()}.${this.extname ?? "unknown"}`, overwrite: true }, options); const filePath = join(location, options.name); try { await moveFile(this.tmpPath, filePath, { overwrite: options.overwrite }); this.markAsMoved(options.name, filePath); } catch (error) { if (error.message.includes("destination file already")) throw new Exception(`"${options.name}" already exists at "${location}". Set "overwrite = true" to overwrite it`); throw error; } } /** * Serializes the file to a JSON-compatible object containing all metadata, * validation state, and file paths. * * @example * ```ts * const file = request.file('avatar') * console.log(file?.toJSON()) * // { * // fieldName: 'avatar', * // clientName: 'profile.jpg', * // size: 45056, * // extname: 'jpg', * // type: 'image', * // subtype: 'jpeg', * // state: 'consumed', * // isValid: true, * // validated: true, * // errors: [] * // } * ``` */ toJSON() { return { fieldName: this.fieldName, clientName: this.clientName, size: this.size, filePath: this.filePath, fileName: this.fileName, type: this.type, extname: this.extname, subtype: this.subtype, state: this.state, isValid: this.isValid, validated: this.validated, errors: this.errors, meta: this.meta }; } }; //#endregion export { supportMagicFileTypes as a, getFileType as i, computeFileTypeFromName as n, formBodyNormalizers as r, MultipartFile as t };