1

I'm trying to generate a PDF file with HTML content that's being sent via AJAX from JavaScript.

I have the HTML being sent properly, and the PDF being generated correctly using the HTML, but I'm stuck trying to figure out how to initiate the file download on the user's browser.

This is what I'm currently trying. This works in a IHttpHandler file when hitting the URL for the file directly, but doesn't work in the ApiController when posting via AJAX.

I've also tried posting to the IHttpHandler file with the same results. Everything works fine until I try to initiate the download by using BinaryWrite.

public class DownloadScheduleController : ApiController
{
    public void Post(HtmlModel data)
    {
        var htmlContent = data.html;

        var pdfBytes = (new NReco.PdfGenerator.HtmlToPdfConverter()).GeneratePdf(htmlContent);

        using (var mstream = new System.IO.MemoryStream())
        {
            HttpContext.Current.Response.ContentType = "application/pdf";
            HttpContext.Current.Response.AppendHeader("content-disposition", "attachment; filename=UserSchedule.pdf");
            HttpContext.Current.Response.BinaryWrite(pdfBytes);
            HttpContext.Current.Response.End();
        }
    }
}

Here is the Ajax request. It's using Angular's $http.

$http({
    method: 'POST',
    url: 'api/downloadSchedule',
    dataType: "json",
    contentType: "application/json; charset=utf-8",
    data: data
});

Alternatively I could return the PDF binary to JavaScript, but then how do I use JavaScript to save the pdf?

Any help will be greatly appreciated.

Nikolay Kostov
  • 16,433
  • 23
  • 85
  • 123
Kolby
  • 2,775
  • 3
  • 25
  • 44
  • I've only ever done this by actually redirecting (via 301) to a non-API controller that returns the bytestream and `content-disposition: attachment`. I'm not 100% sure if it can be done with AJAX (but I definitely could be wrong - going off memory here). – Nate Barbettini Jan 30 '15 at 23:29
  • Ya, unfortunately I'm in a situation where this needs to be done through AJAX. The html content that the PDF generator needs is being loaded dynamically through Javascript on another page. When they click the download butting I use an iFrame to trigger the page and get the content, and once the content has loaded I have an event that triggers the API call to the downloadSchedule ApiController. – Kolby Jan 30 '15 at 23:33
  • I would try doing 2 things: Response.Flush(); before your Response.End(); and Response.OutputStream.Write(pdfBytes,0,pdfBytes.Length); instead of Response.BinaryWrite(pdfBytes) – JFlox Jan 30 '15 at 23:40
  • Thanks for the comment JFlox. I tried both of those, but still nothing. – Kolby Jan 30 '15 at 23:46
  • Not sure the using statement is required as you don't seem to actually be using the mstream anywhere in that block. – JFlox Jan 30 '15 at 23:55
  • This might help: http://stackoverflow.com/questions/4545311/download-a-file-by-jquery-ajax – Nate Barbettini Jan 31 '15 at 00:15

1 Answers1

2

For security reasons, the browser cannot initiate a file download from an AJAX request. It can only be done by navigating to a page that sends a file.

Usually if you need to initiate a download from Javascript, you would either set window.location.href = urlToFile, or create a new iframe pointing to that url.

In your case this would not serve, because the above methods only perform a GET request, and you need a POST, so I only see two possible solutions to this:

  • you could modify your JS to - instead of submitting the request with $http - create an HTML form with fields that correspond to what you originally posted with your AJAX request, then append the form to the page, populate the fields and submit the form
  • or you could change your server-side code as well as your Javascript.

If you opt for the second solution, you could follow the approach of using two methods on the server side:

  • one POST that generates the file and saves it in the cache (there are probably better solutions that using the cache, especially if you're on a server farm, but let's keep it simple)

  • one GET that that retrieves the cached content and returns it to the user. In this way you will be able to post the data via an AJAX call, and then getting the file by navigating to the right url.

Your C# code would look something like this:

public class DownloadScheduleController : ApiController
{
    public object Post(HtmlModel data)
    {
        var htmlContent = data.html;

        var pdfBytes = (new NReco.PdfGenerator.HtmlToPdfConverter()).GeneratePdf(htmlContent);
        var policy = new CacheItemPolicy { AbsoluteExpiration = DateTimeOffset.Now.AddSeconds(30), Priority = CacheItemPriority.NotRemovable };
        var cacheId = Guid.NewGuid().ToString();
        MemoryCache.Default.Add("pdfBytes_" + cacheId, pdfBytes, policy);
        return new { id = cacheId };
    }

    public void Get(string id)
    {
        var pdfBytes = MemoryCache.Default.Get("pdfBytes_" + id);
        MemoryCache.Default.Remove("pdfBytes_" + id);

        using (var mstream = new System.IO.MemoryStream())
        {
            HttpContext.Current.Response.ContentType = "application/pdf";
            HttpContext.Current.Response.AppendHeader("content-disposition", "attachment; filename=UserSchedule.pdf");
            HttpContext.Current.Response.BinaryWrite(pdfBytes);
            HttpContext.Current.Response.End();
        }
    }
}

Your frontend could then be something like:

$http({
    method: 'POST',
    url: 'api/downloadSchedule',
    dataType: "json",
    contentType: "application/json; charset=utf-8",
    data: data
}).success(function(response) {
    // note: the url path might be different depending on your route configuration
    window.location.href = 'api/downloadSchedule/' + response.id;
});

Keep in mind that my code is just meant to show you the approach, it's not meant to be used as it is in production. For example, you should definitely clear the cache immediately after use. You should also make sanity checks in your Get method for what is retrieved from the cache, before using it. Also, you might want to take additional security precautions on the data that you store and retrieve, depending on your requirements.

Stefano Dalpiaz
  • 1,673
  • 10
  • 11
  • Do you know if the first method will work with webforms? I've considered using the second method, but the data can be VERY large, and is a large scale application with a lot of users. I don't think any server side storage option will work in this case. – Kolby Jan 31 '15 at 04:45
  • You could adapt the first solution to work with webforms, but it probably needs viewstate, so you would need to define the form elements beforehand, and then populate them from your JS function and call `__doPostBack()` instead of simply submitting the form. As for the second solution, why do you think it will not work? I don't think it makes a difference if you have a large amount of data: as soon as you create the byte array, you already have it all in memory, so what's the problem in remembering it for the next request? – Stefano Dalpiaz Jan 31 '15 at 05:32
  • I ended up using the second method and it works perfectly, thank you very much. It's not that I didn't think it would work, I'm just concerned about opening a vulnerability for a DOS (or similar) attack. I see what you're say though, remembering it for an additional 30 seconds isn't going to do much. How about removing the cache once the Get function has ran? – Kolby Feb 02 '15 at 18:16
  • Yes, you should definitely do that. My code is just meant to show you the approach, it's not meant to be used as it is in production. You should definitely clear the cache immediately after use. You should also make sanity checks in your Get method for what is retrieved from the cache, before using it. Also, you might want to take additional security precautions, because in this way ANY content stored in cache can be accessed. Using a prefix for the cache key might be enough in your case. – Stefano Dalpiaz Feb 02 '15 at 22:46
  • As I realised that this is quite important, I have edited my answer to include those remarks. I have also made a couple of simple edits to the code: I have added a prefix for the cache key, and I have changed the policy to make sure that the cache item does not get automatically deleted by the application. – Stefano Dalpiaz Feb 02 '15 at 22:59