Photo by Zachary XU on Unsplash

We can store data inside various cloud storage sources like S3, Google Drive, Dropbox, or simply on the same server where our backend is hosted. In this tutorial, we will see how to do it for the latter as the storage target.

We need a real-world use case to better view how this implementation can apply with your own. Here is a good one:

We have a web application where an user can register by filling his full name and a picture of him. When the user submit the data, our backend should store the picture inside a directory on the server then save user in the database. The backend should allow us to view or download the picture of an user through his id.

Project Setup

To start, we will use a boilerplate for the Node.js project I built throughout this tutorial. The main branch contains a simple Node.js app. The branch express contains a project with the Framework express and the node package dotenv for managing environment variables.

git clone https://github.com/tericcabrel/node-ts-starter.git -b express node-upload-files

cd node-upload-files

cp .env.example .env

nano .env

yarn install

yarn start
Create the project with minimal setup

We get in the terminal with this message : Application started on URL http://localhost:4500 🎉

Navigate to the URL in the browser, and we'll get a JSON response:

{
  "message":"Hello World!"
}
JSON Response returned from http://localhost:4500

At the root directory, create a directory public and create another directory uploads. All the files uploaded will leave in this folder. Finally will give the write access to the directory. You can find more on Linux permission at this link.

mkdir -p public/uploads

chmod -R 755 public
Create a directory to hold uploaded files and give write permission

Modeling user schema

We will use MongoDB to store user data. We have already seen how to use it with Node.js in this tutorial.

Configure Mongoose

yarn add mongoose
yarn add -D @types/mongoose

Open the file .env and add variables related to the MongoDB connection property. Your file will now look like this:

HOST=http://localhost
PORT=4500

DB_HOST=localhost
DB_PORT=27017
DB_USER=blogUser
DB_PASS=blogUserPwd
DB_NAME=blog
Configuration file updated with MongoDB connection property.

Replace the database variable with the value that matches your local environment. I show how to secure your Mongo database with a username and a password here.

Inside src folder, create a folder utils then create a file named databaseConnection.ts and add the code below:

import mongoose, { ConnectionOptions } from 'mongoose';

mongoose.Promise = global.Promise;

const { DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_USER } = process.env;

const connectToDatabase = async (): Promise<void> => {
  const options: ConnectionOptions = { useNewUrlParser: true, useFindAndModify: false, useCreateIndex: true, useUnifiedTopology: true };

  await mongoose.connect(`mongodb://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}`, options);
};

export { connectToDatabase };
src/utils/databaseConnection.ts

Update src/index.ts to connect to the database on the application's start.

// Previous code here

app.listen(PORT, async () => {
  await connectToDatabase();

  console.log(`Application started on URL ${HOST}:${PORT} 🎉`);
});

Create the schema

Create a folder model then create a file user.model.ts with the following code:

import mongoose, { Document, Schema, Model } from 'mongoose';

type UserDocument = Document & {
  name: string;
  picture: string;
};

type UserInput = {
  name: UserDocument['name'];
  picture: UserDocument['picture'];
};

const userSchema = new Schema(
  {
    name: {
      type: String,
      required: true,
    },
    picture: {
      type: String,
      required: true,
    },
  },
  {
    timestamps: true,
    collection: 'col_users',
  },
);

const User: Model<UserDocument> = mongoose.model('User', userSchema);

export { User, UserDocument, UserInput };
src/models/user.model.ts: User schema with Mongoose

Upload file to the server

Most backend servers handle requests with the body containing JSON string. Express parse this body and return the object so that we can use it as we want. When a file is sent, the content type is set to multipart/form-data. Express doesn't handle this, so we need an external library to handle that for us. The package Multer is the tool for the situation 😎.

yarn add multer
yarn add -D @types/multer
Install the package multer

Create a function to handle upload

Now create a file named uploadSingle.ts inside utils folder and add the code below:

import * as path from 'path';
import { Request, Response } from 'express';
import multer from 'multer';

const uploadFilePath = path.resolve(__dirname, '../..', 'public/uploads');

