Download <href="blob:http://url/guid" download="filename" /> ignores download attribute

Viewed 1247

Following a number of tutorials and examples on line I have constructed a file download system built on jQuery on the client and MS WebAPI on the server.

Providing direct links to the files isn't possible as the API requires authentication, thus the file URL is an API endpoint not the file location.

On the server I have this:

[HttpGet]
[Route("download/{filename}")]
public HttpResponseMessage DownloadFile(string filename)
{
    try
    {
        // https://gist.github.com/joeriks/3714093
        string path = string.Format("{0}/Exports/{1}", root, filename);

        HttpResponseMessage result = new HttpResponseMessage(HttpStatusCode.OK);
        var stream = new FileStream(path, FileMode.Open);
        result.Content = new StreamContent(stream);
        result.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
        result.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment");
        result.Content.Headers.ContentDisposition.FileName = "download.txt";
        return result;

    }
    catch (Exception ex)
    {
        throw new HttpResponseException(HttpStatusCode.InternalServerError);
    }
}

Which returns a response as expected:

HTTP/1.1 200 OK
Cache-Control: no-cache
Pragma: no-cache
Content-Length: 4809
Content-Type: application/octet-stream
Expires: -1
Server: Microsoft-IIS/10.0
Content-Disposition: attachment; filename=download.txt
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, PUT, DELETE, GET, OPTIONS
Access-Control-Allow-Headers: content-Type, accept, origin, X-Requested-With, X-Authentication, X-Nonce, name
Date: Thu, 25 Oct 2018 13:07:25 GMT

With the text content in the response. So far all is as I would hope.

On the client I have the following for handling responses from my API:

// https://stackoverflow.com/a/23797348
let disposition = jqXHR.getResponseHeader('Content-Disposition');

if (disposition && disposition.indexOf('attachment') !== -1) {
    let filename = "scada-download.txt";
    let matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(disposition);
    if (matches != null && matches[1]) filename = matches[1].replace(/['"]/g, '');

    let type = jqXHR.getResponseHeader('Content-Type');
    let blob = new Blob([data], { type: "text/csv" });

    var downloadUrl = URL.createObjectURL(blob);
    let $a = $("<a id='temp_download_link' style='display: none;' />").attr("href", downloadUrl).attr("download", filename);
    $("body").append($a);
    $a.trigger("click");
}

This does as advertised, and adds an anchor to the page, and clicks it triggering the download.

The download works, and saves a file with the correct contents.

The only bit that doesn't work is that in both browsers I've tested it in (Chrome 69, FF: 62) the default file name provided is simply a GUID.

The api and the client code are both running from my local dev machine at present, http://127.0.0.1:9000/[client | api] so cross origin shouldn't be playing any part.

The call to the api is made via ajax. Ultimately the jQuery $.ajax() method

For clarity sake, the anchor inserted into the DOM is:

<a id="temp_download_link" style="display: none;" href="blob:http://127.0.0.1:9000/c2c5ffb5-3f22-4a57-8775-4e0bbfbfef9e" download="download.txt"></a>

The default filename provided by Chrome is the GUID in the URL, FF generates a seemingly unconnected 6 character random string.

Specifically, Why are the browsers ignoring both the download="download.txt" attribute of the anchor and the Content-Disposition: attachment; filename=download.txt?

UPDATE:

I forked this fiddle:

http://jsfiddle.net/Qjvb3/

And added some other values for the href attribute, it seems that the whole setting the filename is flaky at best:

http://jsfiddle.net/yubjqwvs/

UPDATE 2

I've copied the link from a working example into my fiddle, it works from the original site, but not from fiddle.

http://jsfiddle.net/yubjqwvs/2/

I have a feeling that the answer will boil down to "Why does it work on David Walsh Blog, but not on fiddle?"

3 Answers

I personally used downloadjs to avoid similar problems in my case:

  • An AJAX request is made to download the file; that's simply a PHP script with appropriate header (Content-Disposition: attachment; filename="...")
  • The user click on the link, which use a hashchange event to trigger the download upon which I pass the AJAX response to downloadjs. I suppose the same could be done using a Blob URL.

The code of downloadjs is pretty similar to jsfiddle loosely based on your example. It works fine with Firefox 63, Opera 56 (I guess it should work on Chrome) and it fails on IE 11, but well, that's IE.

  • downloadjs creates an hidden link with a timeout set to 66ms (I suppose it is to wait for the DOM to be ready or something like that).
  • The timeout invoke click() on the link which probably call the native handler.

With your code, and using click() as well as document.getElementById() rather than jQuery, given us this jsfiddle. Contrary to downloadjs, I don't use a window.setTimeout and it works fine, at least in Fx 63. This might simply be a way to bypass a browser bug.

In your case, my guess is that jQuery is not firing the default handler when you invoke trigger('click') which I fails to understand since trigger documentation seems to tell us it does:

As of jQuery 1.3, .trigger()ed events bubble up the DOM tree; an event handler can stop the bubbling by returning false from the handler or calling the .stopPropagation() method on the event object passed into the event. Although .trigger() simulates an event activation, complete with a synthesized event object, it does not perfectly replicate a naturally-occurring event.

To trigger handlers bound via jQuery without also triggering the native event, use .triggerHandler() instead. (quoted from jquery documentation)

Perhaps that the default click on links is not considered a handler (?) for jquery and does not get fired.

The reason I see is that you are using jQuery .attr() function instead of .prop() function. There is a difference between HTML5 properties and attributes. The first one gives markup to elements in cases for event binding and etc, while the other gives access to/sets the value of the DOM element itself.

I'll link an explanation that could be useful here.

I eventually got back to this issue when it became critical to fix it. It turned out that my (homegrown) SPA framework was the issue as it overrides the browser default click events of <a />

I fixed this by adding in a catch for links that are blobs, and then firing the default click event:

// convert all a/href to a#href
$("body").delegate("a", "click", function () {
    let href: string = $(this).attr("href"); 

    // check its not an external / absolute URL
    let regex: RegExp = new RegExp("^(blob:)?(http|https)(:\/\/)", "ig");
    let match = regex.exec(href);
    if (match) {
        // match[0] is the full match, match[1] is lookign for "blob:"
        // it will either be undefined or blob:
        if (match[1]) { // its a blob url, call the default
            return true; // !! this line is the core of the fix !!
        }
        else { // load a normal link
            // see if its got a target.
            let target: string = $(this).attr("target");

            switch (target) {
                default: document.location.href = href; break;
                case "_blank": window.open(href); break;
            }
        }   
    }
    else
        // SPA stuff
    return false;
});

So essentially a problem that no-one else would ever have had, unless they like me are foolish enough to role their own SPA framework.

Its here https://github.com/JohnRayson/JSPA

Related