Chris Weldon

Rants and musings on .Net, SharePoint, PHP, and consulting.

jQuery File Uploader, Cross Domain Requests, and ASHX Web Services

Last night I spent all evening (and well into the night) working on a fix for a customer. This customer was using the jQuery File Uploader control to upload pictures to their remote server that’s associated with a customer’s order. The upload control was not reflecting the upload status in the built-in progress-bar. It was immediately advancing to 100% and holding until the upload actually completed. After which point the user would be redirected to the appropriate destination page. This left the potential for users to become confused as to whether the browser was “locked up” and start clicking around and disrupting the upload process. The request was simple: fix the control to reflect the actual upload progress. What I encountered while trying to fix this solution was anything but simple.

Cross-Domain Requests and JavaScript

This upload problem was complicated slightly by the fact that we are uploading files on a different domain from the site being hosted (e.g. the customer-facing web site is on www.imagehost.com and the upload host is photos.imagehost.com). The reason for this is the server that holds the files and needs to be accessed by employees is different from the server hosting the primary customer-facing web site. The customer was not interested in setting up a web service on the customer-facing web site to act as a proxy to the destination web service, so some work had to be done to investigate how to handle cross-domain requests with JavaScript.

All modern browsers implement the Same Origin Policy. This policy was introduced as a means to help secure browsers by preventing content sharing by unrelated sites in order to maintain confidentiality and prevent loss of data. In this context, this means that my standard XmlHttpRequests that are used to invoke the AJAX calls to my remote web service will be blocked by the browser because the destination origin does not match the source web site origin.

There have been two attempts to circumvent this problem: Cross-Origin Resource Sharing (CORS) and JSONP. There are legitimate use cases (such as the one I’m dealing with) where submitting requests to a remote domain without proxying is preferred. Think about how web mashups work – they involve a huge amount of cross-site requests, so being able to invoke remote services without frames is essential.

The problem with JSONP is that it only supports the GET method and does not use XmlHttpRequest. However, CORS is not supported in legacy browsers, whereas JSONP does work with legacy browsers. Ultimately, I feel the level of complexity of this upload control necessitates a modern browser that supports CORS. Using GET to upload an image is not RESTful. By that same argument, there’s enough other JavaScript on this web site that legacy browsers that don’t support CORS will likely not behave correctly anyways. In short, I have no problem telling customers of this site to use a more modern browser if they wish to use the site.

In order to support CORS, your web service has to send back certain HTTP headers that indicate to the browser what methods, origins, and other security policies are allowed by the web service. Without going into too much detail, i suggest you read a much better article on CORS on the Mozilla site.

Thankfully, the jQuery File Uploader control has does support cross-domain requests in more ways than one. In fact, the demo they have posted on the site is issuing a cross-domain request from blueimp.github.com to jquery-file-upload.appspot.com. The default method is via an XmlHttpRequest (XHR) to a CORS-enabled web service. The alternate method is to use an iFrame transport method. According to their documentation, this is used for browsers like Internet Explorer and Opera which do not yet support XHR file uploads. However, you can configure the file upload control to force iFrame transport by using the forceIframeTransport option.

This is what the previous developer had done. He was forcing the iFrame transport because he had not enabled the web service to send the CORS-required headers to allow uploads via XHR.

Core of the Problem

I started to surmise that using the iFrame transport method instead of XHR could be causing the problem with the upload progress bar. I quickly tested this on the Demo page by issuing the following in my JavaScript console:

force-iframe-transport.js
1
2
3
$('#fileupload').fileupload('option', {
    forceIframeTransport: true
});

I then attempted to upload a file that would take a few seconds to upload (~5MB in size). As expected, the progress bar zipped to 100% and the page hung for several seconds while the upload finished in the background. When I reloaded the page and uploaded the same file without issuing the above JavaScript, the progress bar advanced as I would expect it to, and when it reached 100% the done action fired.

This comes back to the original problem with cross-domain requests with JavaScript. Effectively, my iFrame request is for a different origin than my source web site. The ability for the JavaScript driving the file upload control to monitor the progress of the upload in an iFrame is blocked by the browser in adherence to the Same Origin Policy. Therefore, the best the control can do is kick off the iFrame request in an asynchronous fashion, update the progress bar to 100%, and wait for the iFrame to finish loading. Thanks go to my good friend Ben Floyd for reminding me of this.

I opted to drop the forceIframeTransport option and try to get things working using XHR.

Sending the Headers via an ASHX Service

As mentioned previously, the original developer spun up an ASHX service to handle this request. I feel that since this is a .Net 4.0 project we could have created an ASP.NET Web API project since we’re dealing with JSON requests, but I’ll assume that the developer is unfamiliar with the ease of those types of projects and reverted to what he knew. Fair enough argument.

