Upload
Multipart file upload splits into a driver-agnostic half and a Fastify half:
@modularityjs/http-upload— kindshared:HttpUploadConfig(size/count limits + mimetype allowlist) and thesanitizeFilenamehelper. No Fastify anywhere. It declares no abstract and no pool — there is nothing here for a driver to implement, only vocabulary for driver-specific packages to read — which is why it issharedrather than a contract.@modularityjs/http-fastify-upload— the Fastify extension: registers@fastify/multipartwith the configured limits and provides the parameter resolvers behind@UploadedFile()/@UploadedFiles().
The @UploadedFile / @UploadedFiles decorators and the FileUpload interface live in @modularityjs/http itself — controllers depend only on the abstract HTTP contract.
Wiring
import { HttpUploadModule } from '@modularityjs/http-upload';
import { HttpFastifyUploadModule } from '@modularityjs/http-fastify-upload';
const modules = [
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
HttpUploadModule.forRoot({
maxFileSize: 5 * 1024 * 1024,
allowedMimeTypes: ['image/*', 'application/pdf'],
}),
HttpFastifyUploadModule,
];HttpFastifyUploadModule registers the multipart plugin in afterLoad (before route registration, like every Fastify-plugin extension) and binds the file parameter resolvers as ordinary preferences.
Receiving files in a controller
import type { FileUpload } from '@modularityjs/http';
import {
Controller,
Post,
UploadedFile,
UploadedFiles,
} from '@modularityjs/http';
@Controller('/documents')
export class DocumentsController {
@Post('/avatar')
async uploadAvatar(@UploadedFile('avatar') file: FileUpload | undefined) {
if (!file) {
throw ValidationException.invalidField('avatar', 'No file uploaded.');
}
const content = await file.toBuffer();
// persist via StorageService, hand to media pipeline, ...
return { filename: file.filename, size: file.size };
}
@Post('/batch')
async uploadMany(@UploadedFiles() files: FileUpload[]) {
return { count: files.length };
}
}@UploadedFile('field')resolves the file for that form field; without a name it takes the first file. Absent file →undefined(the controller decides whether that's an error).@UploadedFiles()resolves every file in the request.- Files are parsed once per request and cached — mixing both decorators, or resolving in a guard and the handler, doesn't re-read the stream.
The FileUpload shape:
interface FileUpload {
readonly filename: string;
readonly mimetype: string;
readonly size: number; // bytes
toBuffer(): Promise<Buffer>;
}Limits (HttpUploadConfig)
All limits are enforced by the Fastify multipart parser; the config validates itself at boot (positive integers, well-formed mimetype patterns):
| Field | Default | Meaning |
|---|---|---|
maxFileSize | 10 MiB | Per-file size cap in bytes. |
maxFiles | 10 | Files per request. |
maxParts | 1000 | Total multipart parts per request. |
maxFields | 100 | Non-file fields per request. |
maxFieldSize | 1 MiB | Per-field value size in bytes. |
maxTotalUploadSize | 50 MiB | Aggregate cap across all files in one request (bytes). |
allowedMimeTypes | (unset) | Allowlist of type/subtype or type/* patterns. |
When allowedMimeTypes is set, a disallowed mimetype throws a ValidationException (422) before the offending part is buffered — the file parser checks each part as it streams, so a flood of forbidden files can't fill the heap first. Unset means every mimetype is accepted. Matching is case-insensitive and supports wildcards on the subtype (image/*).
maxTotalUploadSize is a heap safety net: @fastify/multipart installs its own body parser, so HttpModuleConfig.bodyLimit does not apply to multipart/form-data. Without an aggregate cap a request could buffer maxFileSize × maxFiles in memory before any handler runs; the parser aborts with a 422 once the running total exceeds this limit. Raise it for legitimately large multi-file uploads.
Note the declared mimetype comes from the client — treat it as a first-line filter, not proof of content. Sniff the actual bytes (e.g. via the media pipeline) before doing anything content-sensitive.
sanitizeFilename
Client-supplied filenames are attacker-controlled. Before using one in a storage key or filesystem path, run it through the exported helper:
import { sanitizeFilename } from '@modularityjs/http-upload';
sanitizeFilename('../../etc/passwd'); // 'etc_passwd'
sanitizeFilename('con.txt'); // 'con_.txt' (Windows reserved name)
sanitizeFilename(''); // 'file'It strips path separators and ASCII control characters, collapses .. runs, trims leading dots/underscores and trailing dots/spaces, caps length at 255, neutralizes Windows reserved device names (con, prn, aux, nul, com1–com9, lpt1–lpt9), and falls back to 'file' when nothing survives.
Storing uploads
Pair with Storage:
@Post('/upload')
async upload(@UploadedFile() file: FileUpload | undefined) {
if (!file) {
throw ValidationException.invalidField('file', 'No file uploaded.');
}
const key = `uploads/${crypto.randomUUID()}-${sanitizeFilename(file.filename)}`;
await this.storage.write(key, await file.toBuffer());
return { key };
}