3rd October 2016

Upload multiple files with progress using JavaScript and .Net MVC

Over the past few years web technologies have progressed significantly. With the introduction of HTM5 and ECMAScript 5/6 (JavaScript) we have seen quite a few interesting new features being introduces such as web workers, web sockets, XMLHttpRequest2, Geolocation and the file api.
Todays post is primarily regarding XMLHttpRequest2 and the file api.

I have spent quite a bit of time trying to find good examples of how to implement asynchronous file uploads in javascript and being able to accept it using C# and MVC.
There are numerous examples explaining how to achieve the client side uploads using the file api and then making a XMLHttpRequest, but there are very few examples on how to properly receive those files on the MVC controller side.

One of the best examples of async file uploads is the Blueimp file upload control: https://github.com/blueimp/jQuery-File-Upload

Even though this is a wonderful control with a lot of features, I needed something that is very light weight, reusable and that can integrate into any .Net C# project without having to create a custom handler, it must use any MVC controller action.
Almost all examples for MVC involves create a custom handler, and to me this just feels wrong.

One thing that I also needed was for the control to be a able to provide me with the form variable that you normally would have with a form post.
The control should also not only be async but actually have a form post as a fallback for older browser.
I also needed the ability to dynamically set the allowed file types whenever I use the control.

With the criteria above in mind I set out to build just that and chose to create a Knockout.js model. Yes, knockout is not dead, and for something like this it’s perfect because it gives you model binding while being able to implement it on a single view without major scaffolding. I also used bootstrap for the styling and drop container as it comes with the standard MVC template and it has a progress bar control.

The full solution can be downloaded from GitHub: https://github.com/TechnoDezi/MVCMultipleFileUpload

Step 1 – Client side code

The first part of this control is the Knockout model, this will handle the drag & drop events, as well as all the upload progress events inside a single model that can be bound to any html control. The model is implemented as a single javascript file in order to keep the size down and to share it is also easier. The single model will also take care of tracking multiple files at once and be able to receive feedback after it’s uploaded.

