Web Development

How to Create a Public File Sharing Service with Vue.js and Node.js

January 4th, 2019 | By Lamin Sanneh | 17 min read

Learn how to create a Public File Sharing Service with Vue.js and Node.js.

File upload plays an integral part in many web applications. It is used in different programs, such as email clients, chat applications, and commenting systems.

Before JavaScript frameworks dominated web development, file upload systems were similar. These systems usually comprise a form with file input. The backend receives the files, stores them, and redirects the user’s page after the form submission.

With the popularity of JavaScript frameworks today, the situation is different. Nowadays, file uploads can have several features, including AJAX submissions, progress bars, and pause and continue features.

The finished code for this tutorial can be found at these Github repositories:

Application Architecture

We will be building a public file upload and sharing service. It will have a Node.js-powered backend and a Vue.js-powered frontend.

The service will be used anonymously and won’t have any authenticated users. We will submit the file through AJAX and store it in the backend filesystem.

The file meta-data information will be stored in a MongoDB database. All the uploaded files will be listed on the homepage of the application. Upon clicking the name of the file, we will request that the backend download the file. We will also be able to delete files.

We will include a URL-shortener feature in the application to make it easier to share links. This will be achieved by generating a unique hash for each uploaded file. We will also add a mechanism to restrict which file types can be uploaded with the application.

Install and Configure Packages

Before we can install any of the packages, we will need a few things. We must have Node.js installed on our system along with the MongoDB database.

Now that we have a clear understanding of the application requirements, let's start building it.

The application will have a separate backend and frontend. Each will be in a separate folder. Create two folders named client and server in the same directory. Move to the server folder and initialize a new Node.js application with:

npm init


Accept the default values when prompted. Next, move out of the server folder to the level where both folders reside. Initialize a new Vue.js application with:

vue init webpack client


Accept the default values as well. When asked to install the router plugin, select yes. Now that we have scaffolded the front end and back end, let’s install the required packages, starting with the front end. Move into the client folder and install the Axios.js package using the following command:

npm install --save axios


Navigate again to the server folder and, install the packages:

npm install --save btoa body-parser express mongoose multer


Let’s outline the purpose of each of the packages:

  • btoa: This will help us create a unique hash for a file so we can have URL shortener functionality.

  • body-parser: This makes it easy for the backend to access parameters from the front end.

  • express: This is the main backend framework built on top of Node.js

  • Mongoose: This is an ORM library. It helps to insert and manipulate data using a MongoDB database.

  • Multer: This is a library that allows us to receive and store files on the backend.

List Uploaded Files

Now that the packages are installed, we will list the files from the backend. For now, we do not have any uploaded files yet, but we will get to that later. In the server folder, there should be an index.js file. If it is not present, create it. In there, import several libraries by pasting the following:

const bodyParser = require('body-parser');
const express = require('express');
const app = express();

app.listen(3000, () => {
  console.log('Server started on port : ' + 3000);
});


Start the Node.js server using:

node index.js


There should be a message in the console without any errors. The message should read:

Server started on port: 3000 


Next, create a file in models/file.js. In there, paste in the following:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

let FileSchema = new Schema({
  name: { type: String, required: true, max: 100 },
  encodedName: { type: String, required: false, max: 100, default: null }
});

module.exports = mongoose.model('file', FileSchema, 'files');  


Here, we are using Mongoose.js to create the model to represent a single file. This is what we will use to query the database for uploaded files. To use it, we only have to import the exported module from the file.

Next, let's create a service file. This is where our logic for querying the database will be. It will also contain the connection information for the MongoDB database. Still, in the server directory, create a file in services/file.service.js. In there, paste the following:

const mongoose = require('mongoose');
const File = require('../models/file');
const multer = require('multer');
const async = require('async')
const fs = require('fs')
const path = require('path')
const btoa = require('btoa')


In the lines above, we are requiring several libraries. We have also included extra native Node.js libraries: async, fs, and path. The function of async is to perform many asynchronous operations.