The service handler is fairly simple. Thankfully, the developer separated the front-end logic (of the service) from the business processing logic, albeit into a static method. grumbles

UploadManagerHandler.ashx.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using System;
using System.Text;
using System.Web;

namespace UploadManager
{
    public class UploadManagerHandler : IHttpHandler
    {
        public void ProcessRequest(HttpContext context)
        {
            context.Response.AddHeader("Pragma", "no-cache");
            context.Response.AddHeader("Cache-Control", "private, no-cache");

            string action = context.Request.Params["action"];
            if (!string.IsNullOrWhiteSpace(action))
                action = action.Trim().ToLower();

            switch (action)
            {
                case "upload":
                    if (context.Request.Files == null || context.Request.Files.Count == 0)
                        context.Response.End();

                    var upload = UploadImageManager.Upload(context);
                    string json = UploadImageManager.SerializeJson(upload);

                    context.Response.AddHeader("Vary", "Accept");
                    context.Response.ContentType = context.Request["HTTP_ACCEPT"].Contains("application/json") ? "application/json" : "text/plain";
                    context.Response.Write(json);
                    context.Response.StatusCode = 200;
                    break;
                default:
                    context.Response.ClearHeaders();
                    context.Response.StatusCode = 405;
                    break;
            }

            context.Response.End();
        }

        public bool IsReusable
        {
            get
            {
                return false;
            }
        }
    }
}

In order to make this web service CORS-enabled, we need to send back the Access-Control-Allow-Headers and the Access-Control-Allow-Origin headers. So, a simple refactoring led me to the following that I invoke from the ProcessRequest method:

SendHeaders.cs
1
2
3
4
5
6
7
private void SendHeaders(HttpContext context)
{
    context.Response.AddHeader("Pragma", "no-cache");
    context.Response.AddHeader("Cache-Control", "private, no-cache");
    context.Response.AddHeader("Access-Control-Allow-Headers", "X-File-Name,X-File-Type,X-File-Size");
    context.Response.AddHeader("Access-Control-Allow-Origin", "*");
}

I started the debugger in Cassini and everything started working beautifully! It was time to deploy to IIS.

IIS and OPTIONS Requests

While using Cassini to test the changes to the ASHX services, I had no problems getting a successful response to the OPTIONS request submitted by the jQuery File Uploader. However, when I published to IIS, I ran into another wall – my OPTIONS request to the IIS-hosted service was not returning the custom headers Access-Control-Allow-Headers and Access-Control-Allow-Origin, despite getting a 200-level response. In fact, the request and response just showed the following headers:

Request Headers.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
Accept:*/*
Accept-Charset:ISO-8859-1,utf-8;q=0.7,\*;q=0.3
Accept-Encoding:gzip,deflate,sdch
Accept-Language:en-US,en;q=0.8
Access-Control-Request-Headers:origin, x-file-size, x-file-name, content-type, accept, x-file-type
Access-Control-Request-Method:POST
Cache-Control:no-cache
Connection:keep-alive
Host:lionheart.local
Origin:http://localhost:49627
Pragma:no-cache
Referer:http://localhost:49627/BillOrder/PhotoPrintsUpload
User-Agent:Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.152 Safari/535.19
Response Headers.txt
1
2
3
4
5
6
Allow:OPTIONS, TRACE, GET, HEAD, POST
Content-Length:0
Date:Fri, 13 Apr 2012 13:33:33 GMT
Public:OPTIONS, TRACE, GET, HEAD, POST
Server:Microsoft-IIS/7.5
X-Powered-By:ASP.NET

Because I was not getting my custom errors, JavaScript was bailing out because it couldn’t verify that the remote web service would authorize the cross-domain request:

Why weren’t my custom headers coming across the wire? When I attached the debugger, I found that my ASHX web service was not even being invoked when I tried to use the control, despite seeing the OPTIONS request coming through my network console. I knew then that IIS was preventing or intercepting the request, so I decided to investigate the handlers for IIS. For reference, the web service is hosted on a web site with an Application Pool running .Net Framework 4 – Integrated Managed Pipeline.

When looking at the lionheart.local Home screen in IIS, double-click the Handler Mappings.

From there, look through the list until you find a handler named SimpleHandlerFactory-Integrated-4.0 with a path of *.ashx and double-click that handler.

In the Edit Managed Handler window that pops up, click the Request Restrictions button.

In the window that pops up (Request Restrictions), click the Verbs tab.

This confirms my suspicions – the handler is not setup to pass OPTIONS requests to the actual web service. The simple fix is to add OPTIONS to the list of verbs to respond. However, this is just a temporary fix, as every time I re-deploy the web service, this setting would be reverted. Thus, the more permanent fix is to add the following to the project’s web.config (inside <system.webServer>) so that this permanently sticks:

1
2
3
4
<handlers>
  <remove name="SimpleHandlerFactory-Integrated-4.0" />
  <add name="SimpleHandlerFactory-Integrated-4.0" path="*.ashx" verb="GET,HEAD,POST,DEBUG,OPTIONS" type="System.Web.UI.SimpleHandlerFactory" resourceType="Unspecified" requireAccess="Script" preCondition="integratedMode,runtimeVersionv4.0" />
</handlers>

Upon re-deploying the service, the OPTIONS request finally returned the proper headers:

Updated Response Headers.txt
1
2
3
4
5
6
7
8
9
Access-Control-Allow-Headers:X-File-Name,X-File-Type,X-File-Size
Access-Control-Allow-Origin:\*
Cache-Control:private, no-cache
Content-Length:0
Date:Fri, 13 Apr 2012 14:11:17 GMT
Pragma:no-cache
Server:Microsoft-IIS/7.5
X-AspNet-Version:4.0.30319
X-Powered-By:ASP.NET

ASHX Response.StatusCode and Response.End()

After pushing these last changes to IIS, I was able to watch the progress bar advance at the correct pace while the file upload progressed. Yet, I ran into yet another issue. Once the upload completed, the network response did not respond with an HTTP Success status code. In fact, it looked like from the network console in Chrome that the web service arbitrarily terminated the connection. No response headers were even registered in the network console.

I attached the debugger to the IIS process and observed that it executed the code without throwing an exception. In fact, the IIS logs show an HTTP status code value of 200. I was completely at a loss and had to think about this for about 15 minutes before checking out what HttpContext.Current.Response.End() was doing. I came across a Microsoft KnoweldgeBase Article indicating that Response.End() will throw a ThreadAbortException. I began to think about why we even needed this invocation. All raw web services I’ve dealt with since .Net 2.0 have never required forcing the response to end. Simply returning from the service will wrap up all necessary ends and send the data back to the client. That’s when I looked at the MSDN Documentation and found this:

This method is provided only for compatibility with ASP—that is, for compatibility with COM-based Web-programming technology that preceded ASP.NET. If you want to jump ahead to the EndRequest event and send a response to the client, call CompleteRequest instead. To mimic the behavior of the End method in ASP, this method tries to raise a [ThreadAbortException] exception. If this attempt is successful, the calling thread will be aborted, which is detrimental to your site’s performance. In that case, no code after the call to the End method is executed.

This isn’t classic ASP, so dumping this method call was the first thing for me to do. Yet, that didn’t fix the problem. I still did not receive any response headers in my network console. So, in digging around a little more, I decided to try to force send a ResponseCode of 200 back to the client after the upload completed. I literally jumped out of my seat when the upload succeeded, the network console registered a 200-level response, and kicked off the done event to send me to the destination confirmation page.

The final code for the web service that made all of this work is below.

UploadManagerHandler-final.ashx.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using System;
using System.Text;
using System.Web;

namespace UploadManager
{
    public class UploadManagerHandler : IHttpHandler
    {
        public void ProcessRequest(HttpContext context)
        {
            // Send the headers. If the request is for OPTIONS, end the transmission.
            SendHeaders(context);
            if (context.Request.HttpMethod.Equals("OPTIONS", StringComparison.InvariantCultureIgnoreCase))
            {
                return;
            }

            string action = context.Request.Params["action"];
            if (!string.IsNullOrWhiteSpace(action))
                action = action.Trim().ToLower();

            switch (action)
            {
                case "upload":
                    if (context.Request.Files == null || context.Request.Files.Count == 0)
                    {
                        return;
                    }

                    var upload = UploadImageManager.Upload(context);
                    string json = UploadImageManager.SerializeJson(new[] { upload });

                    context.Response.AddHeader("Vary", "Accept");
                    context.Response.ContentType = context.Request["HTTP_ACCEPT"].Contains("application/json") ? "application/json" : "text/plain";
                    context.Response.Write(json);
                    context.Response.StatusCode = 200;
                    break;
                default:
                    context.Response.StatusCode = 405;
                    break;
            }
        }

        private void SendHeaders(HttpContext context)
        {
            context.Response.AddHeader("Pragma", "no-cache");
            context.Response.AddHeader("Cache-Control", "private, no-cache");
            context.Response.AddHeader("Access-Control-Allow-Headers", "X-File-Name,X-File-Type,X-File-Size");
            context.Response.AddHeader("Access-Control-Allow-Origin", "*");
        }

        public bool IsReusable
        {
            get
            {
                return false;
            }
        }
    }
}

Comments