function FileUploadViewModel(uploadUrl, dropBoxID, defaultFileImg, supportedExtentions, dataHeaderObj) {
    var self = this;

    self.uploadUrl = uploadUrl;
    self.dropBoxID = dropBoxID;
    self.defaultFileImg = defaultFileImg;
    self.supportedExtentions = supportedExtentions;
    self.dataHeaderObj = dataHeaderObj;

    self.showResults = ko.observable(false);
    self.showFileSelect = ko.observable(true);
    self.showSubmit = ko.observable(true);
    self.fileObj = function (fileName, fileSize, uploadPercentage, messages, showMessages) {
        this.fileName = fileName;
        this.fileSize = fileSize;
        this.imgSrc = ko.observable("");
        this.uploadPercentage = ko.observable(uploadPercentage);
        this.messages = ko.observable(messages);
        this.showMessages = ko.observable(showMessages);
    }
    self.fileList = ko.observableArray([]);

    self.init = function () {
        // Check if FileAPI and XmlHttpRequest.upload are supported, so that we can hide the old style input method
        if (window.File && window.FileReader && window.FileList && window.Blob && new XMLHttpRequest().upload) {
            self.showFileSelect(false);
            self.showSubmit(false);

            var dropbox = document.getElementById(self.dropBoxID);
            // init event handlers
            dropbox.addEventListener("dragenter", self.dragEnter, false);
            dropbox.addEventListener("dragexit", self.dragExit, false);
            dropbox.addEventListener("dragleave", self.dragExit, false);
            dropbox.addEventListener("dragover", self.dragOver, false);
            dropbox.addEventListener("drop", self.drop, false);
        }
    }

    self.dragEnter = function (evt) {
        evt.stopPropagation();
        evt.preventDefault();
    }

    self.dragExit = function (evt) {
        evt.stopPropagation();
        evt.preventDefault();

        $("#" + self.dropBoxID).removeClass("active-dropzone");
    }

    self.dragOver = function (evt) {
        evt.stopPropagation();
        evt.preventDefault();

        $("#" + self.dropBoxID).addClass("active-dropzone");
    }

    self.drop = function (evt) {
        evt.stopPropagation();
        evt.preventDefault();

        $("#" + self.dropBoxID).removeClass("active-dropzone");

        var files = evt.dataTransfer.files;
        var count = files.length;
        self.fileList.removeAll();

        // Only call the handler if a file was dropped
        if (count > 0) {
            self.showResults(true);
            self.handleFiles(files);
        }
        else {
            self.showResults(false);
        }
    }

    self.handleFiles = function (files) {

        for (var i = 0; i < files.length; i++) {
            var file = files[i];
            var re = /(?:\.([^.]+))?$/;

            var extention = re.exec(file.name)[1];
            var fileName = file.name;
            if (fileName.length > 100) {
                fileName = fileName.substring(0, 100);
                fileName = fileName + "...";
            }
            var size = file.size / 1024;
            size = Math.round(size * Math.pow(10, 2)) / Math.pow(10, 2);

            var fileModel = new self.fileObj(fileName, size + "Kb", "0%", "", false);
            self.fileList.push(fileModel);

            if ($.inArray(extention, self.supportedExtentions) > -1) {

                self.HandleFilePreview(file, fileModel);
                this.UploadFile(file, fileModel);
            }
            else {
                var message = "File type not valid for file " + file.name + ".";
                fileModel.messages(message);
                fileModel.showMessages(true);
            }
        }
    }

    self.HandleFilePreview = function (file, fileModel) {
        if (file.type.match('^image/')) {
            var reader = new FileReader();
            // init the reader event handlers
            reader.onloadend = function (evt) {
                fileModel.imgSrc(evt.target.result);
            };

            // begin the read operation            
            reader.readAsDataURL(file);
        }
        else {
            fileModel.imgSrc(self.defaultFileImg);
        }
    }

    self.UploadFile = function (file, fileModel) {
        fileModel.uploadPercentage("0%");

        var xhr = new XMLHttpRequest();
        xhr.upload.addEventListener("progress", function (evt) {
            if (evt.lengthComputable) {
                var percentageUploaded = parseInt(100 - (evt.loaded / evt.total * 100));
                fileModel.uploadPercentage(percentageUploaded + "%");
            }
        }, false);

        // File uploaded
        xhr.addEventListener("load", function () {
            fileModel.uploadPercentage("100%");
        }, false);

        // file received/failed
        xhr.onreadystatechange = function (e) {
            if (xhr.readyState == 4) {
                if (xhr.status == 200) {
                    console.log(xhr);
                    fileModel.messages(xhr.responseText);
                    fileModel.showMessages(true);
                }
            }
        };

        xhr.open("POST", self.uploadUrl, true);

        // Set appropriate headers        
        xhr.setRequestHeader("Content-Type", "multipart/form-data");
        xhr.setRequestHeader("X-File-Name", file.name);
        xhr.setRequestHeader("X-File-Size", file.size);
        xhr.setRequestHeader("X-File-Type", file.type);
        if (self.dataHeaderObj != null && self.dataHeaderObj != "")
        {
            xhr.setRequestHeader("X-File-Data", self.dataHeaderObj);
        }

        // Send the file                        
        xhr.send(file);
    }

    //Load View Model
    self.init();
}

view rawtechnodezi_20161003_FileUploadViewModel.js hosted with ❤ by GitHub