When all operations are complete, we have one successful callback. FS is used to create, delete, and manipulate local files. Finally, we use paths to create folder paths safely, depending on the environment.

Let's now connect to the database and write our method to fetch all uploaded file information in the database. Note that we will only store file information in the database.

The physical file itself will be in a folder somewhere on the server. In the same file, paste the following:

const fileConfig = require('../config/file.config')
const mongoDB = fileConfig.dbConnection;
mongoose.connect(mongoDB, { useNewUrlParser: true });
mongoose.Promise = global.Promise;


This connects to our database and requires the configuration information for our file service. The file does not exist yet, so let's create it in config/file.config.js. Paste in the following:

module.exports = {
  supportedMimes: {
    'text/csv': 'csv'
  },
  uploadsFolder: 'uploads',
  dbConnection: 'mongodb://127.0.0.1:27017/fileuploaddb'
}


The supportedMimes configuration will enable us to restrict which file types we will allow for uploads. The keys in the object are the mime types, and the values are the file extensions.

The uploadsFolder configuration is used to specify the directory name for uploaded files. It is relative to the server root.

In the dbConnection configuration, we are specifying the connection string for our database. The Mongoose library will create the database if it does not exist.

Finally, let us create a method for querying the files. Paste the following into our file.service.js file:

module.exports = {
  getAll: (req, res, next) => {
    File.find((err, files) => {
      if (err) {
        return res.status(404).end();
      }
      console.log('File fetched successfully');
      res.send(files);
    });
  }
}


This exports an object with a method called getAll, which fetches a list of files from the database. For now, the method only exists but isn't connected to any routes, so the front end has no way of accessing it yet. Let's build our first route to fetch uploaded files.

Create a route file in routes/api.js. Add in the following:

const express = require('express');
const router = express.Router();
const fileService = require('../services/file.service.js');
const app = express();

router.get('/files', fileService.getAll);
module.exports = router;


Before we start the server again, let’s paste the following into index.js:

app.use(bodyParser.json())
const apiRoutes = require('./routes/api');
app.use('/api', apiRoutes);


Before visiting the route, we need to take one more step. With our current setup, the MongoDB database needs to be running on port 27017. This port is the default port when the server is started without any arguments. To start the server with the default port, run the command:

mongod


To start it with a specific port, use the command:

mongod --port portnumber


If you specify a port number, do not forget to update the port number in the config file, config/file.config.js, in this line

dbConnection: 'mongodb://127.0.0.1:27017/fileuploaddb'


Now, the route is ready to serve files from the backend.

The registered route will live at localhost:3000/api/files. We do not have any files in the backend yet. If we visit the URL in the browser, we will get an empty array response. In the backend console, we should notice a message titled File fetched successfully.

Do not forget to restart the Node.js server.

Build backend API for receiving files

At this stage, the backend application can connect to the database.

Next, we will build the route API for receiving one or more files. First, we will only store the file locally. In the file services/file.service.js, alongside the getAll method, add the following:

uploadFile: (req, res, next) => {

}


This will receive the files but not insert any information into the database yet. We will get to that in the next chapter.

In routes/api.js, add the following line before the first route declaration:

const options = fileService.getFileOptions()
const multer = require('multer')(options);

router.post('/upload', multer.any(), fileService.uploadFile);


Here, we are including the multer library and providing some options for it. During the upload route declaration:

router.post('/upload', multer.any(), fileService.uploadFile);


We are specifying the multer library as middleware. This is so that it will intercept uploaded files and do some filtering for unaccepted files.

The options for the library do not exist yet. Let's add them to a method in the file services/file.service.js. Add in the method below:

getFileOptions: () => {
  return {
    storage: multer.diskStorage({
      destination: fileConfig.uploadsFolder,
      filename: (req, file, cb) => {
        let extension = fileConfig.supportedMimes[file.mimetype]
        let originalname = file.originalname.split('.')[0]
        let fileName = originalname + '-' + (new Date()).getMilliseconds() + '.' + extension
        cb(null, fileName)
      }
    }),
    fileFilter: (req, file, cb) => {
      let extension = fileConfig.supportedMimes[file.mimetype]
      if (!extension) {
        return cb(null, false)
      } else {
        cb(null, true)
      }
    }
  }
}


