From 6f5e8042470c94cef14b94b8c4251c1cfb436d95 Mon Sep 17 00:00:00 2001
From: 53hornet
- I'm pretty proud of this project. It's not a very large project, but that's - one of the things I'm proud of. It went through a couple of iterations, and - with each I actually ended up removing code that wasn't being used to solve the - primary problem. It's the first time I've gotten the chance to release an - internal project as FOSS and I'm super stoked. Hopefully someone else will - benefit from its release. Maybe I'll delve into its inner workings in another post sometime. + I'm pretty proud of this project. It's not a very large project, but that's one of the things I'm proud of. It went through a couple of iterations, and with each I actually ended up removing code that wasn't being used to solve the primary problem. It's the first time I've gotten the chance to release an internal project as FOSS and I'm super stoked. Hopefully someone else will benefit from its release. Maybe I'll delve into its inner workings in another post sometime.
diff --git a/posts/2021-10-22-forced-downloads-in-the-browser-via-http.php b/posts/2021-10-22-forced-downloads-in-the-browser-via-http.php new file mode 100644 index 0000000..958a2b1 --- /dev/null +++ b/posts/2021-10-22-forced-downloads-in-the-browser-via-http.php @@ -0,0 +1,159 @@ + + ++ Recently a coworker was tasked with adding a "file download" link to a web page. The file in question was being generated dynamically by the server, but we really wanted to make sure the file didn't get rendered in the user's tab. Here's how you can leverage HTTP headers to do that (and some alternative ways you shouldn't!) +
+ +
+ Let's say you want to genreate a file server-side and send it back to the client's browser to download. If it's plain text, the browser will usually try to render it in a tab, without prompting the user what they want to do with it. The user could then save the page with ^s
but that's less than ideal. Ideally, they would click a link/submit a form, and immediately be prompted to save the file to save the file to disk and even open it in a local application. If they elect to always perform that action with files of that kind, the download will start immediately.
+
+ As a baseline, here is some sample PHP for writing text to the client. +
+ +
+
+<?php
+header('content-type: text/plain');
+print("hello, world\n");
+
+
+
+
+ This code responds to all requests with a single header specifying the default plain text content type and then writes hello, world
as the response body. Note that since text/plain
is the default type, it can usually be omitted. I'm including it here for illustrative purposes.
+
+ You can listen for requests with this script using php(1)
and send requests with curl(1)
.
+
+
+$ nohup php -S localhost:8080 &
+[1] 47647
+appending output to nohup.out
+$ curl -v localhost:8080
+* Trying 127.0.0.1:8080...
+* Connected to localhost (127.0.0.1) port 8080 (#0)
+> GET / HTTP/1.1
+> Host: localhost:8080
+> User-Agent: curl/7.79.1
+> Accept: */*
+>
+* Mark bundle as not supporting multiuse
+< HTTP/1.1 200 OK
+< Host: localhost:8080
+< Date: Fri, 22 Oct 2021 21:39:37 GMT
+< Connection: close
+< X-Powered-By: PHP/7.4.24
+< Content-type: text/plain;charset=UTF-8
+<
+hello, world
+* Closing connection 0
+
+
+
++ Awesome. In the browser, the text gets rendered in the active tab. + +
+ + +Content-Disposition
to the Rescue: Forcing a File Download Prompt
+ We can include a Content-Disposition
header with the request to prompt for file download on the browser side. Here's some updated PHP:
+
+
+<?php
+header('content-type: text/plain');
+header('content-disposition: attachment; filename=hello.txt');
+print("hello, world\n");
+
+
+
+
+ With curl(1)
, we can see the header even though it just prints the text as usual.
+
+
+$ curl -v localhost:8080
+* Trying 127.0.0.1:8080...
+* Connected to localhost (127.0.0.1) port 8080 (#0)
+> GET / HTTP/1.1
+> Host: localhost:8080
+> User-Agent: curl/7.79.1
+> Accept: */*
+>
+* Mark bundle as not supporting multiuse
+< HTTP/1.1 200 OK
+< Host: localhost:8080
+< Date: Fri, 22 Oct 2021 21:59:47 GMT
+< Connection: close
+< X-Powered-By: PHP/7.4.24
+< Content-type: text/plain;charset=UTF-8
+< content-disposition: attachment; filename=hello.txt
+<
+hello, world
+* Closing connection 0
+
+
+
+
+ And on the browser side, the usesr is prompted to download hello.txt
. The browser completely skips rendering the file in the tab (this isn't even a presented option!). Our MIME type (Text Document) is specified, and the user can optionally change their preference for what should be done with that type by default.
+
+
+ This is all it took to satisfy our primary use case. Now let's look at one of the dubious workarounds out there. +
+ +Content-Type
+ This is a strange workaround that you may find elsewhere online (cough cough). It looks something like this: +
+ +
+
+<?php
+header('content-type: application/my-random-fake-mime');
+print("hello, world\n");
+
+
+
++ What this does is pretend that your content is an unknown type to the browser. This way it doesn't attempt to render the content and instead asks the user what to do with it. Not only is this a hack-y workaround, it's also broken. First of all, this MIME doesn't exist in the specification. So it's unknown, undocumented, and unnecessary. +
+ +
+ The second way this is broken is it will probably bypass any of those user-defined settings that determine what to do with files of a plain text type. If the user wanted all text files to instantly download and open in NOTEPAD.EXE™
, this would bypass that. Now the user has to add a rule for files of type my-random-fake-mime
just for you.
+
+ The third (and last I'm going to talk about) way this is broken is it does not give the user a useful filename. They don't even get a file extension that they can do anything useful with. They'll have to guess by inspection, which makes for a poor user experience. See the screenshot for what this abomination looks like. + +
+ +
+ It's pretty easy once you know what to do. I'll quickly mention another hack online is to send multiple Content-Type
headers to the client to "trick" the browser into downloading the file but with the correct MIME. Not only does this not work the way you would expect, it's also unecessary and goes against the HTTP spec., where only one Content-Type
is expected with the response. If you really want to prove that, send as many headers with the content type as you want from the server and see how many of them curl(1)
or the browser keeps around (hint: it's only the last one).
+
+ I should also mention this functionality is universal and does not apply only to PHP. +
-- cgit v1.2.3