January 26, 2019

Photo Gallery Part 6 - Saving Images to Database

Learn how to use GridFS to save and retrieve images in MongoDB.

Photo Gallery Part 6 - Saving Images to Database

When it comes to saving our pictures to our server, there are two main ways to do this. We learned how to save the pictures to disk in Part 5. This time we will learn how to save our images inside our MongoDB database.

There are pros and cons to save files on disk vs in the database and vice versa. Some prefer to keep all of their application data in one place(database), and others opt to leverage the file system for storing uploaded files. No matter your preference, it is always important to know the options available to you so you can make a informed decision next time your working on a project that uses files.

Although each database technology is different, the process is pretty much the same.

  1. Receive a file from the frontend
  2. Read the contents of the file to a variable (in our case a buffer)
  3. Save that data to the database

The wrong way

Following the process above, we can achieve this in our app pretty easily by reading the upload file and putting it's content into our database.

However, you will notice that everything will work perfectly just as it did when we were saving the images to disk. What you will quickly realize is that after uploading a few images, you can't add any more.

So although our code is working, why is it that we can't add any more photos to our gallery?

MongoDB Collection File size

The answer isn't within our code, but rather how MongoDB functions. Mongo has a file size limit of 16 MB per collection. After you reach that limit, it won't allow you to store anything more.

We could get creative and create different collections for different photo albums, and store the files in chunks across multi collections. But that is a lot of work compared to just saving the files on disk. Thankfully Mongo has a built-in way of doing this.

GridFS

GridFS allows us to store files that exceed this 16 MB file size per document. It achieves this be splitting up the file into chunks, and then stores each chunk into it's own document. GridFS also uses a second collection to store the files metadata, which we will also be using to quickly query all of the photos we have uploaded.

For more details on how GridFS works, please check out the official page here.

The Right Way

So now that we know how not to save images in Mongo, lets work on the preferred method.

Mongoose-gridfs

Although we don't need to, we are going to use a Node module to help us interact with GridFS. One of the most popular modules is gridfs-stream. There is a lot of documentation and tutorials out there on how to use it. While preparing for this post I found a recently new module called mongoose-gridfs that is pretty simple to use and interacts with our mongoose module perfectly.

Open a terminal in our project folder and install mongoose-gridfs

npm i mongoose-gridfs

Backend Server

The first thing that we have to change is the location of our express routes. mongoose-gridfs requires a mongoose connection for it to work.

Open server.js and move all of your routes inside the mongoose on connect listener

const express = require('express');
const bodyParser = require('body-parser');
const mongoose = require('mongoose');
const path = require('path');
const multer = require('multer');
const DIR = './public/uploads';
const upload = multer({ dest: DIR });
// Init app and set static file location
const app = express();
app.use(express.static(path.join(__dirname, 'public')));
global.__basedir = __dirname;

// Middleware //
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json({ type: 'application/json' }));


// Start Server //
app.listen(3000, () => {
    console.log('Server started on port 3000 ...');

    // DB //
    mongoose.connect('mongodb://localhost:27017/ng5-photogallery');

    mongoose.connection.on('connected', () => {
        console.log('Connected to database ...');
        // Routes //
        app.use('/api/test/', (req, res) => {
            res.send('Hello, Friend!');
        });

        app.use('/api/pictures', require('./routes/pictures'));
        app.use('/api/albums', require('./routes/albums'));

        // Non GridFS - Receive picture from disk
        app.use('/public/uploads/:album/:photo', (req, res) => {
            res.sendFile(path.join(__dirname, 'public/uploads', req.params.album, req.params.photo));
        });

        app.get('*', (req, res) => {
            res.sendFile(path.join(__dirname, 'public/index.html'));
        });
    });

    mongoose.connection.on('error', (err) => {
        console.log('Database Error: ', err);
    });
});

Mongo Models

album.js

The next thing we are going to do is cleanup our album schema since we are no longer going to keep picture information in this collection.

const mongoose = require('mongoose');

mongoose.Promise = require('bluebird');

const AlbumSchema = mongoose.Schema({
    name: { type: String, required: true },
    description: { type: String },
}, { usePushEach: true });

// AlbumSchema.toObject();

const Album = module.exports = mongoose.model('album', AlbumSchema);

module.exports.add = (newAlbum, callback) => {
    newAlbum.save(callback);
};

