1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
|
<?php
$title = "Forced Downloads in the Browser Via HTTP";
if (isset($early) && $early) {
return;
}
include($_SERVER['DOCUMENT_ROOT'] . '/includes/head.php');
?>
<p class="description">
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!)
</p>
<p>
Let's say you want to generate 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 <code>^s</code> but that's less than ideal. Ideally, they would click a link/submit a form, and immediately be prompted 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.
</p>
<h2>The Default: Rendering in a Tab</h2>
<p>
As a baseline, here is some sample PHP for writing text to the client.
</p>
<pre>
<code>
<?php
header('content-type: text/plain');
print("hello, world\n");
</code>
</pre>
<p>
This code responds to all requests with a single header specifying the default plain text content type and then writes <code>hello, world</code> as the response body. Note that since <code>text/plain</code> is the default type, it can usually be omitted. I'm including it here for illustrative purposes.
</p>
<p>
You can listen for requests with this script using <code>php(1)</code> and send requests with <code>curl(1)</code>.
</p>
<pre>
<code>
$ 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
</code>
</pre>
<p>
Awesome. In the browser, the text gets rendered in the active tab.
<img src="https://nextcloud.53hor.net/index.php/s/6NCf9cXayReAkwT/preview" />
</p>
<h2><code>Content-Disposition</code> to the Rescue: Forcing a File Download Prompt</h2>
<p>
We can include a <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition"><code>Content-Disposition</code></a> header with the request to prompt for file download on the browser side. Here's some updated PHP:
</p>
<pre>
<code>
<?php
header('content-type: text/plain');
header('content-disposition: attachment; filename=hello.txt');
print("hello, world\n");
</code>
</pre>
<p>
With <code>curl(1)</code>, we can see the header even though it just prints the text as usual.
</p>
<pre>
<code>
$ 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
</code>
</pre>
<p>
And on the browser side, the usesr is prompted to download <code>hello.txt</code>. 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.
<img src="https://nextcloud.53hor.net/index.php/s/Nr67rHmQRWy48E9/preview" />
</p>
<p>
This is all it took to satisfy our primary use case. Now let's look at one of the dubious workarounds out there.
</p>
<h2>Please Don't Falsify Your <code>Content-Type</code></h2>
<p>
This is a strange workaround that you may find elsewhere online (<a href="https://www.php.net/manual/en/function.header.php"><em>cough cough</em></a>). It looks something like this:
</p>
<pre>
<code>
<?php
header('content-type: application/my-random-fake-mime');
print("hello, world\n");
</code>
</pre>
<p>
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.
</p>
<p>
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 <code>NOTEPAD.EXE™</code>, this would bypass that. Now the user has to add a rule for files of type <code>my-random-fake-mime</code> just for you.
</p>
<p>
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.
<img src="https://nextcloud.53hor.net/index.php/s/opmEy79ArJJ9se8/preview" />
</p>
<h2>Wrap-Up</h2>
<p>
It's pretty easy once you know what to do. I'll quickly mention another hack online is to send multiple <code>Content-Type</code> 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 <code>Content-Type</code> is expected with the response. If you <em>really</em> want to prove that, send as many headers with the content type as you want from the server and see how many of them <code>curl(1)</code> or the browser keeps around (hint: it's only the last one).
</p>
<p>
I should also mention this functionality is universal and does not apply only to PHP.
</p>
|