Friday, April 6, 2012

Serving HTML files from the App_Data folder with a custom ASP.NET MVC route

Sometimes you may want to store files on your web server that cannot be requested in browsers directly. Normal practice is to put those files in a folder outside the webroot. Another solution is to use the App_Data folder. The App_Data folder was introduced by Microsoft to hold filebased databases and similar data files in a secure way. Files placed in this folder cannot be requested by end users directly. The files will not be served because of a special httpHandler that denies the request.

In one of my applications users can upload an ebook in HTML format (including images and CSS files) to the web server. The books should only be accessible to others by a URL that contains a unique, hard-to-guess, ID of five alphanumeric characters. An example (containing a fictitious domain name):

http://www.manytypesofbooks.com/3Yru3

In ASP.NET MVC I can define a route that sends requests for URL's containing this unique ID to a controller and action that send a file to the browser. First the route (in global.asax.cs):
public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
    // ...
    routes.MapRoute(
        "UniqueId",
        "{uniqueId}",
        new { controller = "Book", action = "DownloadBook" },
        new { uniqueId = "[0-9a-zA-Z]{5}$" }
    );
    // ...
}

The last argument of the MapRoute call defines a regular expression constraint that says: only match this route if there are five alphanumeric characters at the end of the URL.

Here is the DownloadBook action method in the Book controller:
public ActionResult DownloadBook(string uniqueId)
{
    string path = Server.MapPath(
        String.Format("~/App_Data/Books/{0}/index.htm", uniqueId));
    if (System.IO.File.Exists(path))
    {
        return File(path, "text/html");
    }
    return HttpNotFound();
}

The method takes as an argument a unique ID that is specified in a URL and checks whether there is an index.htm file in a subdirectory whose name contains the ID. For instance, after a request to the URL mentioned above:

http://www.manytypesofbooks.com/3Yru3

the ID '3Yru3' will be passed to the DownloadBook method (because of the route we have defined) and the method will check whether the following file exists:

~/App_Data/Books/3Yru3/index.htm

If the file exists it will be returned to the browser. This way we have served an HTML file from the App_Data directory that could not have been requested directly in the browser. The following will not work:

http://www.manytypesofbooks.com/App_Data/Books/3Yru3/index.htm

So far, so good. The index.htm file is displayed in the browser but chances are that it will not look right. When you inspect your browser's error console it may contain messages saying that GET requests to CSS files or image files have failed. Those files are probably located in the same App_Data subdirectory as the index.htm file and referred to by a relative path in index.htm:
<link type="text/css" rel="stylesheet" href="index.css" />
<img src="cover.jpg" alt="Cover" />
Where will the browser try to locate them? At the same location as the original request for the index.htm parent file: at /3Yru3 (i.e. the root folder of the application). Of course, the paths /index.css and /cover.jpg do not exist, the files are in ~/App_Data/Books/3Yru3/. I hope you are still with me!

After having 'routed' the URL containing the unique ID to the App_Data subfolder, how do we point subsequent browser requests for CSS files and images to that same folder? I haven't found a way of doing this using a MapRoute command like the one defined above. Instead I have written a custom route class that both replaces that MapRoute definition AND 'redirects' the GET requests for CSS and image files. The class uses the fact that during those requests the Request.UrlReferrer property contains the URL of the location of the HTML file, i.e. /3Yru3. It is this ID that we need to locate the CSS and image files also.

The custom route class is added to the route collection in global.asax.cs as follows:
routes.Add(new Antrix.Web.Mvc.UniqueIdRoute());

And here is its source code. Note that it refers to the DownloadBook controller method that we have described earlier.
using System;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;

namespace Antrix.Web.Mvc
{
    public class UniqueIdRoute : RouteBase
    {
        public override RouteData GetRouteData(HttpContextBase httpContext)
        {
            const string RE_ID = @"\/(?<id>[0-9a-zA-Z]{5})$";
            Regex regExId = new Regex(RE_ID);
            string requestUrl = httpContext.Request.Url.ToString();
            Match match = regExId.Match(requestUrl);
            if (match.Success)
            {
                string id = match.Groups["id"].Value;
                // There is a unique ID in the URL that matches the constraints.
                // Serve the index.htm file in the corresponding App_Data subdirectory.
                RouteData routeData = new RouteData(this, new MvcRouteHandler());
                routeData.Values.Add("controller", "Book");
                routeData.Values.Add("action", "DownloadBook");
                routeData.Values.Add("uniqueId", id);
                return routeData;
            }
            else if (httpContext.Request.UrlReferrer != null)
            {
                string referrer = httpContext.Request.UrlReferrer.ToString();
                match = regExId.Match(referrer);
                if (match.Success)
                {
                    string id = match.Groups["id"].Value;
                    string fileName = requestUrl.Substring(
                        requestUrl.LastIndexOf("/") + 1);
                    httpContext.RewritePath(
                        String.Format("~/App_Data/Books/{0}/{1}", id, fileName));
                }
            }
            return null;
        }

        public override VirtualPathData GetVirtualPath(RequestContext requestContext,
                    RouteValueDictionary values)
        {
            return null;
        }
    }
}

If you have any questions or remarks regarding this (I think) rather complex issue, feel free to put them in a comment below!

No comments:

Post a Comment