module.exports.edit = (album, callback) => {
    album.save(callback);
};

module.exports.remove = (id, callback) => {
    Album.findByIdAndRemove(id, callback);
};

module.exports.get = (id, callback) => {
    // We use lean so Mongo returns an JSON object instead of a mongo object
    // This is needed because we modify the return object in routes/albums.js
    Album.findById(id).lean().exec(callback);
};

module.exports.getAll = (callback) => {
    Album.find(callback)
};

module.exports.getPicture = (id, callback) => {
    // Non GridFS Way
    Album.findById(id)
        .select('pictures')
        .exec(callback);
};

You'll notice that we remove the picture information from our schema. We also make a small change inside the get function. In our query, we add the lean function.

By default mongoose returns a mongo object. This is normally okay, but we can't modify the mongo object, which we are doing inside our album.js routes file (see below). The lean function will return a JSON object, and therefore allow us to modify the data before we send it to the frontend.

picture.js

Now, create a new file in the model folder called picture.js, which is where we are going to interact with mongoose-gridfs

const mongoose = require('mongoose');
const gridfs = require('mongoose-gridfs');
const fs = require('fs');

// Link gridfs to mongoose
gridfs.mongo = mongoose.mongo;

// Tell gridfs the mongo connection and which collection to save the pictures
const { model: Pictures } = gridfs({
    collection: 'pictures',
    model: 'Picture',
    mongooseConnection: mongoose.connection
});

/**
 * getIds
 * Retrieve all picture ids for a given album
 * @param albumId(mongoId) Which album to search
 * @param callback(function)
 */
module.exports.getIds = (albumId, callback) => {
    Pictures.find({ aliases: albumId })
        .select('_id')
        .exec(callback);
};

/**
 * getById
 * Retrieve a picture
 * @param pictureId(mongoId) Which picture to return
 * @param callback(function)
 */
module.exports.getById = (pictureId, callback) => {
    const pictureStream = Pictures.readById(pictureId);

    pictureStream.on('error', (err) => {
        callback(err, null);
    });
    return pictureStream;
};

/**
 * getContentType
 * Retrive content type for a given picture
 * @param pictureId(mongoId) picture's id to query
 * @param callback(function)
 */
module.exports.getContentType = (pictureId, callback) => {
    Pictures.findById(pictureId, (err, metadata) => {
        if (err) {
            console.log('Metadata Error:', err);
            callback(err, 'image/jpeg');
        }
        callback(null, metadata.contentType);
    });
};
/**
 * deleteById
 * Remove picture from database
 * @param pictureId(mongoId) picture's id to remove
 * @param callback(function)
 */
module.exports.deleteById = (pictureId, callback) => {
    Pictures.unlinkById(pictureId, callback);
};

/**
 * add
 * Add picture to database
 * @param file(obj) picture to add
 * @param albumId(mongoId) Album to add picture to
 */
module.exports.add = (file, albumId, callback) => {
    const readStream = fs.createReadStream(file.path);
    // We are cheating by using the aliases field to store the album id
    const options = ({ filename: file.filename, contentType: file.mimetype, aliases: [albumId] });
    Pictures.write(options, readStream, callback);
};

Since we are using the mongoose-gridfs module, we don't have to specify a schema or interact directly with mongoose for our queries. Mongoose-gridfs does allow us to create custom schema's, but in our case we are okay with the default one. The only thing that we are missing is a way to correlate the image to the album it belongs to. We are cheating a little bit inside our add function, because we save the album details inside the aliases array that we are not using for anything else. If we were to deploy this code in production, I would create a custom schema, which had a albumId field.

Express Routes

Up next is our express routing. We need to modify the albums.js routes and move some of that logic to the pictures.js file.

albums.js

const express = require('express');
const router = express.Router();

const Album = require('../models/album');
const Picture = require('../models/picture');

/**
 * Test Route
 */
router.get('/test', (req, res, next) => {
    res.send('Hello, Friend!');
});

//// GETs ////

/**
 * Get names and descriptions of all photo galleries
 */
router.get('/', (req, res, next) => {
    Album.getAll((err, data) => {
        if (err) {
            return res.json({ error: err });
        }
        res.json({ albums: data });
    });
});

/**
 * Get name, description, and photo IDs of a spesific album
 */