const storageFile: multer.StorageEngine = multer.diskStorage({
  destination: uploadFilePath,
  filename(req: Express.Request, file: Express.Multer.File, fn: (error: Error | null, filename: string) => void): void {
    fn(null, `${new Date().getTime().toString()}-${file.fieldname}${path.extname(file.originalname)}`);
  },
});

const uploadFile = multer({
  storage: storageFile,
  limits: { fileSize: 5 * 1024 * 1024 },
  fileFilter(req, file, callback) {
    const extension: boolean = ['.png', '.jpg', '.jpeg'].indexOf(path.extname(file.originalname).toLowerCase()) >= 0;
    const mimeType: boolean = ['image/png', 'image/jpg', 'image/jpeg'].indexOf(file.mimetype) >= 0;

    if (extension && mimeType) {
      return callback(null, true);
    }
    callback(new Error('Invalid file type. Only picture file on type PNG and JPG are allowed!'));
  },
}).single('picture');

const handleSingleUploadFile = async (req: Request, res: Response): Promise<any> => {
  return new Promise((resolve, reject): void => {
    uploadFile(req, res, (error) => {
      if (error) {
        reject(error);
      }

      resolve({ file: req.file, body: req.body });
    });
  });
};

export { handleSingleUploadFile };
Content of file src/utils/uploadSingle.ts

Let explain what this file does:

In line 5, we define the directory where we will store our files. __dirname is a global variable of Node.js that returns the absolute path of the folder that contains uploadSingle.ts

In line 7, we create a storage engine with two options: the path where the file to upload will be stored and the rule for generating the file's name. For this case, The file name results from the concatenation of the current timestamp, the original name, and the extension: 1614122095-myPicture.png.

In line 15, we create a function to handle file upload, and it begins with the definition of the storage engine configured previously; then, we define the file size limit to 5 MB. We finally define a filter to validate the file by checking the extension and the mime-type since someone can rename a .exe file to .png and upload it, so it is important to prevent that. Finally, we chain the function with .single("picture") which indicate the input's name will be picture and will contains only one file.

To pass multiple files to the input, just replace .single() by .array()

In line 28, we create a function handleSingleUploadFile who wrap the function uploadFile inside a Promise and return an object containing information on the file uploaded and the request's body. The request body at this step contains only the input that is not a file (string, number, and boolean).

Create a route to handle upload

Our file upload handler is ready so let's create a route to send the file and the logic for that route. Create a folder routes then add file a file appRoute.ts with the following content:

import { Router } from 'express';

import { handleSingleUploadFile } from '../utils/uploadSingle';
import { User, UserInput } from '../models/user';

export type UploadedFile = {
  fieldname: string; // file
  originalname: string; // myPicture.png
  encoding: string; // 7bit
  mimetype: string; // image/png
  destination: string; // ./public/uploads
  filename: string; // 1571575008566-myPicture.png
  path: string; // public/uploads/1571575008566-myPicture.png
  size: number; // 1255
};

const appRoute = Router();

appRoute.post('register/user', async (req, res) => {
  let uploadResult;

  try {
    uploadResult = await handleSingleUploadFile(req, res);
  } catch (e) {
    return res.status(422).json({ errors: [e.message] });
  }

  const uploadedFile: UploadedFile = uploadResult.file;
  const { body } = uploadResult;

  const newUserInput: UserInput = {
    name: body.name,
    picture: uploadedFile.filename,
  };

  const createdUser = await User.create(newUserInput);

  return res.json({ data: createdUser });
});

export { appRoute };
Content of file src/routes/appRoute.ts

We use Express Router to define a route /register/user which called with HTTP method POST . We start by handle file uploaded by calling our function handleSingleUploadFile wrap inside try { } catch {} to catch validation errors like Invalid file type, file size bigger than 5MB or storage folder that doesn't have write permission, etc. If one of these errors occurs, we return a 422 HTTP status code with the error message thrown.

When the file is uploaded successfully, we retrieve the user's name inside the request's body and the name of the uploaded file inside the storage directory, then create a user in the database and return the result to the user. Own endpoint is ready to receive data.

