Phoenix: Provide send_attachment/3

Created on 29 Jun 2016  Â·  32Comments  Â·  Source: phoenixframework/phoenix

There doesn't seem to be a mechanism to send data to the browser as an attachment or inline as a file download. The send_file function doesn't seem to allow the file to be sent outside the rendering of the layout. The file renders as the pdf source in the page as opposed to sending the file down to the browser to be saved or opened.

intermediate

Most helpful comment

Thanks @ryanwinchester. We are proceeding with send_download in the next days unless someone has an objection. :)

All 32 comments

You just need to set the mime type correctly.

https://hexdocs.pm/plug/Plug.Conn.html#put_resp_content_type/3

application/octet-stream is the right content-type for it to download in _most_ browsers.

That doesn't change anything for me. It still renders the PDF source in the webpage:

screen shot 2016-06-29 at 11 21 41 am

put_resp_content_type(conn, "application/octet-stream", "utf-8")
send_file(conn, 200, attachment.path)

You need to bind the new conn

conn = put_resp_content_type(conn, "application/octet-stream", "utf-8")
send_file(conn, 200, attachment.path)

Or:

conn
|> put_resp_content_type("application/octet-stream", "utf-8")
|> send_file(200, attachment.path)

Unfortunately that doesn't make a difference. It does make it to the conn object:

{"content-type", "application/pdf; charset=utf-8"}],

You should also set content-disposition to attachment. We had plans to
include a function that handles some of it but I don't remember the
outcome. @chrismccord?

On Wednesday, June 29, 2016, Justin [email protected] wrote:

Unfortunately that doesn't make a difference. It does make it to the conn
object:

{"content-type", "application/pdf; charset=utf-8"}],

—
You are receiving this because you are subscribed to this thread.
Reply to this email directly, view it on GitHub
https://github.com/phoenixframework/phoenix/issues/1786#issuecomment-229429195,
or mute the thread
https://github.com/notifications/unsubscribe/AAAlbnFuKs0Qv6PMR6wO_h36mhhUEVAHks5qQqvqgaJpZM4JBUYd
.

_José Valim_
www.plataformatec.com.br
Skype: jv.ptec
Founder and Director of R&D

@justinbkay are you actually saying that send_file is overriding the content_type?

No, the content-type is being written to the conn object by the put_resp_content_type function. I think there are pieces missing like the content-disposition that Jose mentioned. There are certainly pieces missing to make this work like I'd expect it to. When it does write the pdf source to the response it is incredibly slow.

You don't want to add a "utf-8" encoding to a binary stream. What you need to make this work is something along the lines:

conn
|> put_resp_content_type("application/octet-stream", nil)
|> put_resp_header("content-disposition", ~s[attachment; filename="hello.pdf"])
|> send_file(200, attachment.path)

@chrismccord maybe we should have a send_attachment that works kinda like this:

def send_attachment(conn, kind, opts \\ [])

def send_attachment(conn, {:file, path}, opts) do
  ext = Path.extname(path)
  filename = opts[:filename] || Path.basename(path)
  content_type = opts[:content_type] || case Path.extname(path) do
    "." <> ext -> Plug.MIME.type(ext)
    _ -> "application/octet-stream"
  end

  conn
  |> warn_if_ajax()
  |> put_resp_content_type(content_type, opts[:charset])
  |> put_resp_header("content-disposition", ~s[attachment; filename="#{escape_filename filename}"])
  |> send_file(conn.status || 200, path)
end

def send_attachment(conn, {:binary, contents}, opts) do
  filename = opts[:filename] || raise ":filename option is required"
  content_type = opts[:content_type] ||  "application/octet-stream"

  conn
  |> warn_if_ajax()
  |> put_resp_content_type(content_type, opts[:charset])
  |> put_resp_header("content-disposition", ~s[attachment; filename="#{escape_filename filename}"])
  |> send(conn.status || 200, contents)
end

or something like that.

@josevalim @chrismccord :+1: on this. I am doing the same thing to send a file when I have already read the contents. This seems like the sort of thing that will come up frequently.

Should this issue be in Plug?

So that code doesn't make it work @josevalim, here's the conn:

%Plug.Conn{adapter: {Plug.Adapters.Cowboy.Conn, :...},
 assigns: %{current_user: %OwnersPortal.PortalUser{__meta__: #Ecto.Schema.Metadata<:loaded, "portal_users">,
    active: true, first_name: "asdf", id: 18, last_name: "asdf", password: nil,
    password_digest: "asdfasdf",
    portal_users_property_lists: #Ecto.Association.NotLoaded,
    portal_users_property_lists_property_lists: #Ecto.Association.NotLoaded,
    username: "d@"},
   user_token: "sometoken"},
 before_send: [#Function<0.78035410/1 in Plug.CSRFProtection.call/2>,
  #Function<4.40362542/1 in Phoenix.Controller.fetch_flash/2>,
  #Function<0.74176132/1 in Plug.Session.before_send/2>,
  #Function<1.134073044/1 in Plug.Logger.call/2>], body_params: %{},
 cookies: %{"_owners_portal_key" => "somekey"},
 halted: false, host: "10.0.0.26", method: "GET", owner: #PID<0.369.0>,
 params: %{"_pjax" => "#pjax-container", "id" => "85020",
   "property_id" => "128"},
 path_info: ["properties", "128", "invoice", "85020"],
 peer: {{10, 0, 0, 37}, 60428}, port: 4001,
 private: %{OwnersPortal.Router => {[], %{}}, :phoenix_action => :download,
   :phoenix_controller => OwnersPortal.InvoiceController,
   :phoenix_endpoint => OwnersPortal.Endpoint, :phoenix_flash => %{},
   :phoenix_format => "html", :phoenix_layout => false,
   :phoenix_pipelines => [:browser, :authenticate_user],
   :phoenix_route => #Function<27.118008578/1 in OwnersPortal.Router.match_route/4>,
   :phoenix_router => OwnersPortal.Router,
   :phoenix_view => OwnersPortal.InvoiceView,
   :plug_session => %{"_csrf_token" => "j4enR4jMpJ0ZVlSQesUcTQ==",
     "user_id" => 18}, :plug_session_fetch => :done},
 query_params: %{"_pjax" => "#pjax-container"},
 query_string: "_pjax=%23pjax-container", remote_ip: {10, 0, 0, 37},
 req_cookies: %{"_owners_portal_key" => "somekey"},
 req_headers: [{"host", "10.0.0.26:4001"}, {"connection", "keep-alive"},
  {"accept", "text/html, */*; q=0.01"}, {"x-requested-with", "XMLHttpRequest"},
  {"x-pjax", "true"}, {"x-pjax-container", "#pjax-container"},
  {"user-agent",
   "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.84 Safari/537.36"},
  {"content-type", "application/x-www-form-urlencoded; charset=UTF-8"},
  {"referer", "http://10.0.0.26:4001/properties/128/invoices"},
  {"accept-encoding", "gzip, deflate, sdch"},
  {"accept-language", "en-US,en;q=0.8"},
  {"cookie",
   "_owners_portal_key=somekey"}],
 request_path: "/properties/128/invoice/85020", resp_body: nil,
 resp_cookies: %{},
 resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"},
  {"x-request-id", "rnu7t9ik95a7pe5aqr1c1458hv4v43a3"},
  {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"},
  {"x-content-type-options", "nosniff"},
  {"content-type", "application/octet-stream"},
  {"content-disposition",
   "attachment; filename=\"4.27.16 All Lock Acme $356.pdf\""}], scheme: :http,
 script_name: [],
 secret_key_base: "somethingsecrethere",
 state: :unset, status: nil}

I have just tried it in a small plug and it worked. Which behaviour are you seeing? Which browser are you using? Did you try other browsers to see if it is something maybe browser specific?

I'm using Mac OS with Chrome and Safari, same result.

Here's the full controller code:

def download(conn, %{"property_id" => property_id, "id" => id}) do
    property = OwnersPortal.PortalUser.get_first_property_from_lists(conn.assigns.current_user, property_id)
    invoice = Repo.get!(property_invoices(property), id)
    attachment = get_attachment(invoice)
    conn
    |> put_resp_content_type(attachment.file_type, nil)
    |> put_resp_header("content-disposition", ~s[attachment; filename="#{attachment.file_name}"])
    |> IO.inspect
    |> send_file(200, attachment.path)
  end
  defp property_invoices(property) do
    assoc(property, :invoices)
  end
  defp get_attachment(invoice) do
    query = from a in OwnersPortal.InvoiceAttachment,
            where: a.invoice_id == ^invoice.id,
            limit: 1
    Repo.one query
  end

I think we are missing content-transfer-encoding. It worked here because i was not sending a pdf but a text file. Add this too:

|> put_resp_header("content-transfer-encoding", "binary")

PS: I have updated the code above.

No change, the pdf source still renders to the page.

It does make it into the conn:


resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"},
{"x-request-id", "bje0ed1op9uqd72skk0pavi0kauo39p3"},
{"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"},
{"x-content-type-options", "nosniff"}, {"content-type", "application/pdf"},
{"content-disposition", "attachment; filename=\"AH Acranet 41.00.pdf\""},
{"content-transfer-encoding", "binary"}], scheme: :http, script_name: [],

