Sunday, August 30, 2015

Building a Profile Photo upload with Angular, Node/Express, Cloudinary, and Async.js

Recently I have been spending a lot of time exploring the awesome things about Node.js. The community and user contributions have been tremendous, so tremendous that I rarely find myself having trouble solving a particular problem (there is an existing NPM module out there for everything it seems). However, when approached with writing a user profile/photo upload, there was a lot of different options to weigh. Should I provide the user with some ability to crop the image like many sites do these days? Do I automate image manipulation on my server using node-imagemagick when I want to edit the uploaded file?  What kinds of options work on mobile browsers/touch devices? How do I make it support drag and drop on desktop browsers with Angular.js?

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. 

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 photo
Save 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)
Pretty straightforward, but things to note are use of the "ngf-change" directive from the ng-file-upload library, and the "img-crop" directive from the ngImgCrop library. These directives allow you to read the file (giving an accept parameter to determine what type of photo you'd like to accept), and allow the user to crop the image. When the user has finished cropping the image, the "result-image" parameter allows you to specify the scope variable for your resulting image (note the size is used to specify a target size). Here is the relevant scope variables in my Angular controller for handling this interaction:

    /**
     * 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 :)

17 comments:

  1. Hi Patrick, thanks for that great read. I am currently doing something like this and was not aware of cloudinary. I'll give it a try. any chance of a github of all files edited on this?

    ReplyDelete
  2. Hi Jim, this project is currently a closed source project and is hosted on Bitbucket, however I can share code samples upon request. Is there anything specifically you are looking for? You can email me at patrick@patrickmriley.net and I can provide samples.

    ReplyDelete
  3. Thanks a million Patrick, I have sent you an email there. I have ngImgCrop implemented on my site but i just need to get the cropped image to save? I have link to my site in the email.

    ReplyDelete
  4. Thank You for guiding me. now I can upload the image!

    ReplyDelete
  5. Do you realize there's a 12 word phrase you can tell your partner... that will trigger deep feelings of love and impulsive attraction for you buried within his chest?

    That's because deep inside these 12 words is a "secret signal" that triggers a man's instinct to love, look after and care for you with his entire heart...

    12 Words Will Fuel A Man's Love Instinct

    This instinct is so hardwired into a man's brain that it will drive him to try harder than ever before to to be the best lover he can be.

    In fact, triggering this influential instinct is absolutely essential to getting the best ever relationship with your man that the instance you send your man one of the "Secret Signals"...

    ...You'll immediately find him open his soul and mind for you in such a way he's never expressed before and he'll see you as the one and only woman in the galaxy who has ever truly tempted him.

    ReplyDelete
  6. Through this post, I realize that your great information in playing with all the pieces was exceptionally useful. I advise this is the primary spot where I discover issues I've been scanning for. You have a smart yet alluring method of composing. PMP

    ReplyDelete
  7. Especially superb!!! Exactly when I search for this I found this webpage at the top of every single online diary in web crawler.
    hrdf claimable training

    ReplyDelete
  8. It is an obligation of gratitude to share information, continue the great work ... I sincerely enjoy researching your website. great asset ...
    360DigiTMG Data Analytics Course

    ReplyDelete
  9. incredible article!! sharing these kind of articles is the decent one and I trust you will share an article on information science.By giving an organization like 360DigiTMG.it is one the best foundation for doing guaranteed courses
    hrdf claimable training

    ReplyDelete
  10. I was looking at a portion of your posts on this site and I consider this site is really enlightening! Keep setting up..
    big data course in malaysia

    ReplyDelete
  11. wonderful bLog! its intriguing. thankful to you for sharing.
    https://360digitmg.com/course/project-management-professional-pmp

    ReplyDelete

  12. I like this post,And I figure that they making some incredible memories to scrutinize this post,they may take a good site to make an information,thanks for sharing it to me

    hrdf claimable training

    ReplyDelete
  13. On the off chance that you are being mindful to gain proficiency with a few methodologies, at that point you should peruse this article, I am sure you'll get a lot of extra from this article.
    pmp certification in malaysia

    ReplyDelete
  14. It's acceptable to check this sort of site. I figure I would such a great amount from you.
    what is hrdf

    ReplyDelete
  15. The article posted was very informative and useful. You people are doing a great job. Keep going.
    data scientist course

    ReplyDelete
  16. Apple Watch 6 Titanium - A unique twist on Apple Watch Watch
    Apple Watch features all the most titanium frames notable features to enjoy: crisp 4x8 sheet metal prices near me look, smooth design, titanium camping cookware and titanium block intuitive controls. The watch has been manufactured titanium tools in the

    ReplyDelete