router.get('/:id', (req, res, next) => {
    Album.get(req.params.id, (err, data) => {
        if (err) {
            return res.json({ success: false, error: err });
        }
        Picture.getIds(data._id, (err, pictures) => {
            if (err) {
                return res.json({ success: false, error: err });
            }
            // Add the photo IDs to the returned data from mongo
            // This is why we use lean in our query statement
            data.pictures = [];
            if (pictures) {
                for (let i = 0; i < pictures.length; i++) {
                    data.pictures.push(pictures[i]._id);
                }
            }
            return res.json({ success: true, album: data });
        });
    });
});

//// POSTs ////

/**
 * Create new photo album
 */
router.post('/', (req, res, next) => {
    const newAlbum = new Album({
        name: req.body.name,
        description: req.body.description
    });

    Album.add(newAlbum, (err, data) => {
        if (err) {
            return res.json({ error: err });
        }

        res.json({ album: data });
    });
});


//// PUT ////

/**
 * Update photo album based off album ID
 */
router.put('/:id', (req, res, next) => {
    Album.get(req.params.id, (err, album) => {
        if (err || !album) {
            return res.json({ success: false, error: err });
        }

        if (req.body.name) {
            album.name = req.body.name;
        }
        if (req.body.description) {
            album.description = req.body.description;
        }

        Album.edit(album, (err, data) => {
            if (err) {
                return res.json({ success: false, error: err });
            }
            res.json({ success: true, album: data });
        });
    });
});

//// DELETE ////

/**
 * Delete photo album based off album ID
 */
router.delete('/:id', (req, res, next) => {
    const albumId = req.params.id;

    // First, remove all photos in album
    Picture.getIds(albumId, (err, pictures) => {
        if (err) {
            return res.json({ success: false, error: err });
        }
        for (let i = 0; i < pictures.length; i++) {
            Picture.deleteById(pictures[i]._id, (err) => {
                if (err) {
                    return res.json({ success: false, error: err });
                }
            });
        }

        // Second, remove the album itself.
        Album.remove(req.params.id, (err, data) => {
            if (err) {
                return res.json({ success: false, error: err });
            }
            res.json({ success: true, data: data });
        });
    });
});

module.exports = router;

The first thing that you will notice is that we have removed some unneeded modules at the top. Also, inside our /:id route, we receive the album like normal but we now also do a call to receive the picture ID's associated to the album and add them to the mongo data that is returned from our first call. This is why we needed to add the lean function in our query. Without it, we wouldn't have been able to append the album data with our picture IDs.

pictures.js

const express = require('express');
const router = express.Router();
const fs = require('fs');
const multer = require('multer');
const mongoose = require('mongoose');
const Picture = require('../models/picture');

var filename = '';
const Album = require('../models/album');

// Multer Config //
// Needed when storing on disk
const storage = multer.diskStorage({
    destination: function(req, file, cb) {
        cb(null, 'public/uploads');
    },
    filename: (req, file, cb) => {
        const parts = file.originalname.split('.');
        filename = parts[0] + '-' + Date.now() + '.' + parts[1]
        cb(null, filename);
    }
});
const upload = multer({ storage: storage, preservePath: true });

// TEST //
router.get('/test', (req, res, next) => {
    res.send('Hello, Friend!');
});

//// GETs ////

/**
 * Get photo ID within a spesific album
 * Used for non gridfs logic
 */
router.get('/:album_id/:photo_id', (req, res, next) => {
    Album.getPicture(req.params.album_id, (err, pictures) => {
        if (err) {
            return res.json({ success: false, error: err });
        }
        console.log('Photo ID:', req.params.photo_id);
        // Get all pictures and parse out needed picture
        // Note: MongoDB does not allow to query nested arrays
        pictures.pictures.findIndex((element, index, array) => {
            if (req.params.photo_id.toString() === element._id.toString()) {
                const base64Image = new Buffer(element.data, 'base64');
                res.contentType(element.contentType);
                res.send(base64Image);
            }
        });
    });
});

/**
 * Get picture based off picture ID
 * We return the stream straight to the browser
 */
router.get('/:picture_id', (req, res, next) => {
    const pictureId = mongoose.Types.ObjectId(req.params.picture_id);

    Picture.getContentType(pictureId, (err, contentType) => {
        res.contentType(contentType);

        // Response object is a stream, so we pass the data returned 
        // from gridfs straight back to the browser
        Picture.getById(pictureId).pipe(res);
    });
});

