And afterwards, should I save this photo to my database server using GridFS (or gridfs-stream), or some place in the cloud? Do I read a photo file submitted from a form using Express's multer library (express extension for multipart forms), or should I submit the photo as a base64 encoded string? All questions I mulled over extensively when considering this functionality.
After analyzing several options, I have arrived at a very clever solution which I would like to share with the community (as always, it may not fit your use case exactly, but it should give you some things to think about). I can say with confidence now, my solution will do the following:
- Provide a means to drag and drop photos on modern desktop browsers or select them on mobile devices, using ng-file-upload (lightweight and provides a FileReader shim if you want to support older browsers)
- Give the user the ability to crop the photo once it is uploaded. For this I used ngImgCrop, for its ease of use and touch support. It also outputs a base64 encoded image that is cropped for you (meaning you won't have to worry about this on the server).
- Provide an express server route for handling the image and convert it from a base64 string back to an image.
- Store the image using Cloudinary, an inexpensive cloud image host that provides image manipulation and fast delivery.
- Write the code in a generic way that makes it easily reusable and modular, following best Node.js patterns
This is quite a powerful combination when you think about it! After reviewing several blogs about how Grid FS performance and doing image manipulation yourself can bog down the server if not handled extremely carefully, and how storing base64 strings heavily bloats your database, this made the cloud solution an easy choice for me (although if you absolutely must store the image in-house there are two good references here and here).
You can see the result of my work in action when you select your profile photo at urbanchampsports.com.
Now for the code.
You can see the result of my work in action when you select your profile photo at urbanchampsports.com.
Now for the code.
First the view template... (note I have chosen to use Angular material flex box containers for making it responsive, but any responsive framework should work fine).
Drop image or click to upload profile photoSave Photo Change Photo
This defines the following elements for our view:
- a drop box container for drag and drop of photos
- the user's current photo (if one has already been saved)
- a progress indicator (to display during file upload)
- an image crop container (for use while the user is cropping the photo)
- and finally, buttons for saving the photo and resetting it (once a photo exists)
/** * Profile Photo Upload scope variables */ $scope.loading = false; $scope.uploadPhoto = null; $scope.croppedPhoto = null; $scope.readFileImg = function(files){ $scope.uploadPhoto = null; $scope.croppedPhoto = null; $scope.user.photo = null; if (files && files.length) { var readImgCallback = function(err, img){ $scope.loading = false; if(err) return Toaster.toastErrorMessage($scope, err); $scope.$apply(function(){ $scope.uploadPhoto = img; }); }; $scope.loading = true; Formulator.readImageFile(files[0], readImgCallback); } }; $scope.upload = function () { if ($scope.croppedPhoto) { $scope.loading = true; var uploadCallback = function(currentUser){ currentUser.$promise.then(function(user){ $scope.user = user; Toaster.toastSuccess('Photo saved.'); }); }; Auth.updateProfilePhoto($scope.croppedPhoto, uploadCallback) .catch( function(err) { Toaster.toastErrorMessage($scope, 'Error saving photo.'); }) .finally(function(){ $scope.loading = false; }); } else { $scope.loading = false; } };To make this solution more generic (so it can be used across my site), I have defined factory utilities for reading the image file (using an HTML5 FileReader), and uploading it to the server (using my Auth factory). Note that the factory below checks the file size does not exceed 4 MB, and the file type is an image. Upon upload completion, we load this image into our ngImgCrop directive (at this point it is a "File" type). Once the user has finished cropping, we upload the output base64 encoded image string to the server (shown below). Note: you can disregard the "Toaster" calls, these are utility calls for notifying the user via Angular Material toasts...
angular.module('urbanChampSportsApp') .factory('Formulator', function Formulator() { return { readImageFile: function(file, cb){ if(window.FileReader){ if(file.size > 4000000){ return cb('Error, photo exceeds max size limit.'); } if(!file.type.match('image.*')){ return cb('Error, file must be a photo.'); } var reader = new FileReader(); reader.onloadend = function (event) { if(event.target.error != null){ return cb('Error, please try another photo.'); } else { return cb(null,reader.result); } }; reader.readAsDataURL(file); } else { return cb("Sorry, this browser doesn't support photo uploads."); } } }; });And the upload to the server using $resource...
angular.module('urbanChampSportsApp') .factory('Auth', function Auth($location, $rootScope, $http, User, $cookieStore, $q) { var currentUser = {}; if($cookieStore.get('token')) { currentUser = User.get(); } /** * Update large profile photo */ updateProfilePhoto: function(photo, callback){ var cb = callback || angular.noop; return User.updateProfilePhoto({id : currentUser._id}, { photo: photo }, function(user) { currentUser = User.get(); return cb(currentUser); }, function(err) { currentUser = User.get(); return cb(err); }).$promise; } }; }); angular.module('urbanChampSportsApp') .factory('User', function ($resource) { return $resource('/api/users/:id/:controller', { id: '@_id' }, { get: { method: 'GET', params: { id:'me' } }, updateProfilePhoto: { method: 'PUT', params: { controller: 'profilePhoto' } } }); });
NOTE: There is one gotcha with the ngImgCrop implementation. It currently does not support some portrait (vertical) images uploaded on the iPhone. To fix this, you can apply the following patch to its source:
https://github.com/iblank/ngImgCrop/pull/1
Now for the fun part, handling the image on the Node.js server. The server now takes the following steps:
- Decodes the base64 image string to a file buffer (stripping any 'data:image' tags)
- Writes the buffer to a temporary file (so that it can be uploaded to the cloud)
- Uploads the image to Cloudinary (cloud image host)
- Removes the temporary file and stores the Cloudinary response url as the user's profile image
- Returns the response url to the client
First the Express route definition...
'use strict'; var express = require('express'); var controller = require('./user.controller'); var config = require('../../config/environment'); var auth = require('../../auth/auth.service'); var router = express.Router(); router.delete('/:id', auth.hasRole('admin'), controller.destroy); router.get('/me', auth.isAuthenticated(), controller.me); router.put('/:id/profilePhoto', auth.isAuthenticated(), controller.updateProfilePhoto); router.post('/', controller.create); module.exports = router;
Next we implement the route handler (controller method) for uploading the photo:
var User = require('./user.model'); var passport = require('passport'); var config = require('../../config/environment'); var imageHelper = require('../../components/helper/imageHelper'); /** * Updates user profile photo */ exports.updateProfilePhoto = function(req, res) { var userId = req.user._id; User.findById(userId, function (err, user) { if(user && req.body.photo) { imageHelper.uploadBase64Image('./.tmp/' +userId + '_profile.jpg', req.body.photo, function(err, result){ if(err) res.send(400, err); else{ user.photo = String(result.url); user.save(function(err) { if(err) return validationError(res, err); res.send(200); }); } }); } else { res.send(400); } }); };
To make our solution more reusable, we are going to define our image decoding and cloud upload modules separately. This keeps our controller clean and allows for code reuse. When the upload is finished, we simply set the url string on our mongoose model for the user, and return it to the client.
The implementation for our "imageHelper" is defined below. It makes use of the awesome Async.js library for handling the asynchronous events we need to process. I highly recommend this utility to every Node.js developer.
//imageHelper.js 'use strict'; var async = require('async'); var fs = require('fs'); var cloudinaryHelper = require('./cloudinaryHelper'); //Decodes a base64 encoded image and uploads it to Cloudinary module.exports.uploadBase64Image = function(tmpPath, base64Image, cb){ var imageBuffer = decodeBase64Image(base64Image); async.waterfall([ //write image to tmp disk function writeImage(callback) { fs.writeFile(tmpPath, imageBuffer.data, function(err) { callback(err,tmpPath); }); }, //upload to cloudinary function upload(tmpPath, callback){ cloudinaryHelper.upload(tmpPath, callback); }, function removeFile(result, callback){ fs.unlink(tmpPath, function(err) { callback(err, result); }); } ], function(err, result){ if(err) console.error(err); cb(err,result); } ); }; function decodeBase64Image (dataString){ var matches = dataString.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/), response = {}; if (matches.length !== 3) { return new Error('Invalid input string'); } response.type = matches[1]; response.data = new Buffer(matches[2], 'base64'); return response; }
For further isolation, we define our Cloudinary upload method separately in a "cloudinaryHelper" module. Note our Cloudinary config constants/app secrets are defined in the global config and not in the module.
//cloudinaryHelper.js var cloudinary = require('cloudinary'); var config = require('../../config/environment'); cloudinary.config({ cloud_name: config.cloudinary.cloud_name, api_key: config.cloudinary.apikey, api_secret: config.cloudinary.apisecret }); module.exports.upload = function(imgPath, callback){ cloudinary.uploader.upload(imgPath, function(result) { console.log('Cloudinary photo uploaded result:'); console.log(result); if(result){ callback(null, result); } else { callback('Error uploading to cloudinary'); } }); };
And wah lah! We now have a fully functioning profile photo upload feature! The image is stored in the cloud (as to lighten the load on our app server), and we simply store its hosting URL in the database.
We can also now manipulate its size on the fly! This is a win win.
For example, the following photo upload that is 250px X 250px:
http://res.cloudinary.com/urbanchampsports-com/image/upload/v1440950445/opmsrfg69tip8ryswjdn.png
Can easily be changed to the following (to modify its width and height to 100px X 100px) upon request:
http://res.cloudinary.com/urbanchampsports-com/image/upload/w_100,h_100/v1440950445/opmsrfg69tip8ryswjdn.png
Boom! This makes our life much easier on the client, as simply modifying a URL string gives us the image size we'll need for different breakpoints and use cases.
I hope you enjoyed this solution as much as I did. Happy Profiling :)