Table of Contents
Uploading
Uploading is the core functionality of any image gallery. Although simple in concept, there are many pitfalls that need to be avoided. Fortunately, the Multer middleware provides ways of doing this easily. Many thanks to the creators of Multer!
Filtering
An image gallery should not accept any kind of files. There has to be limits in place, but most importantly, it should only accept images. This can be easily done in Multer.
// routes/upload.js
// Multer
const multer = require('multer')
const imageFileFilter = (req, file, cb) =>{
if(!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) { // If the file uploaded is not of these types
return cb(null, false);
}
cb(null, true)
};
const upload = multer({ storage: storage, fileFilter: imageFileFilter, limits: { fileSize: 8000000 } })
The fileFilter
and limits
properties of the object we pass into Multer will place constraints on the files that can be uploaded. 8000000
is in bytes, which equals to 8 megabytes. Files are also only accepted based on their extensions. However, this filter by extension is not enough as changing a file extension is very easy to do. File extensions can never be relied on for security. More advanced checks will need to be done, and I decided that the controller should do it.
// controllers/uploads_controller.js
const sizeOf = require('image-size');
const fs = require('fs');
exports.upload_post = function (req, res){
let failed = false;
if(!req.file) {
// Multer did not upload because the file filter failed
failed = true;
}
else{
// Try checking for the dimensions of the image, to check whether or not the file is truly an image
try {
sizeOf('tmp/' + req.file.filename);
}
catch(err) {
failed = true;
}
}
if (failed) {
// The upload failed, we now need to delete the file in the tmp folder if it exists
if(req.file){
fs.unlinkSync('tmp/' + req.file.filename);
}
res.render('upload_error', { title: 'Error' });
}
else {
// The upload was successful, we need to move the image to the public/uploads folder
fs.renameSync('tmp/' + req.file.filename, 'public/uploads/' + req.file.filename);
}
}
I find that checking for the dimensions of the image using another Node module called image-size
(Many thanks to their creators!) works well. People who know about file magic bytes might think that checking for them is enough. Unfortunately, forging magic bytes is very easy to do. On the other hand, forging a file that will pass this check is not as straightforward.
One thing that I know by doing this is that it is theoretically possible to upload an image of a type that is not included in the Multer filter (png, jpg, or gif only). It just has to pass the image size check, and named with a file extension that is accepted. Based on my observations, this is not a big problem as the file uploaded will very likely be an image anyway.
Below is a GIF showcasing this functionality.
Storage
Notice how there are actually two directories responsible for uploads. Both the tmp
and the public/uploads
folders will store the uploaded files. All files will first be uploaded to the tmp
folder for security, as that directory is not open for browsing. Once the file is confirmed to be a proper image, it will be moved to the public/uploads
folder, as you may have seen in the code snippets from the previous section. Multer will only ever upload to the tmp
folder, and it is up to the controller to move it to the public/uploads
folder. The destination
property of the object being passed to the Multer disk storage reflects this.
// routes/upload.js
const sjcl = require('sjcl');
// Multer
const multer = require('multer')
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'tmp/');
},
filename: function (req, file, cb) {
const extArray = file.mimetype.split("/");
const extension = extArray[extArray.length - 1];
cb(null, sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(file.originalname + Date.now() + Math.random())) + '.' + extension);
}
})
const upload = multer({ storage: storage, fileFilter: imageFileFilter, limits: { fileSize: 8000000 } })
Notice how the file will not be its original name once uploaded. This is to prevent overwritting, as well as for privacy, which will be further explained in the privacy section. The reason why the SJCL module is used is also explained there.
One important thing to note is that the file extension must be kept. This is so that the MIME type when attempting to view the image in full size will not be an octet stream, which would have prompted the user if they would like to download the image. This is very intrusive.
Entries
As images uploaded will also come with additional data like descriptions, it makes sense to have database records that will tie them all together. I decided to use MongoDB to store records relating to uploads. As I was used to working with ORMs and ODMs, I decided to use Mongoose (Many thanks to their developers!). Mongoose makes handling models very easy.
// models/entry.js
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const EntrySchema = new Schema(
{
image_name: {type: String, required: true},
original_name: {type: String, required: true},
description: {type: String},
public_image: {type: Boolean, default: false},
createdAt: {type: Date, default: Date.now}
}
);
module.exports = mongoose.model('Entry', EntrySchema);
// controllers/uploads_controller.js
const Entry = require('../models/entry');
const fs = require('fs');
const squish = function (string){
return string.replace(/\s+/g,' ').trim();
}
exports.upload_post = function (req, res){
// Other code relating to file checks goes here
// Create and save a new entry
const entry = new Entry({ image_name: req.file.filename, original_name: squish(req.file.originalname), description: squish(req.body.description), public_image: req.body.public_image });
entry.save().then(() => console.log('Entry successfully created'));
res.redirect('../gallery/' + req.file.filename);
}
Now images are tied together with their descriptions, upload date, and public status. This will be helpful for the gallery display. Notice how superflous whitespaces in user inputs, including the file’s original name, will be removed. The function responsible could be moved to a separate module, but since this is the only module that is currently using it, I see no reason to move it yet.
Below is a GIF showcasing this whitespace removal function.