Uploading Files to Sitefinity Asynchronously Using Kendo UI Upload

by Vesselin Vassilev Jul 28, 2017, 22:04 PM

How To Upload Files to Sitefinity Asynchronously with Kendo UI Upload widget

Kendo UI Upload is a great jQuery widget that is able to upload files asynchronously using the HTML5 File API and it also provides fallback for legacy browsers.

In general, you need to configure save action that will receive the uploaded files. An optional remove action is also available. Let's see how this can be done in Sitefinity.

Goal
Create a front-end widget that will allow a logged in user to upload one or more files asynchronously to a Sitefinity Document Library.

Implementation
I'll use Sitefinity Feather to create an ASP.NET MVC widget.

Controller
The main controller itself will be very simple - it will just register the widget to the Page Toolbox (via the ControllerToolboxItem attribute) and return the View where the client side code will reside. 

using System.Web.Mvc;
using Telerik.Sitefinity.Mvc;
 
namespace SitefinityWebApp.Mvc.Controllers
{
    [ControllerToolboxItem(Name = "FileAsyncUploader", Title = "File Async Uploader", SectionName = "Custom MVC", CssClass = "sfMvcIcn")]
    public class FileAsyncUploaderController: Controller
    {
        [HttpGet]
        public ActionResult Index()
        {
            return View("Index");
        }
 
        [HttpPost]
        public ActionResult Upload()
        {
            // save the file to Sitefinity
                         
            return new EmptyResult();
        }
    }
}
Fig: FileAsyncUploaderController.cs in /Mvc/Controllers folder

It is pretty standard, but you may wonder why the Upload method is not doing anything and why it returns an empty result?
The answer is this: when Kendo UI Upload sends the files to the server it requires an empty response to signify success of the operation. If it does not get an empty response, it will show the files in red, even if you have successfully saved them:


Figure: if the server returns a non-empty response, Kendo UI Upload thinks there was an error.

Ok, but you will say - hey, you do return an EmptyResult from your controller, what is the issue then?
The issue is that if you try to send a request to a controller in Sitefinity directly, the request goes through the Sitefinity Pages Http handler and even if the controller's action return an empty result - Sitefinity thinks you are actually browsing the page that contains this controller, so it will return you the whole html of the page.
Here is the evidence:


Figure: if you post to a controller in Sitefinity asynchronously, even if the controller's action return an empty result, Sitefinity will add the full page html as it thinks you are browsing the page where the controller is.

That's why we have to post the files not to the main controller, but to an ApiController. 
An ApiController can return any response and Sitefinity will not interfere with it at all. 

First, let's register a custom route:

Global.asax.cs
using System;
using System.Linq;
using System.Web.Hosting;
using System.Web.Http;
using System.Web.Optimization;
using System.Web.Routing;
using Telerik.Sitefinity.Services;
 
namespace SitefinityWebApp
{
    public class Global : System.Web.HttpApplication
    {
        protected void Application_Start(object sender, EventArgs e)
        {
            SystemManager.ApplicationStart += SystemManager_ApplicationStart;
        }
 
        private void SystemManager_ApplicationStart(object sender, EventArgs e)
        {
            RegisterRoutes(RouteTable.Routes);
        }
 
        private void RegisterRoutes(RouteCollection routes)
        {
            routes.MapHttpRoute(
                name: "MyCustomApi",
                routeTemplate: "ajax/{controller}/{action}/{id}",
                defaults: new { id = RouteParameter.Optional }
                );
        }
    }
}

And next let's create a new folder called Api under the /MVC folder and put the ApiController there:

/Mvc/Api/FilesController.cs
using System;
using System.Net;
using System.Net.Http;
using System.Web.Http;
using Telerik.Sitefinity.Abstractions;
using System.Linq;
using System.Collections.Generic;
using System.Web;
using System.IO;
using Telerik.Sitefinity.Libraries.Model;
using Telerik.Sitefinity.Modules.Libraries;
using Telerik.Sitefinity.Workflow;
using Telerik.Sitefinity.Data;
using System.Text.RegularExpressions;
using Telerik.Sitefinity.Security.Claims;
using Telerik.Sitefinity.GenericContent.Model;
 
namespace SitefinityWebApp.Mvc.Api
{
    public class FilesController : ApiController
    {
        [HttpPost]
        public HttpResponseMessage Upload()
        {
            var user = ClaimsManager.GetCurrentIdentity();
 
            if (!user.IsAuthenticated)
            {
                return Request.CreateErrorResponse(HttpStatusCode.Forbidden, "You must be authenticated to upload files");
            }
 
            var error = string.Empty;
 
            var files = HttpContext.Current.Request.Files;
 
            if (files != null)
            {
                var manager = LibrariesManager.GetManager();
 
                using (var reg = new ElevatedModeRegion(manager))
                {
                    // create a library with the name of the logged in user
                    var libTitle = user.Name;
 
                    var library = GetOrCreateDocumentsLibrary(libTitle);
 
                    for (int i = 0; i < files.Count; i++)
                    {
                        var file = files[i];
 
                        try
                        {
                            // Some browsers send file names with full path.
                            // This needs to be stripped.
                            var fileName = Path.GetFileName(file.FileName);
                            var ext = Path.GetExtension(file.FileName).ToLower();
 
                            var docId = Guid.NewGuid();
                            var doc = manager.CreateDocument(docId);
 
                            var utcNow = DateTime.UtcNow;
 
                            doc.Parent = library;
                            doc.Title = fileName;
                            doc.DateCreated = utcNow;
                            doc.PublicationDate = utcNow;
                            doc.LastModified = utcNow;
 
                            var urlName = CreateSitefinityUrlFromString(fileName);
                            doc.UrlName = doc.MediaFileUrlName = urlName;
 
                            manager.Upload(doc, file.InputStream, ext);
 
                            manager.RecompileItemUrls(doc);
 
                            manager.SaveChanges();
 
                            var bag = new Dictionary<string, string>();
                            bag.Add("ContentType", typeof(Document).FullName);
                            WorkflowManager.MessageWorkflow(docId, typeof(Document), null, "Publish", false, bag);
                        }
                        catch (Exception ex)
                        {
                            Log.Write(ex.ToString());
                            error = "Failed to upload: " + file.FileName + ex.ToString();
                        }
                    }                   
                }
            }
 
            // Return an empty string to signify success or an error message otherwise
            return Request.CreateResponse(HttpStatusCode.OK, error);
        }
 
