June 15, 2020

Delivering Static HTML5 Apps from Zip Files in Episerver's Media Library using Partial Routing

Yeah, so the title seems like a mouthful but it's really quite simple. A prospective customer of mine wanted to know if we could provide a better way of uploading some in-house developed HTML5 apps (large, searchable libraries of content made by another team, but I won't go too deep into why they're not just putting that content directly into the CMS).

Their current solution involves a drawn-out process of replicating folders in SharePoint, uploading each folder's files, go back, rinse, & repeat. Unfortunately, these apps are also updated every few weeks which means that the whole process must be repeated for every app, every month, every year. Sounds like a lot of work to me, so I wrote up a quick proof-of-concept solution that I think tidies up the process nicely.

For this project, I'm going to use Foundation as the base site and add in a couple of models and a partial router to get these things working. Let's get started!
In order for this to work, we're going to need a few things:

  • An HTML5 app to test. I'll use a simple example for this, but it will include HTML, CSS, some JavaScript, and some images.
  • A model for uploading Zip files. This means we need a Media type in Episerver that includes the zip extension. Super simple.
  • A model for delivering Zip file content. This is really a basic model as all it needs to do is support delivery of any generic file type.
  • A Partial Router that will accept additional pathing past the zip file URL so we can direct traffic to files that are inside the zip.
That's more or less it, aside from setup or configuration related to the above.

The HTML5 App

I'm terrible at JavaScript. I used to be ok at JavaScript. At least, I could make things work pretty well. That being said, I'll admit that I completely over-engineered my sample HTML5 app for this test. In honor of our recent launch of a (still pretty alpha at time of writing) Foundation React-based SPA reference architecture, I figured I'd learn something new and try a very hello-world-style React-based page.

Again, I'm terrible at JavaScript. Truly. Don't judge me.

In fact, please don't judge any of this code. 

I'm so old...

Anyway. I wanted to showcase that this little trick will deliver local assets using relative paths, including JavaScript (two React files), CSS (bootstrap, natch), and images. So I spent WAY TOO LONG figuring out just the most basic of tasks in React in order to deliver this little one-page sample that does absolutely nothing impressive. 


While rather simple, the app structure is intended to be representative of functionality that would be desireable in any uploaded static HTML application. That you can load packaged resources, deliver on media, and provide dynamic in-client functionality. 

This app will be compressed as "Html5.zip" and uploaded into Episerver's Media library after we add the following model.

The ZipMedia Model

Episerver's Foundation reference places most of the models for Blocks, Pages, and Media into the Foundation.Cms project - excepting those more specific to either Commerce or Search. I'll folllow that convention.

The model itself just needs to be a container for this PoC. So I'm keeping it basic - just a container that allows for the upload of Zip media into the library.

using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.DataAnnotations;
using EPiServer.Framework.DataAnnotations;

namespace Foundation.Cms.Media
{
    [ContentType(DisplayName = "ZipMedia", GUID = "25c15d1a-1cea-4a46-9d9a-7aeeb23536ff", Description = "")]
    [MediaDescriptor(ExtensionString = "zip")]
    public class ZipMedia : MediaData
    {
        
    }
}

That's it. As I said, just a container. So let's move on to another model - the model needed to deliver raw files from the zipped container.

The ZippedContent Model

I'm going to be honest, I wasn't sure where to put this one in the list of Foundation projects. I think of it as a ViewModel of sorts, but in reality it's a data model as it's not being sent directly to a View, so I put it into a new, generically named, Models folder/namespace under Foundation.Cms. I *think* that's a good/right place, but whatever, this is demo code. You do your thing better than mine.

For delivery, there really are only two properties that are necessary - more, like filename, if you want to do fancy things like attaching things for download rather than raw delivery. For this PoC, I want files to render in-browser, so these two properties are sort of the minimum. The actual bytes of the file and the mimetype so the client can correctly interpret the data.

namespace Foundation.Cms.Models
{
    public class ZippedContentModel
    {
        public virtual byte[] FileBytes { get; set; }
        public virtual string FileType { get; set; }
    }
}

Something I learned along the way here is that these properties need to be virtual. In my prior ignorance I had made these models inherit IContent for some reason.

Don't do that. Do this. SO MUCH EASIER and I have no idea what I was thinking. I have no excuses. We all regret yesterday's code.

NOTE: If you're going to use instances of this model to later reconstruct virtual paths, then you may want to add an additional property for the url-encoded path to the file inside the zip. I'm not doing this, so left it out.

With two models done, it's time to make the PartialRouter.

The ZipRouter...um...Router