//// POSTs ////
/**
 * Upload single picture and save it to the database not using gridFS
 */
// router.post('/', upload.single('picture'), (req, res, next) => {
//     // Save image to DB
//     Album.get(req.body.album, (err, album) => {
//         if (err) return res.json({ success: false, error: err });
//         const pic = { data: fs.readFileSync(req.file.path), contentType: req.file.mimetype };
//         album.pictures.push(pic);
//         console.log('Album:', album);
//         Album.edit(album, (err, data) => {
//             if (err) return res.json({ success: false, error: err });
//             fs.unlink(req.file.path, (err) => {
//                 if (err) {
//                     console.log('Could not delete file from disk');
//                 }
//                 return res.json({ success: true, data: data });
//             });
//         });
//     });
// });

/**
 * Upload picture and add it to our gridFS model
 */
router.post('/', upload.single('picture'), (req, res, next) => {
    Picture.add(req.file, req.body.album, (err, file) => {
        if (err) {
            console.log('Error:', err);
            return res.json({ success: false, error: err });
        }
        // Remove picture from disk
        fs.unlink(req.file.path, (err) => {
            if (err) {
                console.log('Cleanup Error:', err);
                return res.json({ success: false, error: err });
            }
            return res.json({ success: true });
        });
    });
});

//// DELETE ////

/**
 * Delete picture based of picture ID
 */
router.delete('/:picture_id', (req, res, next) => {
    const pictureId = mongoose.Types.ObjectId(req.params.picture_id);
    Picture.deleteById(pictureId, (err) => {
        if (err) {
            return res.json({ success: false, error: err });
        }
        return res.json({ success: true });
    });
});

module.exports = router;

Our pictures.js file is a pretty standard CRUD API, except that we don't have a put(update) function. I have left the older functions in their for reference, so you can see the differences between using GridFS and storing the picture without GridFS.

Frontend

Since we have changed our server logic, there are some modifications that we need to make to the frontend for it to continue working.

Classes

Our first change will be our album class. Although our album is still going to store picture information, it will only contain the IDs and not the actual picture content.

export class Album {
    constructor(
        public name: string = '',
        public description: string = '',
        public _id: string = '',
        public pictures: string = '',
    ) { }
}

Services

album.service.ts

Here we are adding the delete logic and cleaning up the some of the functions. Other than that, we are not making any large changes to this file.

import { Injectable } from '@angular/core';
import { Album } from '../classes/album';
import { Observable } from 'rxjs/Observable';
import { Subscriber } from 'rxjs/Subscriber';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import 'rxjs/add/observable/of';
import 'rxjs/add/observable/from';

@Injectable()
export class AlbumService {

  albums: Array<any> = [];

  constructor(private http: HttpClient) { }

  /**
   * getAlbum
   * Retrieve photo album information from server
   * @param id(string): photo album ID
   */
  getAlbum(id): Observable<any> {
    let headers = new HttpHeaders();
    headers = headers.set('Content-type', 'appliction/json');
    return this.http.get('/api/albums/' + id, { headers: headers });
  }

  /**
   * getAlbums
   * Retrieve all albums from the server
   */
  getAlbums(): Observable<any> {
    let headers = new HttpHeaders();
    headers = headers.set('Content-type', 'appliction/json');
    return this.http.get('/api/albums', { headers: headers });
  }

  /**
   * Create new photo album based off information in album object
   * @param album JSON object containing name and description of new album
   */
  addAlbum(album: Album): Observable<any> {
    let headers = new HttpHeaders();
    headers = headers.set('Content-Type', 'application/json');
    return this.http.post('/api/albums', album, { headers: headers });
  }

  /**
   * Delete spesified photo album
   * @param albumId Photo album ID to remove
   */
  delete(albumId: string): Observable<any> {
    let headers = new HttpHeaders;
    headers = headers.set('Content-Type', 'appliction/json');
    return this.http.delete('/api/albums/' + albumId, { headers: headers });
  }
}

picture.service.ts

import { Observable } from 'rxjs/Observable';
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';

@Injectable()
export class PhotoService {

  constructor(private http: HttpClient) { }

