Skip to content

Upload

Multipart file upload splits into a driver-agnostic half and a Fastify half:

  • @modularityjs/http-upload — kind shared: HttpUploadConfig (size/count limits + mimetype allowlist) and the sanitizeFilename helper. 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 is shared rather than a contract.
  • @modularityjs/http-fastify-upload — the Fastify extension: registers @fastify/multipart with 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

typescript
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

typescript
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:

typescript
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):

FieldDefaultMeaning
maxFileSize10 MiBPer-file size cap in bytes.
maxFiles10Files per request.
maxParts1000Total multipart parts per request.
maxFields100Non-file fields per request.
maxFieldSize1 MiBPer-field value size in bytes.
maxTotalUploadSize50 MiBAggregate 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:

typescript
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, com1com9, lpt1lpt9), and falls back to 'file' when nothing survives.

Storing uploads

Pair with Storage:

typescript
@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 };
}

Next Steps

  • Storage — persist uploads behind the storage contract
  • Media — typed image/video/audio transforms on uploaded content
  • HTTP — controllers, parameter resolvers, and the extension seams