@adonisjs/bodyparser
Version:
BodyParser middleware for AdonisJS http server to read and parse request body
606 lines (605 loc) • 15.6 kB
JavaScript
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 };