  // Non GridFS Way
  // getPhoto(album_id, photo_id): Observable<Blob> {
  //   // Need to tell HttpClient that response type is blob, not text(which is the default)
  //   return this.http.get('/api/pictures/' + album_id + '/' + photo_id, { responseType: 'blob' });
  // }
  // GridFS Way
  /**
   * Get photo from server
   * @param photo_id ID of photo to retrieve
   */
  getPhoto(photo_id): Observable<Blob> {
    return this.http.get('/api/pictures/' + photo_id, { responseType: 'blob' });
  }

  /**
   * Uploads the photos to the backend server
   * @param id photo album to add the picture to
   * @param photos picture to upload to server
   */
  uploadPhotos(id, photos) {
    let headers = new HttpHeaders();
    headers = headers.set('Content-type', 'appliction/json');
    return this.http.post('/api/albums/' + id + '/pictures', photos, { headers: headers });
  }


  /**
   * Delete photo
   * @param pictureId picture ID to delete
   */
  deletePhoto(pictureId) {
    let headers = new HttpHeaders();
    headers = headers.set('Content-type', 'application/json');
    return this.http.delete('/api/pictures/' + pictureId, { headers: headers });
  }
}

Our pictures service logic is pretty straight forward. I have left the non-gridfs way of receiving files for your reference.

Components

We can now modify our components to match our new service logic.

gallery.component

HTML Layout

We are adding 2 buttons to our layout, which will allow us to delete the entire album, as well as individual photos from the album.

<div class="container">

    <!-- Album Info -->
    <div class="header">
        <h1>{{ album.name }}</h1>
        <p *ngIf="album.description">{{ album.description }}</p>
        <button mat-raised-button color="warn" (click)="deleteAlbum()">
          <mat-icon>delete</mat-icon>
          Remove Album
        </button>
    </div>

    <div class="wrapper">

        <!-- Image Upload -->
        <div class="upload-container">
            <mat-card class="upload-card">
                <mat-card-title>Picture Upload</mat-card-title>
                <mat-card-content>
                    <input type="file" ng2FileSelect [uploader]="uploader" multiple />
                </mat-card-content>
            </mat-card>
            <mat-card class="queue-card">
                <mat-card-title>Upload Queue</mat-card-title>
                <mat-card-subtitle>
                    Queue length: {{ uploader?.queue?.length }}
                </mat-card-subtitle>
                <mat-card-actions>
                    <button mat-raised-button color="primary" (click)="uploader.uploadAll()" [disabled]="!uploader.getNotUploadedItems().length">
                        <mat-icon>add</mat-icon>
                        Upload
                    </button>
                </mat-card-actions>
                <mat-card-content>
                    <table class="table" style="width: 100%;">
                        <thead>
                            <tr>
                                <th>Name</th>
                                <th>Status</th>
                                <th>Actions</th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr *ngFor="let item of uploader.queue">
                                <td>{{ item?.file?.name }}</td>
                                <td class="text-center">
                                    <span *ngIf="item.isSuccess">Success</span>
                                    <span *ngIf="item.isCancel">Cancelled</span>
                                    <span *ngIf="item.isError">Errors</span>
                                </td>
                                <td nowrap>
                                    <button mat-button type="button" (click)="item.remove()">
                                        Remove
                                    </button>
                                </td>
                            </tr>
                        </tbody>
                    </table>
                </mat-card-content>
            </mat-card>
        </div>

        <!-- Images -->
        <div class="content">
            <mat-card class="card" *ngFor="let picture of pictures">
                <a [href]="picture.url" target="_blank">
                    <img class="card-img" [src]="picture.picture" alt="image">
                </a>
                <button mat-raised-button (click)="deletePicture(picture.id)" color="warn">Remove</button>
            </mat-card>
        </div>
    </div>
</div>

Typescript

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FileUploader } from 'ng2-file-upload';
import 'rxjs/add/operator/map';

import { Album } from '../../classes/album';
import { AlbumService } from '../../services/album.service';
import { PhotoService } from '../../services/photo.service';
import { NavbarService } from '../../services/navbar.service';

@Component({
  selector: 'app-gallery',
  templateUrl: './gallery.component.html',
  styleUrls: ['./gallery.component.css'],
})
export class GalleryComponent implements OnInit {