function FileUploadViewModel(uploadUrl, dropBoxID, defaultFileImg, supportedExtentions, dataHeaderObj) {
 var self = this;
 
 self.uploadUrl = uploadUrl;
 self.dropBoxID = dropBoxID;
 self.defaultFileImg = defaultFileImg;
 self.supportedExtentions = supportedExtentions;
 self.dataHeaderObj = dataHeaderObj;
 
 self.showResults = ko.observable(false);
 self.showFileSelect = ko.observable(true);
 self.showSubmit = ko.observable(true);
 self.fileObj = function (fileName, fileSize, uploadPercentage, messages, showMessages) {
 this.fileName = fileName;
 this.fileSize = fileSize;
 this.imgSrc = ko.observable("");
 this.uploadPercentage = ko.observable(uploadPercentage);
 this.messages = ko.observable(messages);
 this.showMessages = ko.observable(showMessages);
 }
 self.fileList = ko.observableArray([]);
 
 self.init = function () {
 // Check if FileAPI and XmlHttpRequest.upload are supported, so that we can hide the old style input method
 if (window.File && window.FileReader && window.FileList && window.Blob && new XMLHttpRequest().upload) {
 self.showFileSelect(false);
 self.showSubmit(false);
 
 var dropbox = document.getElementById(self.dropBoxID);
 // init event handlers
 dropbox.addEventListener("dragenter", self.dragEnter, false);
 dropbox.addEventListener("dragexit", self.dragExit, false);
 dropbox.addEventListener("dragleave", self.dragExit, false);
 dropbox.addEventListener("dragover", self.dragOver, false);
 dropbox.addEventListener("drop", self.drop, false);
 }
 }
 
 self.dragEnter = function (evt) {
 evt.stopPropagation();
 evt.preventDefault();
 }
 
 self.dragExit = function (evt) {
 evt.stopPropagation();
 evt.preventDefault();
 
 $("#" + self.dropBoxID).removeClass("active-dropzone");
 }
 
 self.dragOver = function (evt) {
 evt.stopPropagation();
 evt.preventDefault();
 
 $("#" + self.dropBoxID).addClass("active-dropzone");
 }
 
 self.drop = function (evt) {
 evt.stopPropagation();
 evt.preventDefault();
 
 $("#" + self.dropBoxID).removeClass("active-dropzone");
 
 var files = evt.dataTransfer.files;
 var count = files.length;
 self.fileList.removeAll();
 
 // Only call the handler if a file was dropped
 if (count > 0) {
 self.showResults(true);
 self.handleFiles(files);
 }
 else {
 self.showResults(false);
 }
 }
 
 self.handleFiles = function (files) {
 
 for (var i = 0; i < files.length; i++) {
 var file = files[i];
 var re = /(?:\.([^.]+))?$/;
 
 var extention = re.exec(file.name)[1];
 var fileName = file.name;
 if (fileName.length > 100) {
 fileName = fileName.substring(0, 100);
 fileName = fileName + "...";
 }
 var size = file.size / 1024;
 size = Math.round(size * Math.pow(10, 2)) / Math.pow(10, 2);
 
 var fileModel = new self.fileObj(fileName, size + "Kb", "0%", "", false);
 self.fileList.push(fileModel);
 
 if ($.inArray(extention, self.supportedExtentions) > -1) {
 
 self.HandleFilePreview(file, fileModel);
 this.UploadFile(file, fileModel);
 }
 else {
 var message = "File type not valid for file " + file.name + ".";
 fileModel.messages(message);
 fileModel.showMessages(true);
 }
 }
 }
 
 self.HandleFilePreview = function (file, fileModel) {
 if (file.type.match('^image/')) {
 var reader = new FileReader();
 // init the reader event handlers
 reader.onloadend = function (evt) {
 fileModel.imgSrc(evt.target.result);
 };
 
 // begin the read operation 
 reader.readAsDataURL(file);
 }
 else {
 fileModel.imgSrc(self.defaultFileImg);
 }
 }
 
 self.UploadFile = function (file, fileModel) {
 fileModel.uploadPercentage("0%");
 
 var xhr = new XMLHttpRequest();
 xhr.upload.addEventListener("progress", function (evt) {
 if (evt.lengthComputable) {
 var percentageUploaded = parseInt(100 - (evt.loaded / evt.total * 100));
 fileModel.uploadPercentage(percentageUploaded + "%");
 }
 }, false);
 
 // File uploaded
 xhr.addEventListener("load", function () {
 fileModel.uploadPercentage("100%");
 }, false);
 
 // file received/failed
 xhr.onreadystatechange = function (e) {
 if (xhr.readyState == 4) {
 if (xhr.status == 200) {
 console.log(xhr);
 fileModel.messages(xhr.responseText);
 fileModel.showMessages(true);
 }
 }
 };
 
 xhr.open("POST", self.uploadUrl, true);
 
 // Set appropriate headers 
 xhr.setRequestHeader("Content-Type", "multipart/form-data");
 xhr.setRequestHeader("X-File-Name", file.name);
 xhr.setRequestHeader("X-File-Size", file.size);
 xhr.setRequestHeader("X-File-Type", file.type);
 if (self.dataHeaderObj != null && self.dataHeaderObj != "")
 {
 xhr.setRequestHeader("X-File-Data", self.dataHeaderObj);
 }
 
 // Send the file 
 xhr.send(file);
 }
 
 //Load View Model
 self.init();
}