Open src/index.ts and add the line below before the call of app.listen() to register the route we created with Express routing:

app.use('/', appRoute);

Run the app and open your favorite REST client and test the endpoint. I use Postman to test my REST API.

Test endpoint /register/user on Postman

Access uploaded files

Now we store files on the server as we want. Make sure the file is present inside the folder public/uploads. We need to access the picture through the browser. We will see two ways to achieve that, and you feel free to choose what is suitable for you.

The simple way

It is possible to view the picture on the browser by pointing to the location of the file on the server by formatting the URL like this:

  • The host + port: http://localhost:4500
  • The folder where pictures are stored starting at the public folder: uploads
  • The file's name: 1614124882278-picture.jpg (from my screenshot above).

Which result to: http://localhost:4500/uploads/1614124882278-picture.jpg. Let's open the browser and see what happens.

Unable to view the file from the browser

We are unable to view the file because Express can't resolve the route. The thing is, we don't want Express to treat everything located inside public the folder as an application's route. To prevent that, we need to mark the public directory static So Express will load the file (usually called "asset") correctly.

Open the file src/index.ts and add the code below before app.listen()

app.use(express.static(path.join(__dirname, '../public')));

Restart the app and browse the URL again.

View the picture uploaded in the browser. 🎉

We can view the file now but, there is an issue we need to know about. We are exposing a folder of our application through the Internet. Of course, the directory is not accessible, but just one mistake can lead to a breach of data stored on the server. Also, an attacker can run a script that generates a random picture file's name then browse this URL. We need a better way to protect this.

The secure way

Since the picture is stored in the database as a part of user information, it means with the user's id, we can find the picture file's name.

We will create two routes /user/:id/generate-link and /view-file.

We will use the first route to generate a link to view the file. This link will contain a token that holds enough information to guess which file to browse.

We will need a package to generate the token so let install it:

yarn add jsonwebtoken
yarn add -D @types/jsonwebtoken
Install package useful to generate a token

Update the file src/routes/appRoute.ts by adding the code below:

import * as jwt from 'jsonwebtoken';
import path from 'path';


appRoute.get('/user/:id/generate-link', (req, res) => {
  const { id } = req.params;
  const action = req.query.action as string;

  const payload = { userId: id };

  const token = jwt.sign(payload, 'My#SecretKey007', { expiresIn: 1800 });

  const url = `${req.protocol}://${req.hostname}:${process.env.PORT}/view-file?token=${token}&action=${action}`;

  return res.json({ url });
});

appRoute.get('/view-file', async (req, res) => {
  const { action, token } = req.query as { token: string; action: string };

  try {
    const decoded: any = jwt.verify(token, 'My#SecretKey007');

    const user = await User.findOne({ _id: decoded.userId });

    if (!user) {
      return res.status(404).json({ message: 'User not found!' });
    }

    const filePath = path.resolve(__dirname, '../../public/uploads', user.picture);

    if (action === 'view') {
      return res.sendFile(filePath);
    } else {
      return res.download(filePath);
    }
  } catch (err) {
    return res.status(400).json({ message: err.message });
  }
});
src/routes/appRoute.ts: Generate the link to view or download the picture

The first route takes a user's id as a route parameter, a query parameter named action indicating whether we want to view or download the file. We continue by creating a token with a lifetime of 30 minutes which means after 30 minutes from the generation time, it will be impossible to view the file with this token. It will be required to generate a new one. Finally, we create the URL and return it.

The second route receive two query parameters named token and action then decode the token to get the user's id. Then find the user associated with this id, get the picture file's name, and resolve the server's path.

Finally, if the value of the query parameter action is equal view, Express render the picture in the browser res.sendFile() otherwise Express download the file res.download().

Run the app and open this link http://localhost:4500/user/<userId>/generate-link?action=view  . Replace <userId> by a valid id in your users collections.

This request will respond with an URL, copy it, open another browser tab, paste it and see the result 😎.

Download picture from the browser

Here is the end of this tutorial. You can find the code source at this link.

I hope you enjoyed it and see you at the next one.