The two code things I love most about Episerver are ContentProviders and PartialRouters. Really, these just open up so many opportunities that I really could go on. And they're surprisingly easy to implement, so if these aren't in your repertoire of skills, I suggest you add them. They don't disappoint.

The PartialRouter allows me to basically just make use of extra path values tacked onto the end of the content URL. The goal here is to do things like take the base /path/to/file.zip URL and append paths to the contained files onto the end of it. For example, /path/to/file.zip/index.html.

When that happens, this router will define what should happen with that data and what should be returned to the end-user making this request. While this is often used for list pages, category filters, and the like, I find it interesting that we can apply the same concepts to file URLs and just pretend like that's normal. It's just wonderful.

Inside of Foundation, I chose to put this code into the Foundation project and created a new namespace underneath Infrastructure called PartialRouters. This will hold the router and controller. Again, your own architecture may vary.

When you look at the Episerver PartialRouter documentation, you'll see that the implementation boils down to inheriting an interface and implementing its two methods. Let's take a look.


using EPiServer.Core;
using EPiServer.Framework.Blobs;
using EPiServer.Web.Routing;
using EPiServer.Web.Routing.Segments;
using Foundation.Cms.Media;
using Foundation.Cms.Models;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Web;
using System.Web.Routing;

namespace Foundation.Infrastructure.PartialRouters
{
    public class ZipMediaPartialRouter : IPartialRouter<ZipMedia, ZippedContentModel>
    {
        private ContentReference _mediaRoot;

        public ZipMediaPartialRouter(ContentReference zipMediaRoot)
        {
            _mediaRoot = zipMediaRoot;
        }

        public PartialRouteData GetPartialVirtualPath(
            ZippedContentModel content, 
            string language, 
            RouteValueDictionary routeValues, 
            RequestContext requestContext)
        {
            return new PartialRouteData()
            {
                BasePathRoot = _mediaRoot
            };
        }

        public object RoutePartial(
            ZipMedia content, 
            SegmentContext segmentContext)
        {
            var pathOmitQS = segmentContext
                .RemainingPath.Split('?').First();
            segmentContext.RemainingPath = "";
            // This uses 100% of the path to find 
            // the file, so no need to break it 
            // down further.

            var model = new ZippedContentModel
            {
                FileBytes = GetBlobFileBytes(
                    content, 
                    pathOmitQS),
                FileType = GetFileMimeTypeFromPath(
                    pathOmitQS)
            };

            return model;
        }

        // Supporting methods listed separately.
    }
}

Above is the PartialRouter (with supporting private methods omitted). It consists of a constructor and two methods - one, which is not really used, is supposed to accept a model and return the URL that produces it (at least as far as I understand it). The other does the work of most interest here, which is to use the requested path in conjunction with the base content object in order to construct the new model (in my case ViewModel) that contains the correct data for the controller.

What I've done here is consumed the URL portion following the *.zip. I then set the RemainingPath to an empty string in order to prevent further loopbacks on this router that aren't needed. Finally, the code generates a model from the content provided (using methods below) and returns it so that it may be passed to its controller.

As mentioned, there are two private methods that I omitted from the above pasted code. These two methods determine the binary content of the file in blob storage (as a byte array) as well as the MIME type, which is important for browsers or other clients to consume, for example, JavaScript as JavaScript and not as text.

GetBlobFileBytes uses available libraries for working with Zip files to read a specific compressed file at a time, rather than uncompressing the entire asset into memory. This could certainly be further optimized but for a proof-of-concept it works surprisingly well. If you use this, cache as you see fit for your application.

private byte[] GetBlobFileBytes(
    ZipMedia content, 
    string internalFilePath)
{
    var blob = (FileBlob)content.BinaryData;
    var document = ZipFile.Open(
        blob.FilePath, 
        ZipArchiveMode.Read)
        .GetEntry(internalFilePath);
    byte[] fileBytes = null;

    using (var stream = document.Open())
    {
        using (var memStream = 
            new MemoryStream())
        {
            byte[] buffer = new byte[8 * 1024];
            int read;
            while ((read = 
                stream.Read(buffer, 0, buffer.Length)) > 0)
            {
                memStream.Write(buffer, 0, read);
            }

            fileBytes = memStream.ToArray();
        }
    }

    return fileBytes;
}

And, naturally, GetFileMimeTypeFromPath does just as the name implies, using the filename to automatically determine the MimeType to return in the response.

private string GetFileMimeTypeFromPath(string path)
{
    var fileType = string.Empty;

    if (path.Contains(".")) // has presumed extension
    {
        var fileName = path.Split('/').Last();
        fileType = MimeMapping.GetMimeMapping(fileName);
    }

    return fileType;
}

