Nest.js Tutorial

Uploading files to the server

Marcin Wanago
JavaScriptNestJSTypeScript

So far, in this series, we’ve described two ways of storing files on a server. In the 10th article, we’ve uploaded files to Amazon S3. While it is very scalable, we might not want to use cloud services such as AWS for various reasons. Therefore, in the 54th part of the series, we’ve learned how to store files straight in our PostgreSQL database. While it has some advantages, it might be perceived as less than ideal in terms of performance.

In this article, we look into using NestJS to store uploaded files on the server. Again, we persist some information into the database, but it is just the metadata this time.

Storing the files on the server

Fortunately, NestJS makes it very easy to store the files on the server. We need to pass additional arguments to the FileInterceptor.

users.service.ts
1import { UsersService } from './users.service';
2import { Controller, Post, Req, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
3import JwtAuthenticationGuard from '../authentication/jwt-authentication.guard';
4import RequestWithUser from '../authentication/requestWithUser.interface';
5import { Express } from 'express';
6import { FileInterceptor } from '@nestjs/platform-express';
7import { diskStorage } from 'multer';
8 
9@Controller('users')
10export class UsersController {
11  constructor(
12    private readonly usersService: UsersService,
13  ) {}
14 
15  @Post('avatar')
16  @UseGuards(JwtAuthenticationGuard)
17  @UseInterceptors(FileInterceptor('file', {
18    storage: diskStorage({
19      destination: './uploadedFiles/avatars'
20    })
21  }))
22  async addAvatar(@Req() request: RequestWithUser, @UploadedFile() file: Express.Multer.File) {
23    return this.usersService.addAvatar(request.user.id, {
24      path: file.path,
25      filename: file.originalname,
26      mimetype: file.mimetype
27    });
28  }
29}

When we do the above, NestJS stores uploaded files in the ./uploadedFiles/avatars directory.

There are a few issues with the above approach, though. First, we might need more than one endpoint to accept files. In such a case, we would need to repeat some parts of the configuration for each one of them. Also, we should put the ./uploadedFiles part of the destination in an environment variable to change it based on the environment the app runs in.

Extending the FileInterceptor

A way to achieve the above is to extend the FileInterceptor. After looking under the hood of NestJS, we can see that it uses the mixin pattern. Because FileInterceptor is not a class, we can’t use the extend keyword.

We want to extend the FileInterceptor functionalities while:

  • having Dependency Injection to inject the ConfigService,
  • being able to pass additional properties from the controller.

To do that, we can create our mixin:

1import { FileInterceptor } from '@nestjs/platform-express';
2import { Injectable, mixin, NestInterceptor, Type } from '@nestjs/common';
3import { ConfigService } from '@nestjs/config';
4import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
5import { diskStorage } from 'multer';
6 
7interface LocalFilesInterceptorOptions {
8  fieldName: string;
9  path?: string;
10}
11 
12function LocalFilesInterceptor (options: LocalFilesInterceptorOptions): Type<NestInterceptor> {
13  @Injectable()
14  class Interceptor implements NestInterceptor {
15    fileInterceptor: NestInterceptor;
16    constructor(configService: ConfigService) {
17      const filesDestination = configService.get('UPLOADED_FILES_DESTINATION');
18 
19      const destination = `${filesDestination}${options.path}`
20 
21      const multerOptions: MulterOptions = {
22        storage: diskStorage({
23          destination
24        })
25      }
26 
27      this.fileInterceptor = new (FileInterceptor(options.fieldName, multerOptions));
28    }
29 
30    intercept(...args: Parameters<NestInterceptor['intercept']>) {
31      return this.fileInterceptor.intercept(...args);
32    }
33  }
34  return mixin(Interceptor);
35}
36 
37export default LocalFilesInterceptor;

Above, we use the UPLOADED_FILES_DESTINATION variable and concatenate it with the provided path. To do that, let’s define the necessary environment variable.

1import { Module } from '@nestjs/common';
2import { ConfigModule } from '@nestjs/config';
3import * as Joi from '@hapi/joi';
4 
5@Module({
6  imports: [
7    ConfigModule.forRoot({
8      validationSchema: Joi.object({
9        UPLOADED_FILES_DESTINATION: Joi.string().required(),
10        // ...
11      })
12    }),
13    // ...
14  ],
15  // ...
16})
17export class AppModule {
18  // ...
19}
1UPLOADED_FILES_DESTINATION=./uploadedFiles
2# ...

When all of the above is ready, we can use the LocalFilesInterceptor in our controller:

1import { UsersService } from './users.service';
2import { Controller, Post, Req, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
3import JwtAuthenticationGuard from '../authentication/jwt-authentication.guard';
4import RequestWithUser from '../authentication/requestWithUser.interface';
5import { Express } from 'express';
6import LocalFilesInterceptor from '../localFiles/localFiles.interceptor';
7 
8@Controller('users')
9export class UsersController {
10  constructor(
11    private readonly usersService: UsersService,
12  ) {}
13 
14  @Post('avatar')
15  @UseGuards(JwtAuthenticationGuard)
16  @UseInterceptors(LocalFilesInterceptor({
17    fieldName: 'file',
18    path: '/avatars'
19  }))
20  async addAvatar(@Req() request: RequestWithUser, @UploadedFile() file: Express.Multer.File) {
21    return this.usersService.addAvatar(request.user.id, {
22      path: file.path,
23      filename: file.originalname,
24      mimetype: file.mimetype
25    });
26  }
27}

Saving the metadata in the database

Besides storing the file on the server, we also need to save the file’s metadata in the database. Since NestJS generates a random filename for uploaded files, we also want to store the original filename. To do all of the above, we need to create an entity for the metadata.

1import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
2 
3@Entity()
4class LocalFile {
5  @PrimaryGeneratedColumn()
6  public id: number;
7 
8  @Column()
9  filename: string;
10 
11  @Column()
12  path: string;
13 
14  @Column()
15  mimetype: string;
16}
17 
18export default LocalFile;
1interface LocalFileDto {
2  filename: string;
3  path: string;
4  mimetype: string;
5}

We also need to create a relationship between users and files.

1import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
2import LocalFile from '../localFiles/localFile.entity';
3 
4@Entity()
5class User {
6  @PrimaryGeneratedColumn()
7  public id: number;
8 
9  @JoinColumn({ name: 'avatarId' })
10  @OneToOne(
11    () => LocalFile,
12    {
13      nullable: true
14    }
15  )
16  public avatar?: LocalFile;
17 
18  @Column({ nullable: true })
19  public avatarId?: number;
20 
21  // ...
22}
23 
24export default User;
We add the avatarId column above so that the entity of the user can hold the id of the avatar without joining all of the data of the avatar.

While we’re at it, we also need to create the basics of the LocalFilesService:

1import { Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository } from 'typeorm';
4import LocalFile from './localFile.entity';
5 
6@Injectable()
7class LocalFilesService {
8  constructor(
9    @InjectRepository(LocalFile)
10    private localFilesRepository: Repository<LocalFile>,
11  ) {}
12 
13  async saveLocalFileData(fileData: LocalFileDto) {
14    const newFile = await this.localFilesRepository.create(fileData)
15    await this.localFilesRepository.save(newFile);
16    return newFile;
17  }
18}
19 
20export default LocalFilesService;

The last step is to use the saveLocalFileData method in the UsersService:

1import { Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository, Connection, In } from 'typeorm';
4import User from './user.entity';
5import LocalFilesService from '../localFiles/localFiles.service';
6 
7@Injectable()
8export class UsersService {
9  constructor(
10    @InjectRepository(User)
11    private usersRepository: Repository<User>,
12    private localFilesService: LocalFilesService
13  ) {}
14 
15  async addAvatar(userId: number, fileData: LocalFileDto) {
16    const avatar = await this.localFilesService.saveLocalFileData(fileData);
17    await this.usersRepository.update(userId, {
18      avatarId: avatar.id
19    })
20  }
21 
22  // ...
23}

Retrieving the files

Now, the user can retrieve the id of their avatar.

To download the file with a given id, we can create a controller that streams the content.

The first step in achieving the above is extending the LocalFilesService:

1import { Injectable, NotFoundException } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository } from 'typeorm';
4import LocalFile from './localFile.entity';
5 
6@Injectable()
7class LocalFilesService {
8  constructor(
9    @InjectRepository(LocalFile)
10    private localFilesRepository: Repository<LocalFile>,
11  ) {}
12  
13  async getFileById(fileId: number) {
14    const file = await this.localFilesRepository.findOne(fileId);
15    if (!file) {
16      throw new NotFoundException();
17    }
18    return file;
19  }
20  
21  // ...
22}
23 
24export default LocalFilesService;

We also need to create a controller that uses the above method:

1import {
2  Controller,
3  Get,
4  Param,
5  UseInterceptors,
6  ClassSerializerInterceptor,
7  StreamableFile,
8  Res,
9  ParseIntPipe,
10} from '@nestjs/common';
11import LocalFilesService from './localFiles.service';
12import { Response } from 'express';
13import { createReadStream } from 'fs';
14import { join } from 'path';
15 
16@Controller('local-files')
17@UseInterceptors(ClassSerializerInterceptor)
18export default class LocalFilesController {
19  constructor(
20    private readonly localFilesService: LocalFilesService
21  ) {}
22 
23  @Get(':id')
24  async getDatabaseFileById(@Param('id', ParseIntPipe) id: number, @Res({ passthrough: true }) response: Response) {
25    const file = await this.localFilesService.getFileById(id);
26 
27    const stream = createReadStream(join(process.cwd(), file.path));
28 
29    response.set({
30      'Content-Disposition': `inline; filename="${file.filename}"`,
31      'Content-Type': file.mimetype
32    })
33    return new StreamableFile(stream);
34  }
35}
We learn about the StreamableFile class and the Content-Disposition header in the previous part of this series.

Doing the above allows the user to retrieve the file with a given id.

Filtering incoming files

We shouldn’t always trust the files our users upload. Fortunately, we can easily filter them with the fileFilter and limits properties supported by multer.

1import { FileInterceptor } from '@nestjs/platform-express';
2import { Injectable, mixin, NestInterceptor, Type } from '@nestjs/common';
3import { ConfigService } from '@nestjs/config';
4import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
5import { diskStorage } from 'multer';
6 
7interface LocalFilesInterceptorOptions {
8  fieldName: string;
9  path?: string;
10  fileFilter?: MulterOptions['fileFilter'];
11  limits?: MulterOptions['limits'];
12}
13 
14function LocalFilesInterceptor (options: LocalFilesInterceptorOptions): Type<NestInterceptor> {
15  @Injectable()
16  class Interceptor implements NestInterceptor {
17    fileInterceptor: NestInterceptor;
18    constructor(configService: ConfigService) {
19      const filesDestination = configService.get('UPLOADED_FILES_DESTINATION');
20 
21      const destination = `${filesDestination}${options.path}`
22 
23      const multerOptions: MulterOptions = {
24        storage: diskStorage({
25          destination
26        }),
27        fileFilter: options.fileFilter,
28        limits: options.limits
29      }
30 
31      this.fileInterceptor = new (FileInterceptor(options.fieldName, multerOptions));
32    }
33 
34    intercept(...args: Parameters<NestInterceptor['intercept']>) {
35      return this.fileInterceptor.intercept(...args);
36    }
37  }
38  return mixin(Interceptor);
39}
40 
41export default LocalFilesInterceptor;

Let’s allow only files that include “image” in the mimetype and are smaller than 1MB.

1import { UsersService } from './users.service';
2import { BadRequestException, Controller, Post, Req, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
3import JwtAuthenticationGuard from '../authentication/jwt-authentication.guard';
4import RequestWithUser from '../authentication/requestWithUser.interface';
5import { Express } from 'express';
6import LocalFilesInterceptor from '../localFiles/localFiles.interceptor';
7 
8@Controller('users')
9export class UsersController {
10  constructor(
11    private readonly usersService: UsersService,
12  ) {}
13 
14  @Post('avatar')
15  @UseGuards(JwtAuthenticationGuard)
16  @UseInterceptors(LocalFilesInterceptor({
17    fieldName: 'file',
18    path: '/avatars',
19    fileFilter: (request, file, callback) => {
20      if (!file.mimetype.includes('image')) {
21        return callback(new BadRequestException('Provide a valid image'), false);
22      }
23      callback(null, true);
24    },
25    limits: {
26      fileSize: Math.pow(1024, 2) // 1MB
27    }
28  }))
29  async addAvatar(@Req() request: RequestWithUser, @UploadedFile() file: Express.Multer.File) {
30    return this.usersService.addAvatar(request.user.id, {
31      path: file.path,
32      filename: file.originalname,
33      mimetype: file.mimetype
34    });
35  }
36}

If the file doesn’t meet the size requirements, NestJS throws 413 Payload Too Large. It could be a good idea to go beyond just checking the mimetype and using the file-type library.

Summary

In this article, we’ve covered the basics of managing files on our server through NestJS. We’ve learned how to store them on the server and return them to the user. When doing that, we’ve extended the built-in FileInterceptor and implemented filtering.  There are still ways to extend the code from this article. Feel free to implement file deleting and use transactions as described in the 15th part of this series.

Thanks to learning about various ways of storing files, you are now free to compare the advantages and disadvantages and use an approach to suit your needs best.