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
Thanks for the function! One issue:
While the browser (Firefox) is downloading a file outputted by this function, it does not appear to know the total size of the file. So there is no download progress and the download status reads something like “downloaded 3.5MB of unknown”. Is that something we can fix?
The total size does appear for me when using Firefox, so I don’t know about that. However, your question prompted me to review the code and I found a (non-critical) bug, which I have now fixed (I think). You can try the modified function and see if that helps any.
Strange. I’m still getting “unknown file size” with Firefox or IE7. Thanks for your help!
(Sorry for the late response. Forgot to check back here.)
Strange indeed. Oh well
Thank you for your script! I tried a few others, and no matter what I did, powerpoint files were coming through with a filesize of zero bytes. Using this script however, I am able to get the file to come through properly.
I do have one problem though. I am programming my site for Japanese (I work in a Japanese company), and when I call the function with $name using Japanese text, it comes through garbled. I have tried using header(’Accept-Charset: Shift_JIS’) and header(’Content-Type: ‘ . $mime_type ‘; charset=Shift_JIS’); and a combination of both, but they don’t seem to work. I have also tried using the other two types of Japanese encoding, and they dont seem to work either. Any ideas?
I don’t have a complete solution, but here are some ideas :
* Use a HTTP header viewer and look what headers are sent by other websites that display the filename correctly. For example, if you have FireFox, you could use the LiveHTTPHeaders addon for this.
* When you set charset=whatever, are you sure you’re using the same charset in your PHP file? Maybe it’s actually Unicode?
* See this page – HTTP headers and non-ASCI characters
Thanks so much for this script! I was looking for a way to force the download of a vcard off my website and this did the trick!
hi
the code for download works fine for me…
thank you very much…
For some reason it is not working for me. I have these attributes assigned in my downloadfile.php:
filename: test.12.docfiletype: application/msword
filepath: /files/15.attach
I call it using the function that is in this article using the following:
output_file($filepath, $filename, $filetype);However, I get this error:
File not found or inaccessible!OOPS! If anyone has a solution for the above problem, please email me at mrd3sai@gmail.com (Thanks)
A path that starts with a forward slash will be interpreted as an absolute path by most operating systems, which is probably not what you want to do here. If you need the script to download the file “15.attach” from a folder called “files” that is in the same directory as the script, you should use “file/15.attach”, not “/file/15.attach”.
Found out the bug was in my functions.php file. I had a space at the end and because headers don’t do well if something is already outputted, it was erroring out.
Just wanted to say thanks – helped me get something done at work. I’ve left the URL of this page in the PHP comments in case anyone other than me works on that partic script
Glad to help
Very nice.
I am also getting the filesize problem. filesize($file); returns the correct filesize, so I can only assume the browser (firefox/ie7) is not figuring out the header.
This happens with other download scripts I have seen. For example, the simplest way of doing it:
header(’Content-Length: ‘.filesize($file));
has the same problem.
Just tested again with the first download manager in google (http://www.freedownloadmanager.org) and size is given correctly, resume works nicely etc. Good job.
Doesn’t help explain browsers though(?)
I can’t add much to what you said.
However, you might be able to locate the cause by looking at the complete HTTP headers for a download that displays the file size, and the one that doesn’t. See what the difference is.
I’m too lazy to do it myself
Another thing Mr. Shadow. What would you say is the most robust way of implementing this?
At the moment I have a page which fetches the information about the download. Currently, the file path is put in a session variable, and a link is given on this page to download.php. download.php contains you script. It looks at the session variable, and calls output_file accordingly.
But it is not perfect. For example, if I cancel the download in firefox half way, then click the download.php link a second time to restart it, nothing happens. (Similarly, clicking retry in firefox attempts to download download.php rather than the correct file).
Cheers
I think the obvious way would be to pass the information to download.php in a URL parameter. Though you probably don’t want the user to know the file path (right?). For this, there are many possible solutions :
* Store the file path in a database and pass an unique ID to download.php, e.g. “download.php?id=123″. Make the download script get the appropriate path from the DB. This is probably what I would do.
* Do the same thing but instead of a DB save the ID/filepath pairs in a file.
* Encrypt the file path and pass it to the script. You could use the mcrypt module, or something like this encryption class
Use what best fits your situation.
‘Robustivness’ problem identified. In my case, session_start() at the start of download.php. Calling a session messes up the flow I suppose. I will be implementing the db approach.
Still no clues about filesize.
Thanks again
Thanku very much .. its working fine in my code
[...] This script has done well for me. __________________ This is not a signature. [...]
Thanks for the code! Worked a charm.
I need this to get it to work with Flash-based slideshows like http://www.flashnifties.com/xml_slideshow.php or http://www.maani.us/xml_slideshow. Without download resume support, Flash cannot download the images and generates an error in the slideshow, which means you could only use static images, not ones coming from a script.
Thanks again!
This script is working very well, there is one question I have and it’s late, or early depending on how you look at it, but I can’t get this script to do anything once its completed. I would prefer to have it return true since I am calling it from with in a custom class, but even if i could get it to do a header redirect that would be good.
Thanks so much for the fine work though and any help you can give!
Have you tried simply replacing the last die() with “return true”? I haven’t tested it but I think it should work. This wouldn’t tell you if the file download has completed successfuly (in fact, that would be impossible to know server-side with multithreaded download managers), but you would at least know the downloader has disconnected (but may connect again later if that wasn’t the last chunk to download).
excuse me, i just wanna ask something
what does this part means?
header(”Expires: Mon, 26 Jul 1997 05:00:00 GMT”);
thanks before
This is supposed to prevent the browser (if a browser is used to download the file) from caching the download. Specifically, it states that the file can only be cached until an arbitrary day in July 1997, which means it will not be cached at all.
[...] use This script. Put it on your server, and make your links point to the script, using the link syntax that they [...]
It works in firefox fine. but in IE the save dialog box is not coming.
what could be the reason?
“Blame Microsoft!”
As I might have suggested before, compare headers sent by downloads that show the download box and and those that don’t. See how they differ.
this works well. Thanks
Nice script. Thanks for publishing. 2 questions:
Can I call this after output has started?
Is it possible after calling this function to send another page to the browser?
@pixelterra – It won’t work after output has started unless you are buffering the output. This is because the script needs to send some headers which can only be done before any other (non-header) output is sent.
I don’t think you can send another page after this script, but I suspect you could achieve the result you’re thinking of by sending the “other” page first and adding a meta refresh tag (or something similar, i.e. JS redirect) that will trigger the download.
how to install it?
where do i have to put this?
please answer me… tnx
@Zid – Basically, just put it in a .php file and change the filenames to your own. The script already includes a simple example of use (it’s at the end of the script).
@White Shadow – thanks… (^^,)
@White Shadow – i still have problem … the dowload dialog window didn’t show …
some error :
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
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 362
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 363
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 364
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 368
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 369
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 370
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 391
please help me,…. thx
@aseng – Well, I don’t know anything your scripts, so all this tells me is that something already sent some output before calling the output_file() function. Figuring out what did that might solve the problem.
Interesting that you’re using 1MB buffers. Many of the variations of this on the web use 8k buffers. That ends up making a ridiculous amount of IO calls for large files. I was using 32k, but I tried the 1MB buffer as you use here and it seems to reduce CPU usage even further.
@Eric – It’s really a bona-fide optimization problem. Larger buffers save CPU cycles but use up more memory. There are some situations when a script with a smaller buffer would hold up better under heavy load.
Thanku very much.
Parabens pelo tutorial .
does this script work with force downloading of video files? specifically .mov files that are on the bigger side (in excess of 100 MB)? i’m testing with 15MB .mov files – i get the download box but get a file that is 0kb – any suggestions on how to tweak accordingly?
thanks for putting this out there…
@cm – In theory, it should work with files of any size. I’ve had a few minor problems in certain browsers (e.g. Opera sometimes stops downloading the file before the download is complete, so I need to resume it), but nothing significant.
The way I would approach this problem is I would run a network sniffer (for example, SmartSniff – google it) and watch what actually happens during the browser-server communication when you get the 0Kb file. Otherwise there simply isn’t enough information to figure out the likely source of the problem.
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.