htmx icon indicating copy to clipboard operation
htmx copied to clipboard

Content-Disposition: attachment is not respected

Open spaceone opened this issue 1 year ago • 6 comments

I have a POST resource /users/user/report/PDF%20Document which responds with 200 OK and

Content-Type: application/pdf
Content-Disposition: attachment; filename="report-ele4hnxorml.pdf"

and try to use HTMX to make the request to it by adding values from some selected items in a grid:

<button 
  formaction="/users/user/report/PDF%20Document"
  hx-boost="false"
  hx-disable="true"
  hx-post="users/user/report/PDF%20Document"
  hx-vals="js:{items: getAllSelectedItems()}"
  id="report2"
  name="PDF Document"
  mimetype="application/pdf"
  download
>Create PDF Document report</button>

The content of the PDF is then just inserted into the DOM - rendering it as HTML (XSS risk) (while the Content-Type says it's a application/pdf) - and no download dialog is offered.

What can I do? Maybe this would be good to document in https://hypermedia.systems/.

spaceone avatar Dec 21 '23 12:12 spaceone

I also struggled to download a file using htmx directly. After some research find out that is not currently supported, and end up using normal HTML and using a JavaScript function to download it (how is traditionally done):

  <form  action="/api/something" method="post" enctype="multipart/form-data"  onsubmit="return handleSubmit(event)" >   
      <button  type="submit">Process Files</button>

In my opinion is a very common use case that should be supported. What do you think @1cg?

I would be glad to take a look and see if I can manage to make PR for this.

WilliamSoler avatar Feb 25 '24 09:02 WilliamSoler

A quick and dirty beginning to hook beforeSwap:


htmx.defineExtension("dl", {
    onEvent: function (name, evt) {
        if (name === "htmx:beforeSwap") {
            const contentType = evt.detail.xhr.getResponseHeader("Content-Type");
            filename = "getmesomewhere"
            document.body.appendChild(Object.assign(document.createElement('a'), {
                href: URL.createObjectURL(
                    new Blob([evt.detail.xhr.responseText], {type: contentType})
                ),
                download: filename
            })).click()
            document.body.removeChild(document.body.lastChild)
        }
    }
});

It’s obviously incomplete. Should stop swap and only apply if target or swap matches something like file:bla.

andryyy avatar Feb 26 '24 09:02 andryyy

A quick and dirty beginning to hook beforeSwap:


htmx.defineExtension("dl", {
    onEvent: function (name, evt) {
        if (name === "htmx:beforeSwap") {
            const contentType = evt.detail.xhr.getResponseHeader("Content-Type");
            filename = "getmesomewhere"
            document.body.appendChild(Object.assign(document.createElement('a'), {
                href: URL.createObjectURL(
                    new Blob([evt.detail.xhr.responseText], {type: contentType})
                ),
                download: filename
            })).click()
            document.body.removeChild(document.body.lastChild)
        }
    }
});

It’s obviously incomplete. Should stop swap and only apply if target or swap matches something like file:bla.

It defeats a little bit the purpose of HTMX to reduce the amount of JavaScript. You could use downloads.download() which reduces the amount of code and increases readability.

But I still think HTMX should support file downloads.

WilliamSoler avatar Feb 26 '24 10:02 WilliamSoler

Download() has a poor browser support. And would it resolve all hx attributes like hx-vals before downloading? I used this solution to resolve the whole hx processing before downloading the returned data.

Don’t know what hx should and what not. :) Never used downloads like this, but I see the use case.

andryyy avatar Feb 26 '24 11:02 andryyy

Generating the a tag then simulating a click as @andryyy did is the right solution. Making HTMX support it natively isn't possible because HTMX uses AJAX. Here's my solution that doesn't use an extension and uses a different event listener. I do need to set the HX-Download header with the filename though. IMO this should be the documented approach or an official extension made @a-h

Obviously it's hardcoded to csv in my use case, but it wouldn't be hard to embed and extract the mime type from the HX-Download header.

document.addEventListener('htmx:afterRequest', function(evt) {
    const xhr = evt.detail.xhr;
    if (xhr.getResponseHeader('Content-Disposition') == 'attachment' && typeof xhr.getResponseHeader('HX-Download') === 'string') {
      const filename = xhr.getResponseHeader('HX-Download');
      const blob = new Blob([xhr.response], { type: 'text/csv' });
      const link = document.createElement('a');
      link.href = window.URL.createObjectURL(blob);
      link.download = filename;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
    }
  });

DreamwareDevelopment avatar Jul 24 '24 15:07 DreamwareDevelopment

Download() has a poor browser support. And would it resolve all hx attributes like hx-vals before downloading? I used this solution to resolve the whole hx processing before downloading the returned data.

Don’t know what hx should and what not. :) Never used downloads like this, but I see the use case.

Hmm what do you consider to be poor browser support? I know your reply is a few months old, but the download attribute is currently at 97.19% globally. Which in my use case is good enough.

DreamwareDevelopment avatar Jul 24 '24 16:07 DreamwareDevelopment