        [HttpPost]
        public HttpResponseMessage Remove([FromBody] RemoveFileModel filesToRemove)
        {
            var user = ClaimsManager.GetCurrentIdentity();
 
            if (!user.IsAuthenticated)
            {
                return Request.CreateErrorResponse(HttpStatusCode.Forbidden, "You must be authenticated to upload files");
            }
 
            var manager = LibrariesManager.GetManager();
 
            using (var region = new ElevatedModeRegion(manager))
            {
                var library = manager.GetDocumentLibraries()
                                     .Where(l => l.Title == user.Name)
                                     .Single();
 
                var fileNames = filesToRemove.fileNames;
 
                var docToDelete = library.Documents()
                                         .Where(d => d.Status == ContentLifecycleStatus.Master)
                                         .Where(d => fileNames.Contains(d.Title.ToString()))
                                         .ToList();
 
                docToDelete.ForEach(d => manager.DeleteDocument(d));
                 
                manager.SaveChanges();
            }
 
            return Request.CreateResponse(HttpStatusCode.OK);
        }
 
        public Library GetOrCreateDocumentsLibrary(string title)
        {
            var librariesManager = LibrariesManager.GetManager();
            Library library = null;
 
            using (var region = new ElevatedModeRegion(librariesManager))
            {
                var libs = librariesManager.GetDocumentLibraries().Where(l => l.Title == title);
                if (libs.Count() == 0)
                {
                    library = librariesManager.CreateDocumentLibrary();
                    library.Title = title;
                    library.DateCreated = DateTime.UtcNow;
                    library.LastModified = DateTime.UtcNow;
                    library.UrlName = CreateSitefinityUrlFromString(title);
 
                    librariesManager.RecompileAndValidateUrls(library);
                    librariesManager.SaveChanges();
                }
                else
                {
                    library = libs.First();
                }
            }
            return library;
        }
 
        public string CreateSitefinityUrlFromString(string title, bool isTitleNotUnique = false)
        {
            var url = Regex.Replace(title.ToLower(), @"[^\w\-\!\$\'\(\)\=\@\d_]+", "-");
 
            if (isTitleNotUnique)
            {
                url += Guid.NewGuid().ToString();
            }
 
            return url;
        }
    }
 
    public class RemoveFileModel
    {
        // the name of the files to be deleted
        public string[] fileNames { get; set; }
    }
}

It may look like a lot of code, but most of it is just Sitefinity infrastructure.

Let's break it down a bit: the Upload method is what is being requested by the "save" method of Kendo UI Upload. We check if the user is authenticated and if not return an error. 

Next we loop through the Files collection and create a Sitefinity document for each of them. We also create a library (if it does not exist yet) with the name of the currently logged in user. If all went well, the error string would be empty and would be returned as a result to Kendo Upload which will show the items as successfully uploaded:


Fig.: Kendo Upload shows that the files have been uploaded successfully


View
The Index.cshtml view includes the KendoWeb javascript that comes with Sitefinity and also loads the 2 bootstrap theme Kendo files from Telerik's CDN

@using Telerik.Sitefinity.Frontend.Mvc.Helpers;
@using Telerik.Sitefinity.Modules.Pages;
 
@Html.Script(ScriptRef.KendoWeb, "bottom")
 
@Html.StyleSheet("//kendo.cdn.telerik.com/2017.2.504/styles/kendo.common-bootstrap.min.css", "head")
@Html.StyleSheet("//kendo.cdn.telerik.com/2017.2.504/styles/kendo.bootstrap.min.css", "head")
 
<div class="row">
    <div class="col-md-4">
        <div class="form-group">
            <label>Upload Files</label>
            <input name="files" id="files" type="file" />
        </div>
    </div>
</div>
 
<script>
    $(function() {
 
        var saveUrl = '/ajax/files/upload';
        var removeUrl = '/ajax/files/remove';
 
        $("#files").kendoUpload({
            multiple: true,
            async: {
                saveUrl: saveUrl,
                removeUrl: removeUrl,
                autoUpload: true,
                batch: true
            }
        });
 
    })
</script>

The saveUrl is our Upload action in the ApiController and we also have a removeUrl which goes to the corresponding Remove action. 

autoUpload: true means the files will start uploading as soon as the user selects them.
batch: true means that all files selected at once will be sent in a single request.

That view has a dependency on jQuery, so it is assumed that jQuery is loaded by the head section of the main page Layout template.

And here are the uploaded files:



The gist with all code is here:
https://gist.github.com/VesselinVassilev/bb040081ccd9868853a47a9e5d6152e3