view rawtechnodezi_20161003_FileUploadViewModel.js hosted with ❤ by GitHub

Step 2 – The html and model binding

On the html side the model can be bound to any html allowing you to style the upload exactly as you wish. When binding the Knockout model to the html you can also serialize you MVC model to a json object and send it along with every upload. 


@{
    ViewBag.Title = "Upload File";
}

@section scripts
{
<script src="~/Scripts/knockout-3.4.0.js"></script>
<script src="~/Scripts/FileUploadViewModel.js"></script>
<script type="text/javascript">
    $(function () {
        //var data = @(Html.Raw(Json.Encode(this.Model)));
        //var jsonModel = JSON.stringify(data);
        var jsonModel = null;

        ko.applyBindings(new FileUploadViewModel(
                "@Url.Action("UploadFilePost", "Home")",
                "dropbox",
                "@Url.Content("~/Content/images/file_type_icons_flat_-13.png")",
                ["jpg", "png"],
                jsonModel), document.getElementById("fileUploadContainer"));
    });
</script>
}

<div id="fileUploadContainer">
    @using (Html.BeginForm("UploadFilePost", "Home", FormMethod.Post, new {  @class = "form-horizontal", role = "form", enctype = "multipart/form-data" }))
    {
        <br />
        <div id="dropbox" dropzone="copy f:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet f:application/vnd.ms-excel" class="hero-unit">
            <h2 id="droplabel">Drop zone - Drag & Drop your files here</h2>
            <p id="dnd-notes">Only jpg and png file types are supported. Once you drop your files in the dropzone, the upload will start.</p>

            <input data-bind="visible: showFileSelect" id="fileSelect" type="file" name="fileSelect" />
            <p><button data-bind="visible: showSubmit" type="submit" class="btn btn-primary btn-large">Upload</button></p>
        </div>
        <table class="table table-striped" data-bind="visible: showResults">
            <thead>
                <tr>
                    <th></th>
                    <th>File name</th>
                    <th>File size</th>
                    <th>Upload Progress</th>
                    <th>Message</th>
                </tr>
            </thead>
            <tbody data-bind="foreach: fileList">
                <tr>
                    <td>
                        <img style="max-height: 80px" data-bind="attr: { src: imgSrc }" alt="preview will display here" />
                    </td>
                    <td data-bind="text: fileName"></td>
                    <td data-bind="text: fileSize"></td>
                    <td>
                        <div class="progress progress-info progress-striped">
                            <div class="progress-bar" data-bind="style: { width: uploadPercentage }"></div>
                        </div>
                    </td>
                    <td>
                        <div data-bind="visible: showMessages, html: messages">
                        </div>
                    </td>
                </tr>
            </tbody>
        </table>
    }
</div>

view rawtechnodezi_20161003_UploadFile.html hosted with ❤ by GitHub

 
@{
 ViewBag.Title = "Upload File";
}
 
