Skip to content

File Uploads

@fozooni/nestjs-storage provides NestJS interceptors that handle file uploads and automatically store them to a disk, plus validation pipes for robust file type checking.

WARNING

File upload interceptors require multer as a peer dependency. Install it before using StorageFileInterceptor or StorageFilesInterceptor:

bash
pnpm add multer
pnpm add -D @types/multer

Single File Upload

StorageFileInterceptor

StorageFileInterceptor is a factory function that creates an interceptor for handling a single file upload. It receives the file from the request, stores it on the configured disk, and attaches a StoredFile object to the request.

typescript
import {
  Controller,
  Post,
  UploadedFile,
  UseInterceptors,
} from '@nestjs/common';
import {
  StorageFileInterceptor,
  StoredFile,
} from '@fozooni/nestjs-storage';

@Controller('uploads')
export class UploadsController {
  @Post('avatar')
  @UseInterceptors(
    StorageFileInterceptor('avatar', {
      disk: 'local',
      path: 'avatars',
    }),
  )
  uploadAvatar(@UploadedFile() file: StoredFile) {
    return {
      message: 'Avatar uploaded successfully',
      path: file.path,
      url: file.url,
      size: file.size,
    };
  }
}

StorageFileInterceptor Options

OptionTypeDefaultDescription
diskstringDefault diskWhich disk to store the file on.
pathstring''Directory prefix within the disk.
namingStrategyNamingStrategyDisk defaultHow to generate the stored filename.
fileFilter(req, file, cb) => voidAccept allMulter file filter function.
limitsMulterLimitsundefinedMulter limits (fileSize, files, etc.).

StoredFile Interface

The StoredFile object returned after successful upload contains:

PropertyTypeDescription
pathstringFull path within the disk (e.g., 'avatars/550e8400.jpg').
urlstringPublic URL of the stored file.
sizenumberFile size in bytes.
mimetypestringDetected MIME type (e.g., 'image/jpeg').
originalnamestringOriginal filename from the client.
diskstringName of the disk the file was stored on.

Multi-File Upload

StorageFilesInterceptor

For endpoints that accept multiple files:

typescript
import {
  Controller,
  Post,
  UploadedFiles,
  UseInterceptors,
} from '@nestjs/common';
import {
  StorageFilesInterceptor,
  StoredFile,
} from '@fozooni/nestjs-storage';

@Controller('gallery')
export class GalleryController {
  @Post('photos')
  @UseInterceptors(
    StorageFilesInterceptor('photos', 10, {
      disk: 's3',
      path: 'gallery',
      limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB per file
    }),
  )
  uploadPhotos(@UploadedFiles() files: StoredFile[]) {
    return {
      message: `${files.length} photos uploaded`,
      photos: files.map((f) => ({
        path: f.path,
        url: f.url,
        size: f.size,
        originalname: f.originalname,
      })),
    };
  }
}

The second argument to StorageFilesInterceptor is maxCount -- the maximum number of files the endpoint will accept. If the client sends more, the request will be rejected.

File Validation

ParseFilePipe with Custom Validators

Use NestJS's ParseFilePipe combined with the built-in validators for robust file type checking:

typescript
import {
  Controller,
  Post,
  UploadedFile,
  UseInterceptors,
  ParseFilePipe,
} from '@nestjs/common';
import {
  StorageFileInterceptor,
  StoredFile,
  FileExtensionValidator,
  MagicBytesValidator,
} from '@fozooni/nestjs-storage';

@Controller('documents')
export class DocumentsController {
  @Post('upload')
  @UseInterceptors(
    StorageFileInterceptor('document', {
      disk: 's3',
      path: 'documents',
    }),
  )
  upload(
    @UploadedFile(
      new ParseFilePipe({
        validators: [
          new FileExtensionValidator({
            allowedExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx'],
          }),
          new MagicBytesValidator({
            allowedTypes: ['pdf'],
          }),
        ],
      }),
    )
    file: StoredFile,
  ) {
    return {
      path: file.path,
      url: file.url,
      mimetype: file.mimetype,
    };
  }
}

FileExtensionValidator

Validates the file extension against an allow list:

typescript
new FileExtensionValidator({
  allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
})

WARNING

Extension validation alone is not secure -- a malicious user can rename any file to have a .jpg extension. Always combine it with MagicBytesValidator for production use.

MagicBytesValidator

Validates the actual file content by inspecting the file's magic bytes (file signature). This is the most reliable way to verify file types:

typescript
new MagicBytesValidator({
  allowedTypes: ['jpeg', 'png', 'gif', 'pdf'],
})

Supported Magic Byte Types

TypeFile SignaturesMIME Types
jpeg / jpgFF D8 FFimage/jpeg
png89 50 4E 47image/png
gif47 49 46 38image/gif
webp52 49 46 46...57 45 42 50image/webp
pdf25 50 44 46application/pdf
zip50 4B 03 04application/zip
wav52 49 46 46...57 41 56 45audio/wav
mp3FF FB / 49 44 33audio/mpeg
ogg4F 67 67 53audio/ogg
webm1A 45 DF A3video/webm
mp466 74 79 70 (offset 4)video/mp4
svg3C 73 76 67 / 3C 3F 78 6Dimage/svg+xml