This method returns some configuration for filename construction. It will set the destination for the uploaded file. Then it filters files so that we only upload the ones specified in the file config/file.config.js.

We cannot test the upload functionality with our current setup because we have not written the front end yet. There is a tool called Postman. It is designed exactly for that.

Store File meta-data in the database

Currently, the API route for receiving files only stores the file locally. Let's modify the application so it also stores the file meta-data in the database. In the file services/file.service.js, modify the uploadFile method as below:

uploadFile: (req, res, next) => {
  let savedModels = []
  async.each(req.files, (file, callback) => {
    let fileModel = new File({
      name: file.filename
    });
    fileModel.save((err) => {
      if (err) {
        return next('Error creating new file', err);
      }
      fileModel.encodedName = btoa(fileModel._id)
      fileModel.save((err) => {
        if (err) {
          return next('Error creating new file', err);
        }
        savedModels.push(fileModel)
        callback()
        console.log('File created successfully');
      })
    });
  }, (err) => {
    if (err) {
      return res.status(400).end();
    }
    return res.send(savedModels)
  })
}


After the multer library stores the files locally, it will pass the file list to the callback above.

The callback will create a unique hash for each file, and then it will store the file's original name and hashed key in the database.

The async part is necessary because the meta-data insertion happens asynchronously for each file. We want to return a response to the front end only when all the information has been saved.

Any file that fails the filter test of the multer middleware will not be passed to the uploadFile callback. If no files have been uploaded, we will return an empty array to the front end. We can then deal with any validation however we wish.

Build Frontend for listing Files

Now let's add functionality to our frontend so that it can list files from the backend. Navigate to the front-end folder. Start the development server using the command below:

npm run dev

The front-end application will be running on the URL localhost:8080.

The first thing we need to do is allow the front end to send AJAX requests to the backend. To allow this during development, in the client root folder, let's modify the file config/index.js. Modify the proxyTable key to:

proxyTable: {
  '/api': 'http://localhost:3000',
    '/file': 'http://localhost:3000'
},


We have covered more details about the above configuration in a previous blog article regarding the Vue.js authentication system with a Node.js backend, so check that if you’re facing any difficulties.

Let us create a component to list files. This will be responsible for fetching files from the backend. It will also loop over the list of returned files and create many instances of a child component called UploadedFile, which we will create later.

To begin with, create a component in src/components/UploadedFilesList.vue.

In there, paste the following:

<template>
  <div>
    <h1>Files List</h1>

    <ul>
      <uploaded-file v-for="file in files" v-bind:file.sync="file"></uploaded-file>
    </ul>
  </div>
</template>

<script>
import axios from "axios";
import UploadedFile from "./UploadedFile";