  album: Album = new Album();
  imageToShow: any;
  pictures: Array<any> = [];
  public uploader: FileUploader = new FileUploader({ url: '/api/pictures', itemAlias: 'picture' });
  displayedHeaders = ['name', 'status', 'actions'];

  constructor(private _albumService: AlbumService,
    private _photoService: PhotoService,
    private _navService: NavbarService,
    private route: ActivatedRoute,
    private router: Router) { }

  ngOnInit() {
    this.route.params.subscribe((params) => {
      // Retrieve album details based of ID in url parameters
      this._albumService.getAlbum(params['id'])
        .subscribe((res) => {
          // TODO: Deal will errors
          if (!res.error) {
            this.album = res.album;
            if (this.album.pictures) {
              // Request each photo in album
              for (let i = 0; i < this.album.pictures.length; i++) {
                this._photoService.getPhoto(this.album.pictures[i])
                  .subscribe((picture) => {
                    // Take base64 image and read it as a file
                    const reader = new FileReader();
                    reader.addEventListener('load', () => {
                      // Save the image url and the image content for our view.
                      // If the user clicks on the image, we will use the url to open the picture in fullscreen
                      this.pictures.push({
                        id: this.album.pictures[i],
                        url: '/api/pictures/' + this.album.pictures[i],
                        picture: reader.result
                      });
                    }, false);

                    if (picture) {
                      reader.readAsDataURL(picture);
                    }
                  });
              }
            }
          }
        });
    });

    this.uploader.onAfterAddingFile = (file) => {
      file.withCredentials = false;
    };

    // Add album ID to post request parameters for each photo
    this.uploader.onBuildItemForm = (fileItem: any, form: any) => {
      form.append('album', this.album._id);
      return { fileItem, form };
    };

    this.uploader.onCompleteItem = (item: any, response: any, status: any, headers: any) => {
      const res = JSON.parse(response);
      if (res.success) {
        this.uploader.removeFromQueue(item);
        if (this.uploader.queue.length === 0) {
          // Reload page when all images have been uploaded
          location.reload();
        }
      }
    };
  }

  deletePicture(id) {
    this._photoService.deletePhoto(id)
      .subscribe((data) => {
        this.pictures.splice(this.findPictureIndex(id), 1);
      });
  }

  findPictureIndex(id): number {
    for (let i = 0; i < this.pictures.length; i++) {
      if (this.pictures[i].id === id) {
        return i;
      }
    }
    return -1;
  }

  deleteAlbum() {
    this._albumService.delete(this.album._id)
      .subscribe((res) => {
        if (res.error) {
          console.log('Delete Error:', res.error);
        }
        this.router.navigate(['/']);
      });
  }
}

You'll notice that we are making a few code changes and adding some additional comments to help explain what we doing. The biggest changes are at the bottom of the file where we deal with the logic of deleting albums and photos.

Testing

Our testing is going to be the same as last time. But first we should clear our database since we have modified the schema's. Open a terminal window and execute the following

mongo
use ng5-photogallery
db.dropDatabase()
exit

Next, open two terminal windows to start our server and frontend code

Server

npm run dev

Angular Frontend

npm run ng-start

Now that our database is clean and our app is started, lets create a new photo album.

create-album

Once that is finished, let's upload some pictures and see where they get stored

album-with-pictures

Previously our pictures where stored inside the public/uploads folder, but you will see that this folder is now empty. If we check our database, you'll notice that we have our album collection like we did previously, but we now also have a pictures.files and pictures.chunks collections as well. A simple query shows of the pictures.files collection shows us the pictures that we just uploaded.

upload-folder

mongo-query

When you delete a picture, you will notice that our database gets updated. You will also notice that if you delete the entire album, the application will first delete all of the pictures and then delete the album itself.

Summary

This brings us to the end of our Photo Album project. In this series you have seen how to create a MEAN(Mongo, Express, Angular, Node) application from scratch and learned how to upload files to a Node server, as well as save them to a Mongo database.

Before we can consider this production ready, there are a few things that I left out. This series was meant to be informational, so if you indent to use this in production please take note that we are not doing much error handling, we have not enabled server or frontend logging, and our mongo database is not protected with a username/password. You will also notice some TODO comments of items I would change prior to deploying this.

I hope you enjoyed this series and was able to pick up something from it. I look forward to reading your comments below.

As always, the entire code can be found on GitHub, here.