How To Force File Download With PHP
Say you want a PHP script that will make the browser to download a file instead of opening it. This is useful for common filetypes that would normally be displayed in a browser, like .html, images, PDFs and .doc files.
You can find a lot of scripts that do this on the web but one thing most of them don’t handle is download resuming and multi-thread downloading. If you don’t need that feature, feel free to use a simpler script. Personally I’ve found that a function that handles download resuming works more reliably across various browsers (what actually happened : a simpler script didn’t work with Opera + my weird Internet connection, so I found another script and decided to make broad generalizations about download resuming :p).
Note – most of the code below isn’t mine. I found it somewhere on the web and adjusted it for my needs; unfortunately I’ve lost the URL of the original page. If you know where it came from, let me know and I’ll add a link to this post.
function output_file($file, $name, $mime_type='') { /* This function takes a path to a file to output ($file), the filename that the browser will see ($name) and the MIME type of the file ($mime_type, optional). If you want to do something on download abort/finish, register_shutdown_function('function_name'); */ if(!is_readable($file)) die('File not found or inaccessible!'); $size = filesize($file); $name = rawurldecode($name); /* Figure out the MIME type (if not specified) */ $known_mime_types=array( "pdf" => "application/pdf", "txt" => "text/plain", "html" => "text/html", "htm" => "text/html", "exe" => "application/octet-stream", "zip" => "application/zip", "doc" => "application/msword", "xls" => "application/vnd.ms-excel", "ppt" => "application/vnd.ms-powerpoint", "gif" => "image/gif", "png" => "image/png", "jpeg"=> "image/jpg", "jpg" => "image/jpg", "php" => "text/plain" ); if($mime_type==''){ $file_extension = strtolower(substr(strrchr($file,"."),1)); if(array_key_exists($file_extension, $known_mime_types)){ $mime_type=$known_mime_types[$file_extension]; } else { $mime_type="application/force-download"; }; }; @ob_end_clean(); //turn off output buffering to decrease cpu usage // required for IE, otherwise Content-Disposition may be ignored if(ini_get('zlib.output_compression')) ini_set('zlib.output_compression', 'Off'); header('Content-Type: ' . $mime_type); header('Content-Disposition: attachment; filename="'.$name.'"'); header("Content-Transfer-Encoding: binary"); header('Accept-Ranges: bytes'); /* The three lines below basically make the download non-cacheable */ header("Cache-control: private"); header('Pragma: private'); header("Expires: Mon, 26 Jul 1997 05:00:00 GMT"); // multipart-download and download resuming support if(isset($_SERVER['HTTP_RANGE'])) { list($a, $range) = explode("=",$_SERVER['HTTP_RANGE'],2); list($range) = explode(",",$range,2); list($range, $range_end) = explode("-", $range); $range=intval($range); if(!$range_end) { $range_end=$size-1; } else { $range_end=intval($range_end); } $new_length = $range_end-$range+1; header("HTTP/1.1 206 Partial Content"); header("Content-Length: $new_length"); header("Content-Range: bytes $range-$range_end/$size"); } else { $new_length=$size; header("Content-Length: ".$size); } /* output the file itself */ $chunksize = 1*(1024*1024); //you may want to change this $bytes_send = 0; if ($file = fopen($file, 'r')) { if(isset($_SERVER['HTTP_RANGE'])) fseek($file, $range); while(!feof($file) && (!connection_aborted()) && ($bytes_send<$new_length) ) { $buffer = fread($file, $chunksize); print($buffer); //echo($buffer); // is also possible flush(); $bytes_send += strlen($buffer); } fclose($file); } else die('Error - can not open file.'); die(); } /********************************************* Example of use **********************************************/ /* Make sure script execution doesn't time out. Set maximum execution time in seconds (0 means no limit). */ set_time_limit(0); $file_path='that_one_file.txt'; output_file($file_path, 'some file.txt', 'text/plain');
Related info
Determining MIME type of a file automatically
Common MIME types
HTTP, caching and other stuff
Hey- Thanks for the great script. I got it working, and it works perfectly with 200MB+ files. I see your donate button, so I’ll have to send you some cash! One question. How secure is this script if I’m using a variable to get the file like so:
download.php?fileName=video.mov
And users can see the script path in the html? I hard coded the directory in the script. Would someone be able to use the script and a bit of trickery to download any file on the site? Thanks again for the great script.
@MeltingMIND – Thanks
The script isn’t very secure – it wasn’t intended to be. However, you could improve the security with a few modifications.
For example, in the current implementation, a user who knows (or can guess) the names of other files, could download any file. Here’s one way to prevent this :
Pick a secret value and put it in your conf. files or wherever…
…modify the file that shows the download links to calculate a hash for each link and append it to the download URL :
…and verify the hash in download.php :
Or something like that. Obviously you’ll need to adapt this to your setup.
Great, thanks for that. I will give it a try just to be double-sure. But the script, as is, if I hardcoded the file path in the script, wouldn’t allow download of any other files except those in the hard coded folder? If I make it a folder of only downloads, then I should be relatively OK?
Though as I said, I may try your hash method to be sure…
@MeltingMIND – Theoretically yes, but a user could try to pass in a relative path, e.g. “fileName=../../index.php”. You could treat filenames that contain slashes as invalid, but I’m not sure that would solve the problem completely.
Great script, thanks. I need some help though please:
1. Tested on IIS6 running php 5.2.3; no problems. The ’save as’ dialog pops up in FireFox and IE8. Beautiful.
2. Tried on apache running php 5.2.5; all other php code works BUT instead of getting the file download dialog, I get several packets of html data including the file chunks tacked on the end. the header states the packet type is text/html, whereas with IIS, the type is binary (which is what I’m expecting).
I’ve not been able to locate a similar issue via google. Is this a problem with php settings, php versions, or apache vs IIS? Does anyone have a solution to this?
Thank you.
@Rhys` – I use Apache exclusively and the script works fine for me, so it’s unlikely that the problem is with the server itself. Configuration problems are possible, though.
What kind of html data do you get? Maybe it’s error messages or something like that?
@Rhys` – You could also try adding these headers to the top:
header(”Pragma: “);
header(”Cache-Control: “);
header(”Cache-Control: public”);
This corrects certain issues with IE. I don’t know why, but it works.
[...] and it will open in whatever picture viewer they have set as a default on their computer. I use this force download script. It’s not the easiest to implement, but unlike other force download scripts I have used, this one [...]
How can make it support resume but only allow for 1 thread only, not multi threads ?
I don’t think there’s an easy way to do it. You could use a database to record who (IP address) and when starts downloading the file. Then change the script to exit if the visitor’s IP is already downloading. It would probably end up pretty complex.
Re. this problem:
Warning: Cannot modify header information – headers already sent by (output started at F:\localhost\CURRENT\cngadv\mrp\incl\kondisi.php:65) in F:\localhost\CURRENT\cngadv\mrp\incl\function.php on line 361
I had this and resolved it; it is to do with output buffering. If output buffering is off, then the page header is sent as the php code is parsed, which makes it impossible to change the header (it’s already sent). The solution is to turn output buffering on:
ob_start();
There are a couple of other ob_ functions I can’t recall, but for me at least, adding ob_start() to the top of the relevant php file solved it.
Simply superb ! Works a charm with a bit of modifications for my requirement which included a hash verification (like white shadow said).
Muy buena funcion, felicitaciones!. Me ha sido de mucha utilidad.
Hello,
thanks for the script !
I have a bug on my site, when user clicks my DOWNLOAD FILE button, the image, video or sound file doesn’t get downloaded but the browser displays ASCII characters, here an extract :
�����JFIF���������;CREATOR: gd-jpeg v1.0 (using IJG JPEG v62), quality = 90 ���C� ���C ���,�”�������������� ������ �}�!1AQa”q2���#B��R��………..
I have this error with different MIME TYPES : mp3, jpg, mpg, flv, ….
What could be the reason ?
THanks in advance.
Have you tried a different browser? I’ve had this problem with certain filetypes in Opera, regardless of the server.
Other than that, you could use a a network sniffer to find out what’s different between the downloads that work normally and those that have this problem.
The function doesn’t work if the files are more than 1MB. I’m just getting a 404 error.
Hmm, it works fine here. And even if it glitches when loading large files, it shouldn’t produce a 404 error – the script just doesn’t include any commands that could do that.
Nevertheless, you could try setting $chunksize to a lower value, e.g. 102400 bytes. It’s currently set to 1024*1024 (= 1 MB), which might cause the script run out of RAM on severely memory-restricted systems.
I tried making that higher so that’s probably the problem. I only needed to download rar, zip and exe so i’m now using the few lines in the php manual which seems to work.
Hi,
Warning: Cannot modify header information – headers already sent by (output started at F:\localhost\CURRENT\cngadv\mrp\incl\kondisi.php:65) in F:\localhost\CURRENT\cngadv\mrp\incl\function.php on line 361
can be fixed if you remove all priint and acho statements in your code
If it still comes then use ob_start(); at the start of file.
Anees
Very useful article and script, thank you very much.
This works great in all browsers except Firefox (v 3.5.4). In Firefox there is a prompt that says:
C:\Users\Downloads\smilies.zip.part could not be saved, because the source file could not be read.
Try again later, or contact the server administrator.
But if you hit the retry button in the Firefox download manager it downloads ok. All other browsers work fine… any suggestions?
What I would do : Use a network sniffer and compare the HTTP headers generated by the script to headers generated by normal downloads. Then tweak the script so that it outputs sufficiently similar headers.
Sorry I am not familiar with a “network sniffer” I tried googling it and found some results for freeware but I have no clue how to use it. Can you suggest a free, easy one that I could use?
SmartSniff is free and pretty easy to use. Wireshark is probably the most popular one, but personally I find it a bit too complicated.
Wireshark is way overkill for that kind of thing. Fiddler2 is probably the way to go. It’s actually Microsoft-developed and free — go figure (although beginning to be more common these days). There is even a Firefox plugin for it.
But ya, I definitely agree with White Shadow here. There is almost certainly a problem with your headers because I have had this working in Firefox for a while now.
I was able to use the FireFox add-on to view the headers. The only difference I see is that when serving the download directly the header tag Conetet-Disposition is added.
Header direct download:
HTTP/1.1 200 OK
Date: Tue, 03 Nov 2009 21:30:35 GMT
Server: Apache/2.0.52 (Red Hat)
Vary: Host
Last-Modified: Tue, 03 Nov 2009 04:43:32 GMT
Etag: “4f86a7-284aab-22e02500″
Accept-Ranges: bytes
Content-Length: 2640555
Keep-Alive: timeout=15, max=91
Connection: Keep-Alive
Content-Type: application/zip
Headers through force download
HTTP/1.1 200 OK
Date: Tue, 03 Nov 2009 21:32:26 GMT
Server: Apache/2.0.52 (Red Hat)
Vary: Host,Accept-Encoding
X-Powered-By: PHP/4.3.9
Expires: Mon, 26 Jul 1997 05:00:00 GMT
Cache-Control: must-revalidate, post-check=0, pre-check=0, private
Pragma: public
Content-Encoding: gzip
Content-Disposition: attachment; filename=”1000smilies.zip”
Content-Transfer-Encoding: binary
Accept-Ranges: bytes
Content-Length: 2640555
Keep-Alive: timeout=15, max=84
Connection: Keep-Alive
Content-Type: application/x-zip-compressed
I deleted some for testing so they matched up but I get the same result. Anything you see that sticks out at you?
No, the headers look fine to me. Perhaps there is some other difference, like the script dropping the connection too early or some other glitch?
I just tried testing the script with FF 3.5.4 myself, but it seems to work fine on my machine (at least when the script is run on localhost).
Not sure why it would work fine in all browsers except Firefox. If the script was dropping the connection I would think it would drop it in all browsers. Do you think the gzip is causing the issue? Thank you for your time.
I commented out @ob_end_clean(); and it seems to have fixed the issue. Do you think that will cause issues?
It should be fine. That ob_end_clean() call is only relevant on servers that have output buffering turned on by default, so commenting it out shouldn’t cause problems on most systems.