Complete Upload + Validate + Respond

Here is a comprehensive example combining interceptors, validation, naming strategies, and a full service layer:

typescript
// uploads.controller.ts
import {
  Controller,
  Post,
  UploadedFile,
  UseInterceptors,
  ParseFilePipe,
  MaxFileSizeValidator,
  BadRequestException,
} from '@nestjs/common';
import {
  StorageFileInterceptor,
  StoredFile,
  FileExtensionValidator,
  MagicBytesValidator,
  DatePathNamingStrategy,
} from '@fozooni/nestjs-storage';
import { UploadsService } from './uploads.service';

@Controller('api/uploads')
export class UploadsController {
  constructor(private readonly uploadsService: UploadsService) {}

  @Post('image')
  @UseInterceptors(
    StorageFileInterceptor('image', {
      disk: 's3',
      path: 'images',
      namingStrategy: new DatePathNamingStrategy(),
      limits: {
        fileSize: 5 * 1024 * 1024, // 5 MB
      },
      fileFilter: (_req, file, cb) => {
        if (!file.mimetype.startsWith('image/')) {
          cb(new BadRequestException('Only image files are allowed'), false);
        } else {
          cb(null, true);
        }
      },
    }),
  )
  async uploadImage(
    @UploadedFile(
      new ParseFilePipe({
        validators: [
          new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
          new FileExtensionValidator({
            allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
          }),
          new MagicBytesValidator({
            allowedTypes: ['jpeg', 'png', 'gif', 'webp'],
          }),
        ],
      }),
    )
    file: StoredFile,
  ) {
    const record = await this.uploadsService.recordUpload(file);

    return {
      id: record.id,
      url: file.url,
      path: file.path,
      size: file.size,
      mimetype: file.mimetype,
      originalname: file.originalname,
    };
  }
}
typescript
// uploads.service.ts
import { Injectable } from '@nestjs/common';
import { StoredFile, StorageService } from '@fozooni/nestjs-storage';

interface UploadRecord {
  id: string;
  path: string;
  url: string;
  size: number;
  mimetype: string;
  originalname: string;
  uploadedAt: Date;
}

@Injectable()
export class UploadsService {
  private uploads: Map<string, UploadRecord> = new Map();

  constructor(private readonly storage: StorageService) {}

  async recordUpload(file: StoredFile): Promise<UploadRecord> {
    const id = crypto.randomUUID();
    const record: UploadRecord = {
      id,
      path: file.path,
      url: file.url,
      size: file.size,
      mimetype: file.mimetype,
      originalname: file.originalname,
      uploadedAt: new Date(),
    };

    this.uploads.set(id, record);
    return record;
  }

  async deleteUpload(id: string): Promise<void> {
    const record = this.uploads.get(id);
    if (record) {
      await this.storage.disk('s3').delete(record.path);
      this.uploads.delete(id);
    }
  }
}

Naming Strategies in Interceptors

When you set a namingStrategy in the interceptor options, the file is renamed before being stored. This means file.path in your controller already reflects the final name. For example, with DatePathNamingStrategy, an uploaded photo.jpg might have a path of images/2026/03/17/a1b2c3d4.jpg.

See Naming Strategies for all available strategies and how to create your own.

Upload with Custom Metadata

You can attach metadata to uploaded files by combining the interceptor with a service call:

typescript
@Post('document')
@UseInterceptors(
  StorageFileInterceptor('file', {
    disk: 's3',
    path: 'documents',
  }),
)
async uploadDocument(
  @UploadedFile() file: StoredFile,
  @Body('title') title: string,
  @Body('category') category: string,
) {
  // Re-write the file with metadata attached
  const content = await this.storage.disk('s3').get(file.path, {
    responseType: 'buffer',
  });

  await this.storage.disk('s3').put(file.path, content, {
    metadata: {
      title,
      category,
      uploadedAt: new Date().toISOString(),
    },
  });

  return { path: file.path, title, category };
}

Frontend Integration

HTML Form

html
<form action="/api/uploads/image" method="POST" enctype="multipart/form-data">
  <input type="file" name="image" accept="image/*" />
  <button type="submit">Upload</button>
</form>

Fetch API

typescript
const formData = new FormData();
formData.append('image', fileInput.files[0]);

const response = await fetch('/api/uploads/image', {
  method: 'POST',
  body: formData,
});

const result = await response.json();
console.log('Uploaded to:', result.url);

Axios

typescript
const formData = new FormData();
formData.append('image', file);

const { data } = await axios.post('/api/uploads/image', formData, {
  headers: { 'Content-Type': 'multipart/form-data' },
  onUploadProgress: (event) => {
    const progress = Math.round((event.loaded * 100) / event.total);
    console.log(`Upload progress: ${progress}%`);
  },
});

Next Steps

Learn how to generate URLs & Downloads for your uploaded files -- public URLs, presigned URLs, CDN URLs, and streaming downloads.

Released under the MIT License.