Building a file upload service for managing public files in Nestjs with Amazon s3
Modern applications may require files in different formats as part of their product requirements. Storing these files in our database can result in downtime or poor performance because files can take up a lot of space. The best approach is to store these files in an external provider such as Amazon AWS, Google Cloud, or Microsoft Azure.
In this article, we will learn how to build a file upload service in Nestjs using Amazon S3. We will also learn how to configure the files to be either publicly available or accessed by users.
Prerequisites
To follow up with this article, you need to be familiar with the following:
Connecting to S3
S3 also known as Amazon's simple storage service serves as a storage provider for any form of files that are represented as buckets and assessed through the AWS SDK.
To connect to s3, we need to create an AWS account in which we can log in as a root user or IAM (Identity and Access Management) user. The best approach is to log in as an IAM user.
Setting up an IAM user
After you log in as a root user, go to your AWS console and search for IAM
After creating an IAM user, let's give the user full permission access to S3.
Once permission has been given to the user, an Access Key ID
and Secret Access Key
will be given to you which you can use to access our AWS bucket through our API.
Add these variables to your .env
in your Nestjs project:
.env
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=************
AWS_SECRET_ACCESS_KEY=*************
Using SDK to connect to AWS
To connect to AWS through our API, we need to install the AWS SDK. On your terminal run the following command:
npm install aws-sdk @types/aws-sdk
Connect to AWS with SDK through our main.ts
file
main.ts
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { setupSwagger } from './utils /swagger';
import helmet from 'helmet';
import * as cookieParser from 'cookie-parser';
**import { config } from 'aws-sdk';**
import { ConfigService } from '@nestjs/config';
import { HttpExceptionFilter } from './filters/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
app.enableCors({
origin: '*',
credentials: true,
});
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
}),
);
const logger = new Logger('Main');
app.enableCors({
origin: '*',
credentials: true,
});
app.setGlobalPrefix('api/v1');
app.useGlobalFilters(new HttpExceptionFilter());
setupSwagger(app);
app.use(helmet());
**const configService = app.get(ConfigService);
config.update({
accessKeyId: configService.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: configService.get('AWS_SECRET_ACCESS_KEY'),
region: configService.get('AWS_REGION'),
});**
await app.listen(AppModule.port);
// Log docs URL
const baseUrl = AppModule.getBaseUrl(app);
const url = `http://${baseUrl}:${AppModule.port}`;
logger.log(`API Documentation available at ${url}/docs`);
}
bootstrap();
Run the application, and if you configured everything well, it should run successfully.
Creating a bucket on AWS S3
To create a bucket which is how files are arranged in S3, we need to go to the AWS console and search for S3.
We want our buckets to be available to the public, so we have to allow public access to the files while creating the bucket. An example of files that we can allow public access to is a user’s profile image.
Next, we have to add the bucket name to our .env
file:
.env
AWS_PUBLIC_BUCKET_NAME=articlebucket
Your bucket name has to be unique, so provide it with another name.
Setting up an uploading file service
Once our server has successfully connected to AWS, we can proceed to create a file upload service but before that, we have to create a file schema.
On your terminal run the following command:
nest g module uploads
nest g controller uploads
nest g service uploads
touch src/uploads/upload.schema.ts
import { Document, ObjectId } from 'mongoose';
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Transform } from 'class-transformer';
export type UploadDocument = Document & Upload;
@Schema()
export class Upload {
@Transform(({ value }) => value.toString())
_id: ObjectId;
@Prop()
url: string;
@Prop()
key: string;
}
export const UploadSchema = SchemaFactory.createForClass(Upload);
The url
property saves the file url
in our database which gives us direct access to the file while the key
property serves as a unique identifier of each file in a bucket which is useful in some cases, for example, when you want to delete the file.
To make the key
unique, we can use the [uuid](https://www.npmjs.com/package/uuid)
package to generate unique IDs. On your terminal run:
npm install uuid @types/uuid
To create an upload service, go to uploads.service.ts
import { ConfigService } from '@nestjs/config';
import { Model } from 'mongoose';
import { Upload, UploadDocument } from './upload.schema';
import { HttpException, Injectable, HttpStatus } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { S3 } from 'aws-sdk';
import { v4 as uuid } from 'uuid';
@Injectable()
export class UploadsService {
constructor(
@InjectModel(Upload.name) private uploadModel: Model<UploadDocument>,
private configService: ConfigService,
) {}
async uploadFile(dataBuffer: Buffer, filename: string) {
const s3 = new S3();
const uploadResult = await s3
.upload({
Bucket: this.configService.get('AWS_PUBLIC_BUCKET_NAME'),
Body: dataBuffer,
Key: `${uuid}-${filename}`,
})
.promise();
const newFile = await this.uploadModel.create({
key: uploadResult.Key,
url: uploadResult.Location,
});
await newFile.save();
return newFile;
}
}
The uploadFile
method takes in a [buffer](https://www.quora.com/What-is-a-buffer-in-the-computer)
which is a memory chunk that serves as a binary representation of a file and the filename.
Setting up a controller for uploading files
Let’s create an endpoint for uploading user profile images. To do that, we have to create a User
schema and then reference the photo
property to the Upload
schema.
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import mongoose, { Document } from "mongoose";
import { Upload } from "src/uploads/upload.schema";
export type UserDocument = User & Document;
@Schema({
toJSON: {
getters: true,
virtuals: true,
},
timestamps: true,
})
export class User {
@Prop({ required: true, unique: true })
username: string
@Prop({ required: true })
password: string
@Prop({ required: true, unique: true })
email: string
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'Upload' })
photo: Upload;
}
const UserSchema = SchemaFactory.createForClass(User)
UserSchema.index({ email: 1 });
export { UserSchema };
In our user service, we can create a method that can enable a user to upload profile images. We have to make the upload service a dependency of other services by providing in the list of exports in uploads.module.ts
import { Upload, UploadSchema } from './upload.schema';
import { Module } from '@nestjs/common';
import { UploadsController } from './uploads.controller';
import { UploadsService } from './uploads.service';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
MongooseModule.forFeature([
{ name: Upload.name, schema: UploadSchema },
]),
],
controllers: [UploadsController],
providers: [UploadsService],
exports: [UploadsService],
})
export class UploadsModule {}
The uploads service can now be used as a dependency for the user service:
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { User, UserDocument } from './schemas/user.schema';
import { Model } from 'mongoose';
import { UploadsService } from 'src/uploads/uploads.service';
@Injectable()
export class UsersService {
constructor(@InjectModel(User.name) private userModel: Model<UserDocument>, private uploadsService: UploadsService) {}
async addProfilePhoto(
userId: string,
imageBuffer: Buffer,
filename: string,
) {
const photo = await this.uploadsService.uploadFile(
imageBuffer,
filename,
);
const updatedUser = await this.userModel.findByIdAndUpdate(userId, {
photo: photo,
});
if (!updatedUser) {
throw new HttpException(
'The user with this id does not exist',
HttpStatus.NOT_FOUND,
);
}
return updatedUser;
}
}
We can now set up an endpoint that would utilize the addProfilePhoto
method to upload an image. To do this we have to use the [FileInterceptor](https://docs.nestjs.com/techniques/file-upload)
which uses [multer](https://www.npmjs.com/package/multer)
under the hood.
import { AccessTokenGuard } from 'src/common/guards/accessToken.guard';
import { UsersService } from './users.service';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Req, UseGuards, UploadedFile, UseInterceptors } from '@nestjs/common';
import MongooseClassSerializerInterceptor from 'src/utils /mongooseClassSerializer.interceptor';
import { User } from './schemas/user.schema';
import RequestWithUser from 'src/auth/requestWithUser.interface';
import { FileInterceptor } from '@nestjs/platform-express';
import { Express } from 'express';
@Controller('users')
@ApiTags('Users')
@UseInterceptors(MongooseClassSerializerInterceptor(User))
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post('profile-image')
@UseGuards(AccessTokenGuard)
@UseInterceptors(FileInterceptor('file'))
async addProfileImage(@Req() request: RequestWithUser, @UploadedFile() file: Express.Multer.File) {
return this.usersService.addProfilePhoto(request.user._id, file.buffer, file.originalname);
}
}
Setting up a delete file service
We need to delete the file from both our database and AWS s3. Go to the uploads service and write a **deleteFile
**method which takes in a file ID.
import { ConfigService } from '@nestjs/config';
import { Model } from 'mongoose';
import { Upload, UploadDocument } from './upload.schema';
import { HttpException, Injectable, HttpStatus } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { S3 } from 'aws-sdk';
import { v4 as uuid } from 'uuid';
@Injectable()
export class UploadsService {
constructor(
@InjectModel(Upload.name) private uploadModel: Model<UploadDocument>,
private configService: ConfigService,
) {}
async deleteFile(fileId: string) {
const file = await this.uploadModel.findById(fileId);
if (!file) {
throw new HttpException('File not found', HttpStatus.NOT_FOUND);
}
const s3 = new S3();
await s3
.deleteObject({
Bucket: this.configService.get('AWS_PUBLIC_BUCKET_NAME'),
Key: file.key,
})
.promise();
await this.uploadModel.findByIdAndDelete(fileId);
}
}
Let’s add a deleteProfilePhoto
to the user service.
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { User, UserDocument } from './schemas/user.schema';
import { Model } from 'mongoose';
import { UploadsService } from 'src/uploads/uploads.service';
@Injectable()
export class UsersService {
constructor(@InjectModel(User.name) private userModel: Model<UserDocument>, private uploadsService: UploadsService) {}
async addProfilePhoto(
userId: string,
imageBuffer: Buffer,
filename: string,
) {
const photo = await this.uploadsService.uploadFile(
imageBuffer,
filename,
);
const updatedUser = await this.userModel.findByIdAndUpdate(userId, {
photo: photo,
});
if (!updatedUser) {
throw new HttpException(
'The user with this id does not exist',
HttpStatus.NOT_FOUND,
);
}
return updatedUser;
}
async deleteProfilePhoto(userId: string) {
const user = await this.userModel.findById(userId);
if (!user) {
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
}
const fileId = String(user.photo._id);
if (fileId) {
await this.userModel.findByIdAndUpdate(userId, {
photo: null,
});
}
await this.uploadsService.deleteFile(fileId);
}
}
Set up a custom controller to handle files
We can set up a custom controller or route in uploads.controller.ts
that can handle a schema with just one image property or a schema that has an array of images as a property. For example, this can be useful in an eCommerce application that has a product schema with multiple product images. Instead of building two different routes for products and a user's profile image, we can just build one custom route that can handle either of the requests.
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
ForbiddenException,
} from '@nestjs/common';
import { ApiTags, ApiBody, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
import { UploadsService } from './uploads.service';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
@Controller('uploads')
@ApiBearerAuth()
@ApiTags('Uploads')
export class UploadsController {
constructor(private readonly fileUploadService: UploadsService) {}
@Post('image')
@ApiBody({
required: true,
type: 'multipart/form-data',
schema: {
type: 'object',
properties: {
files: {
type: 'array',
items: {
type: 'string',
format: 'binary',
},
},
},
},
})
@ApiConsumes('multipart/form-data')
@UseInterceptors(AnyFilesInterceptor())
async uploadFile(@UploadedFile('files') files) {
if (!files[0])
throw new ForbiddenException("You can't upload a blank field");
const imageArr = [];
for (const i in files) {
const uploadedFile = await this.fileUploadService.uploadFile(
files[i].buffer,
files[i].originalname,
);
imageArr.push(uploadedFile.url);
}
return { error: false, data: imageArr };
}
}
The uploadFile
controller takes in an array of files and loops through them to access each of the files buffer or data chunks and file names and save them to the file upload
schema.
Summary
In this article, we demonstrated how we can use AWS S3 to serve public files instead of saving them in the same database as our application which affects the performance of our application.
You can leave a comment if you face any issues implementing any part of this article.