I just tried with the package.json file in the send_file function and it renders the text file (package.json) to the page just like the pdf source.

Is it something in the routing pipeline maybe?

@josevalim I believe we decided to wait because there are so many disposition options. We also preferred a compassable approach like put_attachment instead of send_attachment, so I would be onboard doing put_attachment followed by send_file/send_resp

I think we are missing content-transfer-encoding. It worked here because i was not sending a pdf but a text file. Add this too:
|> put_resp_header("content-transfer-encoding", "binary")

@josevalim this shouldn't solve anything, the header was obsoleted long time ago. see rfc2616#sec19.4.5
Also, in 2014, RFC2616 was replaced by multiple RFCs (7230-7237). The "actual" headers can be found in wiki :pray:

@justinbkay could you please provide the "high-level" debug information(the response/request headers from dev tools or curl) to make sure that nothing else changes your response?

Here are the response headers from dev tools:

HTTP/1.1 200 OK
server: Cowboy
date: Thu, 30 Jun 2016 17:32:34 GMT
content-length: 21463
cache-control: max-age=0, private, must-revalidate
x-request-id: ah6ipdv1ug0fln64fhokkjkdla1g7i36
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
x-content-type-options: nosniff
content-type: application/pdf
content-disposition: attachment; filename="AH Acranet 41.00.pdf"
content-transfer-encoding: binary

@justinbkay are you trying to download the pdf using ajax?

No, I am using a pjax plug.

On Thu, Jun 30, 2016 at 12:49 PM, dm1try [email protected] wrote:

@justinbkay https://github.com/justinbkay are you trying to download
the pdf using ajax?

—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
https://github.com/phoenixframework/phoenix/issues/1786#issuecomment-229753133,
or mute the thread
https://github.com/notifications/unsubscribe/AAAP_g7k33W5Qndt9V1mYftuO6tVLrqhks5qRA-rgaJpZM4JBUYd
.

Sites:
http://jbkayconstruction.com
https://twitter.com/justinbkay
http://justinbkay.tumblr.com

It always takes longer than you expect, even when you take into account
Hofstadter’s Law.

  • Hofstadter’s Law

@justinbkay in the logs you provided above, the request is AJAX({"x-requested-with", "XMLHttpRequest"}), in that case content-disposition does not make sense. Make sure you did a proper integration.

I don't think that the described error is related to phoenix framework, but the send/put_attachment helpers is something that can be helpful anyway.

If I turn off the pjax, the download now works.

@chrismccord I guess both put_attachment and send_attachment are fine. The advantage of send_attachment is that we can swap between send_file and send behind the scenes properly but with a bit more coupling. Another reason for adding such function is so we warn if it is called during an ajax request. :D

In my head attachments are to do with email and downloads are to do with browsers. So I'd name this send_download/put_download \2p

Naming is tricky here because the content-disposition header which controls all this uses "attachment" so deviating is a balance between clarity for those not familiar with the header and those who are and who will be confused by with "download" does

If you knew about content-disposition: attachment then you wouldn't need the helper method :^) But I see you can argue it from both sides! I can take a look at this if no-one else is doing it...

EDIT: Does this go in Plug or Phoenix?

How to detect if send/send_file is used - should we do this as it's possible that without send_file support you are going massively increase load without it being explicit to the author what happened.

@chrismccord i don't think many people who are familiar with the header would be confused by what "download" means, but i think more likely people not familiar with the header could be confused by what "attachment" means. jmho

Thanks @ryanwinchester. We are proceeding with send_download in the next days unless someone has an objection. :)

So we seem to have send_download now in Phoenix (great), but we don't seem to be able to configure content-disposition header, and the other common case is to use "inline" disposition versus "attachment".

"attachment" instructs browser to save file to disk. "inline" instructs it to open downloaded file.

Common scenario is to use "inline" when you have something like "Print this page to PDF", and current browsers can open PDF files natively.

Would it make sense to either provide: send_inline that does everything the same as send_download, except using "inline"? I'd call this Option 1.

Alternatively we could:

  1. Allow setting content-disposition header by user prior to calling send_download. Then our current helper function would only conditionally put_resp_header and never overwrite what the user already set.
  2. Parametrize send_download to understand inline vs. attachment content disposition and act accordingly.

What do you think?

I would vote for 3.

José Valimwww.plataformatec.com.br
http://www.plataformatec.com.br/Founder and Director of R&D

I will get someone to send over PR for this then.

Was this page helpful?
0 / 5 - 0 ratings