@section scripts
{
<script src="~/Scripts/knockout-3.4.0.js"></script>
<script src="~/Scripts/FileUploadViewModel.js"></script>
<script type="text/javascript">
 $(function () {
 //var data = @(Html.Raw(Json.Encode(this.Model)));
 //var jsonModel = JSON.stringify(data);
 var jsonModel = null;
 ko.applyBindings(new FileUploadViewModel(
 "@Url.Action("UploadFilePost", "Home")",
 "dropbox",
 "@Url.Content("~/Content/images/file_type_icons_flat_-13.png")",
 ["jpg", "png"],
 jsonModel), document.getElementById("fileUploadContainer"));
 });
</script>
}
 
<div id="fileUploadContainer">
 @using (Html.BeginForm("UploadFilePost", "Home", FormMethod.Post, new { @class = "form-horizontal", role = "form", enctype = "multipart/form-data" }))
 {
 <br />
 <div id="dropbox" dropzone="copy f:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet f:application/vnd.ms-excel" class="hero-unit">
 <h2 id="droplabel">Drop zone - Drag & Drop your files here</h2>
 <p id="dnd-notes">Only jpg and png file types are supported. Once you drop your files in the dropzone, the upload will start.</p>
 
 <input data-bind="visible: showFileSelect" id="fileSelect" type="file" name="fileSelect" />
 <p><button data-bind="visible: showSubmit" type="submit" class="btn btn-primary btn-large">Upload</button></p>
 </div>
 <table class="table table-striped" data-bind="visible: showResults">
 <thead>
 <tr>
 <th></th>
 <th>File name</th>
 <th>File size</th>
 <th>Upload Progress</th>
 <th>Message</th>
 </tr>
 </thead>
 <tbody data-bind="foreach: fileList">
 <tr>
 <td>
 <img style="max-height: 80px" data-bind="attr: { src: imgSrc }" alt="preview will display here" />
 </td>
 <td data-bind="text: fileName"></td>
 <td data-bind="text: fileSize"></td>
 <td>
 <div class="progress progress-info progress-striped">
 <div class="progress-bar" data-bind="style: { width: uploadPercentage }"></div>
 </div>
 </td>
 <td>
 <div data-bind="visible: showMessages, html: messages">
 </div>
 </td>
 </tr>
 </tbody>
 </table>
 }
</div>

view rawtechnodezi_20161003_UploadFile.html hosted with ❤ by GitHub

Step 3 – Server side MVC controller

On the server side a couple of things is to note. Firstly there is an upload helper class that can retrieve the file content from the request for either an async post or a normal form post. This helper will build up a model that contains the file content, the file name, size as well as the json model or form fields serialized to json string that can be easily desterilized back into a C# model class. Once the file is retrieved it can be saved to disc, opened, or pushed to a database. Controller:

[HttpPost]
public async Task<string> UploadFilePost()
{
    FileUploadHelper handler = new FileUploadHelper();

    //Retriev the file from the request
    UploadedFile fileObj = handler.GetFileFromRequest(this.Request);

    //Write file to disc
    System.IO.File.WriteAllBytes(Path.Combine(Server.MapPath("~/App_Data/"), Guid.NewGuid() + Path.GetExtension(fileObj.Filename)), fileObj.Contents);

    return "File uploaded";
}

view rawtechnodezi_20161003_HomeController.cs hosted with ❤ by GitHub

[HttpPost]
public async Task<string> UploadFilePost()
{
 FileUploadHelper handler = new FileUploadHelper();
 
 //Retriev the file from the request
 UploadedFile fileObj = handler.GetFileFromRequest(this.Request);
 
 //Write file to disc
 System.IO.File.WriteAllBytes(Path.Combine(Server.MapPath("~/App_Data/"), Guid.NewGuid() + Path.GetExtension(fileObj.Filename)), fileObj.Contents);
 
 return "File uploaded";
}

view rawtechnodezi_20161003_HomeController.cs hosted with ❤ by GitHub File upload handler:

using MVCMultipleFileUpload.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace MVCMultipleFileUpload.Helpers
{
    public class FileUploadHelper
    {
        public UploadedFile GetFileFromRequest(HttpRequestBase Request)
        {
            string filename = null;
            string fileType = null;
            string fileData = null;
            byte[] fileContents = null;

            if (Request.Files.Count > 0)
            { //we are uploading the old way
                var file = Request.Files[0];
                fileContents = new byte[file.ContentLength];
                file.InputStream.Read(fileContents, 0, file.ContentLength);
                fileType = file.ContentType;
                filename = file.FileName;

                IDictionary<string, object> dict = new Dictionary<string, object>();
                foreach (string key in Request.Form.Keys)
                {
                    dict.Add(key, Request.Form.GetValues(key).FirstOrDefault());
                }
                dynamic dobj = dict.ToExpando();

                fileData = Newtonsoft.Json.JsonConvert.SerializeObject(dobj);
            }
            else if (Request.ContentLength > 0)
            {
                // Using FileAPI the content is in Request.InputStream!!!!
                fileContents = new byte[Request.ContentLength];
                Request.InputStream.Read(fileContents, 0, Request.ContentLength);
                filename = Request.Headers["X-File-Name"];
                fileType = Request.Headers["X-File-Type"];
                fileData = Request.Headers["X-File-Data"];
            }

            return new UploadedFile()
            {
                Filename = filename,
                ContentType = fileType,
                FileSize = fileContents != null ? fileContents.Length : 0,
                Contents = fileContents,
                FileData = fileData
            };
        }
    }
}

view rawtechnodezi_20161003_FileUploadHelper.cs hosted with ❤ by GitHub

using MVCMultipleFileUpload.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
 
namespace MVCMultipleFileUpload.Helpers
{
 public class FileUploadHelper
 {
 public UploadedFile GetFileFromRequest(HttpRequestBase Request)
 {
 string filename = null;
 string fileType = null;
 string fileData = null;
 byte[] fileContents = null;
 
 if (Request.Files.Count > 0)
 { //we are uploading the old way
 var file = Request.Files[0];
 fileContents = new byte[file.ContentLength];
 file.InputStream.Read(fileContents, 0, file.ContentLength);
 fileType = file.ContentType;
 filename = file.FileName;
 
 IDictionary<string, object> dict = new Dictionary<string, object>();
 foreach (string key in Request.Form.Keys)
 {
 dict.Add(key, Request.Form.GetValues(key).FirstOrDefault());
 }
 dynamic dobj = dict.ToExpando();
 
 fileData = Newtonsoft.Json.JsonConvert.SerializeObject(dobj);
 }
 else if (Request.ContentLength > 0)
 {
 // Using FileAPI the content is in Request.InputStream!!!!
 fileContents = new byte[Request.ContentLength];
 Request.InputStream.Read(fileContents, 0, Request.ContentLength);
 filename = Request.Headers["X-File-Name"];
 fileType = Request.Headers["X-File-Type"];
 fileData = Request.Headers["X-File-Data"];
 }
 
 return new UploadedFile()
 {
 Filename = filename,
 ContentType = fileType,
 FileSize = fileContents != null ? fileContents.Length : 0,
 Contents = fileContents,
 FileData = fileData
 };
 }
 }
}

view rawtechnodezi_20161003_FileUploadHelper.cs hosted with ❤ by GitHub 

Model class:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace MVCMultipleFileUpload.Models
{
    public class UploadedFile
    {
        public int FileSize { get; set; }
        public string Filename { get; set; }
        public string ContentType { get; set; }
        public byte[] Contents { get; set; }
        public string FileData { get; set; }
    }
}

view rawtechnodezi_20161003_UploadedFile.cs hosted with ❤ by GitHub

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
 
namespace MVCMultipleFileUpload.Models
{
 public class UploadedFile
 {
 public int FileSize { get; set; }
 public string Filename { get; set; }
 public string ContentType { get; set; }
 public byte[] Contents { get; set; }
 public string FileData { get; set; }
 }
}

view rawtechnodezi_20161003_UploadedFile.cs hosted with ❤ by GitHub

Give credit where credit is due: Many thanks to Valerio Gheri who gave me a running start: https://github.com/vgheri/HTML5Drag-DropExample

Facebook
Twitter
LinkedIn
Pinterest