@adonisjs/bodyparser
Version:
BodyParser middleware for AdonisJS http server to read and parse request body
403 lines (397 loc) • 10.1 kB
JavaScript
// src/multipart/file.ts
import { join } from "node:path";
import { Exception } from "@poppinss/utils";
import Macroable from "@poppinss/macroable";
// src/helpers.ts
import mediaTyper from "media-typer";
import { dirname, extname } from "node:path";
import { RuntimeException } from "@poppinss/utils";
import { fileTypeFromBuffer, supportedExtensions } from "file-type";
import { access, mkdir, copyFile, unlink, rename } from "node:fs/promises";
var supportMagicFileTypes = supportedExtensions;
function parseMimeType(mime) {
try {
const { type, subtype } = mediaTyper.parse(mime);
return { type, subtype };
} catch (error) {
return null;
}
}
async function getFileType(fileContents) {
const magicType = await fileTypeFromBuffer(fileContents);
if (magicType) {
return Object.assign({ ext: magicType.ext }, parseMimeType(magicType.mime));
}
return null;
}
function computeFileTypeFromName(clientName, headers) {
return Object.assign(
{ ext: extname(clientName).replace(/^\./, "") },
parseMimeType(headers["content-type"])
);
}
async function pathExists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
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;
}
}
}
// src/multipart/validators/size.ts
import bytes from "bytes";
var SizeValidator = class {
#file;
#maximumAllowedLimit;
#bytesLimit = 0;
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 = typeof this.#maximumAllowedLimit === "string" ? bytes(this.#maximumAllowedLimit) : this.#maximumAllowedLimit;
}
}
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 ${bytes(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();
}
}
/**
* Validate the file size
*/
validate() {
if (this.validated) {
return;
}
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;
}
}
};
// src/multipart/validators/extensions.ts
var ExtensionValidator = class {
#file;
#allowedExtensions = [];
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;
}
constructor(file) {
this.#file = file;
}
/**
* Report error to the file
*/
#reportError() {
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;
if (this.#allowedExtensions.includes(this.#file.extname)) {
return;
}
this.#reportError();
}
/**
* Validate the file extension after it has been streamed
*/
#validateAfterConsumed() {
this.validated = true;
if (this.#allowedExtensions.includes(this.#file.extname || "")) {
return;
}
this.#reportError();
}
/**
* Validate the file
*/
validate() {
if (this.validated) {
return;
}
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();
}
}
};
// src/multipart/file.ts
var MultipartFile = class extends Macroable {
/**
* File validators
*/
#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 extname for the file.
*/
extname;
/**
* Upload errors
*/
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;
/**
* Tmp path, only exists when file is uploaded using the
* classic mode.
*/
tmpPath;
/**
* The file meta data
*/
meta = {};
/**
* The 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;
}
constructor(data, validationOptions) {
super();
this.sizeLimit = validationOptions.size;
this.allowedExtensions = validationOptions.extnames;
this.fieldName = data.fieldName;
this.clientName = data.clientName;
this.headers = data.headers;
}
/**
* Validate the file
*/
validate() {
this.#extensionValidator.validate();
this.#sizeValidator.validate();
}
/**
* Mark file as moved
*/
markAsMoved(fileName, filePath) {
this.filePath = filePath;
this.fileName = fileName;
this.state = "moved";
}
/**
* Moves the file to a given location. Multiple calls to the `move` method are allowed,
* incase you want to move a file to multiple locations.
*/
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: this.clientName, 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;
}
}
/**
* Returns file JSON representation
*/
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
};
}
};
export {
supportMagicFileTypes,
getFileType,
computeFileTypeFromName,
MultipartFile
};