Now complete, the model that's generated in the RoutePartial method of our IPartialRouter seems to go through the same flow as any other CMS content. Meaning that you can pick it up in a controller.

But first, this router needs initialized.

Router Initialization

I'm placing this in the same Foundation.Infrastructure.PartialRouters namespace as it seems cleaner to me to have these things be colocated.

This initialization module is seriously one of the most simple you can create. Just a standard module with two lines of code.

using System.Web.Routing;
using EPiServer.Core;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.Web.Routing;

namespace Foundation.Infrastructure.PartialRouters
{
    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class ZipMediaPartialRouterInitialization 
        : IInitializableModule
    {
        public void Initialize(InitializationEngine context)
        {
            var partialRouter = 
                new ZipMediaPartialRouter(
                    new ContentReference(609));
            RouteTable.Routes
                .RegisterPartialRouter(partialRouter);
        }

        public void Uninitialize(InitializationEngine context)
        {
            //Add uninitialization logic
        }
    }
}

If you've ever created an initialization module, then you'll notice right away that there's not much to initializing a PartialRouter. In this case, I set up a specific "HTML5 Apps" folder in the Media library where I want zip routing enabled.

If you want enable in-zip routing globally, you can set it to the Root or For All Sites or whatever, but I felt it a better solution to have it be restricted to a specific folder. By doing so you can permission it differently, have a more dedicated structure, and have uploaded files outside of this scope where in-zip routing isn't desired.

With Routing enabled for these paths, we now need a controller that can handle accepting the data and passing it along in the correct format.

The ZippedContentController

Again, I found myself looking at the Foundation code architecture and sort of wondering where I should put the controller and view information. In the end, I chose to follow convention and included a ZippedContent folder directly under Foundation.Features.

Instead of using a Visual Studio Extension Template for either a Page or Block type, it's best to start with a plain Controller. Keep the inheritance from Controller, but add a rule to inherit also from IRenderTemplate so this Controller becomes the default for data coming from the PartialRouter (ZippedContentModel).


using EPiServer.Web;
using EPiServer.Web.Routing;
using Foundation.Cms.Models;
using System.Web.Mvc;

namespace Foundation.Features.ZippedContent
{
    public class ZippedContentController 
        : Controller, IRenderTemplate<ZippedContentModel>
    {
        public ActionResult Index()
        {
            var zippedContent = Request
                .RequestContext
                .GetRoutedData<ZippedContentModel>();

            if (zippedContent.FileType.ToLower()
                .Contains("image"))
            {
                Response.ClearContent();
                Response.ContentType = zippedContent
                    .FileType;
                Response.OutputStream.Write(
                    zippedContent.FileBytes,
                    0, 
                    zippedContent.FileBytes.Length);
                Response.End();
                return null;
            }

            return File(zippedContent.FileBytes, 
                zippedContent.FileType);
        }
    }
}

Remember that this controller has a singular purpose - to accept files extracted from the zip and pass them forward. In writing this, I found a great struggle in returning images. Other file types for JavaScript & HTML worked fine. Images...no. Hence the "image" filter and alteration to the response. I simply couldn't find a single response methodology that worked across the board. If you have a better solution, please share as this was painful.

Anyway, step one in this is to grab the data from the PartialRouter. This could probably use some error checking, so please stay safe. Regardless, it's a pretty simple exercise to get the routed data from the RequestContext. The generic method helps it come through already typed, which is nice. :)

In my case, due to aforementioned image tribulations, I have a conditional in this controller to watch out for images. It clears the response (I had issues with byte 0 being bad) before manually setting the ContentType (MIME) and writing the extracted bytes directly to the OutputStream. That's for images.

Literally every other file type was happy to be delivered over the Return File method. In this case, it is still important to include the FileType as the second parameter. Without it, browsers will read delivered HTML or JavaScript (et al) as plain text with no execution.

Wrapping Up

For implementation, that pretty much wraps this up. I broke it down pretty fiercely with explanations but what you're looking at is a mere 5 class files, less than 200 lines of code altogether, to get this little PoC off the ground.

To be honest, I spent far more time debugging why images weren't coming through correctly than I did writing the PartialRouter. So if you haven't given one of these a try, then I highly recommend writing one of your own just to add this bit of knowledge to your Episerver Superhero Utility Belt. We all have one of those, right?

The result? Well, I'll let this little video speak for itself. Demo of completed code at 16:16.


1 comment:

Joe Mayberry said...

Very cool idea, and it seems like it would be pretty easy to implement. Great job!