export default {
  name: "UploadedFilesList",
  data() {
    return {
      files: []
    };
  },
  components: {
    UploadedFile
  },
  methods: {
    fetchFiles() {
      let self = this;
      axios
        .get("/api/files")
        .then(response => {
          this.$set(this, "files", response.data);
        });
    }
  },
  mounted() {
    this.fetchFiles();
  }
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>


To list files, we are sending a request to the backend when the component is mounted. This is done using Axios.js, an HTTP library for Javascript.

The component is initially created with an empty list of files. When the file list data is returned, we add it to the list of files.

Let's create the UploadedFile component.Create a component file in src/components/UploadedFile.vue. In there, paste in the following:

<template>
  <div>
    <div>
      <a>{{ file.name }}</a>
      <button>Delete</button>
    </div>
  </div>
</template>

<script>
import axios from "axios";
export default {
  props: ["file"],
  data() {
    return {};
  },
  name: "UploadedFile",
  methods: {}
};
</script>

<style scoped>
</style>


All that this component is currently doing is displaying the file name. The delete button does not currently perform any action, but we will get to that later.

Next, let's configure the router for our application so we can display the list of files.

In the src/router/index.js file, modify the router as shown below:

import Vue from 'vue'
import Router from 'vue-router'
import Main from '@/components/Main'

Vue.use(Router)
export default new Router({
  routes: [
    {
      path: '/',
      name: 'Main',
      component: Main
    }
  ]
})


The router is referencing the Main component file, which does not exist yet. Let's create it in src/components/Main.vue. Paste in the following:

<template>
  <div>
    <h1>Anonymous File Uploader System</h1>

    <div>
      <uploaded-files ref="filesList" v-bind:files.sync="files"></uploaded-files>
    </div>
  </div>
</template>

<script>
import axios from "axios";
import UploadedFiles from "@/components/UploadedFilesList";

export default {
  name: "Main",
  data() {
    return {
      files: []
    };
  },
  components: {
    UploadedFiles
  },
  methods: {}
};
</script>

<style scoped>
h1,
h2 {
  font-weight: normal;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

Delete the existing file src/components/Hello.vue. It was created during the scaffolding stage, and we will not need it.

Boot up the frontend development server using npm run dev. If there aren't any files in the backend, the list will be empty. If all goes well, we should see the text:

Files List


Build Frontend for Uploading Files

At this stage, we can list files on the server.

Our next task is to add the ability to upload files.
First, create a component for uploading files in src/components/UploadsContainer.vue. In there, paste the following:

<template>
  <div class="hello">
    <h1>Uploader</h1>
    <div>
      <input
        v-show="!uploadStarted"
        type="file"
        multiple
        v-bind:name="uploadName"
        @change="fileSelected"
      >
      <p v-show="uploadStarted">Uploading...</p>
    </div>
    <div>
      <button v-show="!uploadStarted" v-on:click="startUpload">Start Upload</button>      
      <button v-show="uploadStarted" v-on:click="cancelUpload">Cancel Upload</button>
    </div>
  </div>
</template>

<script>
import axios from "axios";
const CancelToken = axios.CancelToken;
const source = CancelToken.source();

export default {
  name: "Main",
  data() {
    return {
      uploadStarted: false,
      uploadName: "files",
      uploadUrl: "/api/upload",
      formData: null
    };
  },

  methods: {
    fileSelected(event) {
      if (event.target.files.length === 0) {
        return;
      }

      let files = event.target.files;
      let name = event.target.name;
      let formData = new FormData();

      for (let index = 0; index < files.length; index++) {
        formData.append(name, files[index], files[index].name);
      }
      this.$set(this, "formData", formData);
    },
    startUpload() {
      this.$set(this, "uploadStarted", true);
      this.uploadData(this.formData);
    },
    cancelUpload() {
      if (this.uploadStarted) {
        source.cancel();
      }
      this.$set(this, "uploadStarted", false);
    },
    uploadData(formData) {
      if (this.formData === null) {
        return;
      }
      axios
        .post(this.uploadUrl, formData, {
          cancelToken: source.token
        })
        .then(response => {
          if (response.data.length === 0) {
            alert("File not uploaded. Please check the file types");
            return;
          }
          this.updateFilesList(response.data);
          this.$set(this, "formData", null);
        })
        .catch(() => {
          alert("Error occured");
        })
        .then(() => {
          this.$set(this, "uploadStarted", false);
        });
    },
    updateFilesList(files) {
      this.$emit("files-uploaded", files);
    }
  }
};
</script>

<style scoped>
</style>


Add it to the dependencies of src/components/Main.vue as shown below. First, let us import it:

import UploadsContainer from '@/components/UploadsContainer'


Then we list it as a child component:

components: {
  UploadedFiles,
    UploadsContainer
},

Add a method as shown below:

methods: {
  filesUploaded(files) {
    this.$refs.filesList.filesUploaded(files)
  }
}

Then, instantiate it in the template as shown below:

<template>
  <div class="hello">
    <h1>Anonymous File Uploader System</h1>
    <div>
      <uploads-container v-on: files-uploaded="filesUploaded"></uploads-container>
    </div>
    <div>
      <uploaded-files ref="filesList" v-bind: files.sync="files"></uploaded-files>
    </div>
  </div>
</template >


In the component src/components/UploadedFilesList.vue, add a method as below:

filesUploaded(files) {
  files.forEach(file => {
    this.files.push(file)
  })
}


Let us break down what is happening in these components.

Inside src/components/UploadsContainer, we have a file upload input. Attached to it is a changed event handler called fileSelected:

@change="fileSelected"


When a file is selected, this handler is fired. The logic in this handler sets the selected files as a property in the component using the following:

let formData = new FormData()

for (let index = 0; index < files.length; index++) {
  formData.append(name, files[index], files[index].name)
}

this.$set(this, 'formData', formData)


This is using HTML5's native FormData API. Then we have a submit button:

<button v-show="!uploadStarted" v-on:click="startUpload">Start Upload</button>


This calls a method named startUpload, which is responsible for setting the status as actively uploading. Then, it calls another method that sends the formData property, containing the files to the backend.

If the upload was successful, we set the formData to null. Then, we emit an event to the parent container so it can update the uploaded files list using:

updateFilesList (files) {
  this.$emit("files-uploaded", files)
}

If an error occurs, we show an alert to the user. We also have a cancel feature which will be triggered by the cancel button below:

<button v-show="uploadStarted" v-on:click="cancelUpload">Cancel Upload</button>


And it will only show when an upload process has started. The “start upload” button will only display when there is no upload in progress.

We are binding the form field to a property that specifies the key that will be used when sending files to the server:

v-bind:name="uploadName"


The input field will also be hidden when an upload is in progress.

Onto the next file src/components/Main.vue. After instantiating UploadsContainer, we listen to an event using the syntax:

v-on:files-uploaded="filesUploaded"

This will receive the uploaded files so we can pass them to a method named filesUploaded in the component src/components/UploadedFilesList.vue. This will make sure the list is updated.

Add support for file download


Frontend download setup

Now that we can upload files, let's make sure we can download them.

First, create a component in src/components/FileDownloader.vue. In there, paste the following:

<template>
  <iframe v-bind:src="source"></iframe>
</template>

<script>
export default {
  data() {
    return {
      source: ""
    };
  },
  methods: {
    downloadFile(source) {
      this.$set(this, "source", source);
    }
  }
};
</script>


This component includes an iframe in the template. Anytime the source for the iframe changes, it will request that URL.

In the component src/components/UploadedFile.vue, include the downloader:

import FileDownloader from './FileDownloader'


Let us register it first:

components: {
  FileDownloader
},


Then we can use it in the template:

<file-downloader :key="downloadKey" ref="downloader"></file-downloader>


Add a method:

downloadFile(event) {
  event.preventDefault()
  let url = event.target.href
  this.downloadKey += 1

  this.$nextTick().then(() => {
    this.$refs.downloader.downloadFile(url)
  })
}


Then, modify the link in the template as shown below:

<a v-bind:href="'/file/download/' + file.encodedName" v-on:click="downloadFile">{{ file.name }}</a>


This generates the appropriate URL by binding to the encodedName property of our file props.

Let's make sure that the download is triggered on every click. We have to bind the download component's key to a data property on the parent component.

Add a data property in src/components/UploadedFile.Vue:

return {
  downloadKey: 1
}


This key is incremented on each click of the download link. This forces the iframe to rerender and hence triggers the download.

Backend download setup

Now, the front-end is ready for making download requests. However, the backend has not been set up to serve the files yet. Let's set it up now. In the backend file index.js, add the following lines before the call to start the server:

const fileRoute = require('./routes/file');
app.use('/file', fileRoute);


Next, create the route file routes/file.js. In there, add the content:

const express = require('express');
const router = express.Router();
const fileService = require('../services/file.service.js');
router.get('/download/:name', fileService.downloadFile);
module.exports = router;


This sets up a route that accepts the hashed key of a file as an argument. This argument is then used to fetch the file from the database to get the real name of the file. Then, we reply with a download response.

Let’s set up the method handler for the route. Inside the file services/file.service.js, add a method to the exports as shown:

downloadFile(req, res, next) {
  File.findOne({ name: req.params.name }, (err, file) => {
    if (err) {
      res.status(400).end();
    }

    if (!file) {
      File.findOne({ encodedName: req.params.name }, (err, file) => {
        if (err) {
          res.status(400).end();
        }
        if (!file) {
          res.status(404).end();
        }

        let fileLocation = path.join(__dirname, '..', 'uploads', file.name)

        res.download(fileLocation, (err) => {
          if (err) {
            res.status(400).end();
          }
        })
      })
    }
  })
}

When we restart the backend server, any file link on the front end can now be clicked to download that file.

Add Frontend support for deleting files

Finally, let's add functionality to delete files. Let's work on the front end first. In the frontend file src/components/UploadedFile.vue, add the method below:

deleteFile (file) {
  this.$emit("delete-file", file);
},


Modify the delete button in the component to the following:

<button v-on:click="deleteFile(file)">Delete</button>


Upon clicking the button, the component emits an event called delete-file to the parent.

Let's modify the parent component src/components/UploadedFilesList.vue. Modify the UploadFile instantiation to the following:

<uploaded-file
v-for="file in files"
v-bind:file.sync="file"
v-on:delete-file="deleteFile"
></uploaded-file>


In there, we add an event listener for the emitted child event we just made. This in turn calls a method named deleteFile in the parent. Let's create that method:

deleteFile(file) {
  if (confirm('Are you sure you want to delete the file?')) {
    axios.delete('/api/files/' + file._id)
      .then(() => {
        let fileIndex = this.files.indexOf(file)
        this.files.splice(fileIndex, 1)
      })
      .catch(() => {
        console.log("Error deleting file")
      })
  }

The frontend is ready to send AJAX requests to the backend.

Let's set up the backend to receive the request. In the backend file routes/api.js, add the following line just before the export statement:

router.delete('/files/:id', fileService.deleteFile);


Then, in the file services/file.service.js, add the method below:

deleteFile(req, res, next) {
  File.findOne({ id: req.params._id }, (err, file) => {
    if (err) {
      res.status(400).end();
    }

    if (!file) {
      res.status(404).end();
    }

    let fileLocation = path.join(__dirname, '..', 'uploads', file.name)

    fs.unlink(fileLocation, () => {
      File.deleteOne(file, (err) => {
        if (err) {
          return next(err)
        }
        return res.send([])
      })
    })
  })
},

Now, we can delete files.

When we click the delete link, we get an alert to confirm. If we click “OK”, the file is deleted from the backend folder, and the information is removed from the database.

Conclusion

That brings us to the end of our article. We created a file upload service that is capable of many file uploads. It enables us to delete the files and we can download the file as well.

This is only a basic upload application. Possible expansions to this application could be advanced validation, upload progress, image preview feature, or multiple file downloads.

Hopefully, this brought you some inspiration and ideas.

Also, if you're building Vue applications with sensitive logic, be sure to protect them against code theft and reverse engineering.

Jscrambler

The leader in client-side Web security. With Jscrambler, JavaScript applications become self-defensive and capable of detecting and blocking client-side attacks like Magecart.

View All Articles

Must read next

Web Security Frameworks

How To Protect Your Vue.js Application With Jscrambler

In this post, we're going to walk through the steps to protect your Vue application with Jscrambler, by integrating it into your build process.

January 14, 2020 | By Jscrambler | 8 min read

Web Development

Vue.js Authentication System with Node.js Backend

In this tutorial, we'll explore different authentication types for JavaScript applications and build a Vue authentication system with a Node.js backend.

October 18, 2018 | By Lamin Sanneh | 17 min read

Section Divider