PHP Download Script with Resume option

downloadA while ago I wrote an article about the common pitfalls of handling file downloads in PHP. One thing I did not realize at that time is that in most cases developers don’t have the time to write such a script and they’ll use whatever they can find, even if it has flaws.

Because of this, I decided to write a download script and release it free for everyone with a BSD License. It’s not a class, just a script that accepts a “file” parameter via GET or POST and outputs the file. For security purposes any paths are stripped and replaced with a path in the script (the folder containing the downloadable file(s) should be protected against direct access).

The script sets the correct MIME type for ZIP files, all other files are sent as octet stream. You may customize that part depending on the type of docs you host.

The download script also accepts range download but not multiple ranges; for the vast majority of cases this is enough.

The script is in active use and has handled tens of thousands of downloads from a vast variety of browsers. I tested it only on Apache 2 / PHP 5. Some hosts have really weird setups and limitations but hopefully you won’t get any issues.

Here’s the full script (Updated on October 31, 2012):

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
<?php
/**
 * Copyright 2012 Armand Niculescu - MediaDivision.com
 * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
 * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
 * THIS SOFTWARE IS PROVIDED BY THE FREEBSD PROJECT "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FREEBSD PROJECT OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
// get the file request, throw error if nothing supplied
 
// hide notices
@ini_set('error_reporting', E_ALL & ~ E_NOTICE);
 
//- turn off compression on the server
@apache_setenv('no-gzip', 1);
@ini_set('zlib.output_compression', 'Off');
 
if(!isset($_REQUEST['file']) || empty($_REQUEST['file'])) 
{
	header("HTTP/1.0 400 Bad Request");
	exit;
}
 
// sanitize the file request, keep just the name and extension
// also, replaces the file location with a preset one ('./myfiles/' in this example)
$file_path  = $_REQUEST['file'];
$path_parts = pathinfo($file_path);
$file_name  = $path_parts['basename'];
$file_ext   = $path_parts['extension'];
$file_path  = './myfiles/' . $file_name;
 
// allow a file to be streamed instead of sent as an attachment
$is_attachment = isset($_REQUEST['stream']) ? false : true;
 
// make sure the file exists
if (is_file($file_path))
{
	$file_size  = filesize($file_path);
	$file = @fopen($file_path,"rb");
	if ($file)
	{
		// set the headers, prevent caching
		header("Pragma: public");
		header("Expires: -1");
		header("Cache-Control: public, must-revalidate, post-check=0, pre-check=0");
		header("Content-Disposition: attachment; filename=\"$file_name\"");
 
        // set appropriate headers for attachment or streamed file
        if ($is_attachment)
                header("Content-Disposition: attachment; filename=\"$file_name\"");
        else
                header('Content-Disposition: inline;');
 
        // set the mime type based on extension, add yours if needed.
        $ctype_default = "application/octet-stream";
        $content_types = array(
                "exe" => "application/octet-stream",
                "zip" => "application/zip",
                "mp3" => "audio/mpeg",
                "mpg" => "video/mpeg",
                "avi" => "video/x-msvideo",
        );
        $ctype = isset($content_types[$file_ext]) ? $content_types[$file_ext] : $ctype_default;
        header("Content-Type: " . $ctype);
 
		//check if http_range is sent by browser (or download manager)
		if(isset($_SERVER['HTTP_RANGE']))
		{
			list($size_unit, $range_orig) = explode('=', $_SERVER['HTTP_RANGE'], 2);
			if ($size_unit == 'bytes')
			{
				//multiple ranges could be specified at the same time, but for simplicity only serve the first range
				//http://tools.ietf.org/id/draft-ietf-http-range-retrieval-00.txt
				list($range, $extra_ranges) = explode(',', $range_orig, 2);
			}
			else
			{
				$range = '';
				header('HTTP/1.1 416 Requested Range Not Satisfiable');
				exit;
			}
		}
		else
		{
			$range = '';
		}
 
		//figure out download piece from range (if set)
		list($seek_start, $seek_end) = explode('-', $range, 2);
 
		//set start and end based on range (if set), else set defaults
		//also check for invalid ranges.
		$seek_end   = (empty($seek_end)) ? ($file_size - 1) : min(abs(intval($seek_end)),($file_size - 1));
		$seek_start = (empty($seek_start) || $seek_end < abs(intval($seek_start))) ? 0 : max(abs(intval($seek_start)),0);
 
		//Only send partial content header if downloading a piece of the file (IE workaround)
		if ($seek_start > 0 || $seek_end < ($file_size - 1))
		{
			header('HTTP/1.1 206 Partial Content');
			header('Content-Range: bytes '.$seek_start.'-'.$seek_end.'/'.$file_size);
			header('Content-Length: '.($seek_end - $seek_start + 1));
		}
		else
		  header("Content-Length: $file_size");
 
		header('Accept-Ranges: bytes');
 
		set_time_limit(0);
		fseek($file, $seek_start);
 
		while(!feof($file)) 
		{
			print(@fread($file, 1024*8));
			ob_flush();
			flush();
			if (connection_status()!=0) 
			{
				@fclose($file);
				exit;
			}			
		}
 
		// file save was a success
		@fclose($file);
		exit;
	}
	else 
	{
		// file couldn't be opened
		header("HTTP/1.0 500 Internal Server Error");
		exit;
	}
}
else
{
	// file does not exist
	header("HTTP/1.0 404 Not Found");
	exit;
}
?>

You can also download it: 

  php-downloader.zip (2.1 KiB, 5,628 hits)

Armand Niculescu, BEng, MSM, is a 34 year old Art Director at Media Division. and he enjoys working with visual arts for film, web and print.

37 responses to “PHP Download Script with Resume option”

  1. Fred White

    very useful post.thnx!

  2. Marc Dingena

    Hi and thank you very much for sharing your insights. I just compared this with the download script that I created a year ago from various snippets around the internet. It does the job but as you stated earlier, it does contain some misuse of headers etc.

    I’d like to use this code, but I’m not very PHP-keen. I can read and understand it, yet I have trouble writing my own modifications. Since you mentioned the use of the Apache module (X-sendfile i think), I was wondering if you can write an adaptation of the above code for using that module (since I use this on my site). I was wondering if ranges etc are also supported with x-sendfile.

    Last but not least, I try to maintain a download counter when the file is accessed, but I’m assuming that if I use ranges, only the first range should increment the database, and all other ranges should skip this incrementation.

    I was hoping you could post an adaption of the above code using x-sendfile and “doing other stuff when file is requested” placeholder (for me that is a counter, but I’m sure people have countless of other applications).

    Many thanks in advance.

  3. william

    tried copy/paste from the page, file downloads but is corrupted (bad image, bad zip etc).

  4. Sean Bone

    Hello and thank you very much for these good and insightful articles!
    I really do not know much abut using HTTP headers in PHP, but I do agree that one should always strive to use code that is correct as well as working.

    Though I have not managed to try it yet, I cannot help noticing one thing: in the conditional on line 79 of the script you use $size – but it is not declared anywhere… surely you meant $file_size, like it’s used in the rest of the script?

    Thank you very much again!

  5. Shaun

    I got bad image and video file after download completion!! It download full file but corrupted!! As i’m working with downloading large video files, It’s good that it download the whole file! but corrupted, is something disappointing!! by the way nice work!!

  6. Shaun

    :)
    You have done great work and I’m appreciate it! :)
    I tried with small images too.. but it may be server configuration problem, I’ll check for that too!
    Thanks friend!! :)

  7. Hargobind

    I have a few improvements for you.
    http://pastebin.com/Uy8hsGXx

    – Turned off error reporting for Notices — important in your calls to list().
    – Turn off gzip compression which causes browsers to abort the download sometimes.
    – Added ability to specify “stream” in the query string to stream the file contents.
    – Cleaned up Content Type variables because I find switch/case statements very wordy.

  8. Andrej Elias

    Try to add
    ob_clean();
    on line 92… Might solve the corruption problem…

    1. parvej khan

      thanks ,it works.

    2. parvej khan

      The script was great ,but images are corrupted but when i put ob_clean() on line 92 ,it works. Thanks a lot

  9. jazztix

    thanks

  10. LAU

    In the other article, you say “First of all, I notice the use of headers like Content-Description and Content-Transfer-Encoding. There is no such thing in HTTP.”
    But why you use Content-Transfer-Encoding here?
    So confused

  11. JWynn

    Thank you for posting this…it’s very helpful!
    My specific usage is only for ZIP files on a shared Linux server

    I removed the inline option
    and simply forced the “Content-Type”
    I also had to remove the “apache_setenv” line in the provided code or it would crash (again Linux)

    I noticed that using this code…Firefox works wonderfully (Pause and Resume)

    Chrome could be paused, but only resumed if the request was relatively quick. (If I waited a couple of minutes, the file could not be resumed)

    I was unable to Pause the download at all in IE 9x

    (I find this topic very confusing. This solution is still superior to what I was using before, and I hope will help with users suffering from poor connections where a more standard fopen/fread will simply fail. I plan on testing soon.)

  12. Gerard Folkerts

    I needed a download solution for downloading large files (6 Gb) from a IIS server with PHP. Your script is the best I have found so far but failed on such large files. I (finally) found that the PHP filesize function is the problem as explained in http://www.borngeek.com/2011/03/28/php-and-large-file-sizes. For the Windows environment there is a solution by using the filesystem object. The filesize function could than be replaced by:
    $fsobj = new COM(“Scripting.FileSystemObject”);
    $f = $fsobj->GetFile(realpath($file_path));
    $file_size = $f->Size;
    unset($fsobj);

    These lines of code solved my problem with downloading large files.

  13. kavish

    i needed a download solution for downloading a file over #G connection. when we download our content or file using Wifi connection then its successfully downloaded. At the time of 3G connection, it’s failed after 1 Mb download for every file. Can you explain, it’s a problem of application or other????

  14. kavish

    using this script, i download the image then it provide the

    “Could not load image ‘Lighthouse (7).jpg’.
    Error interpreting JPEG image file (Not a JPEG file: starts with 0x3c 0x6c)”

    where i added the content type of jpg and jpeg image..

    1. kavish

      yes, i got a solution for my image issue from your contribution.

  15. Paddy

    I need to be able to have my download links like these:

    http://example.com/download.php?file=html/files/files.zip
    http://example.com/download.php?file=flash/files/files.zip

    As you can see the files are in different directories.
    Is there any way I can do this with your script.

    Thanks

  16. Stefan

    Hi,

    first of all thanks for the script. I just have problems with zipped files. The upload to the server works well and I can unzip all files. If I download the files with your script (and also with my own script) the ZIP file is broken. I have no idea why. Also tested with gzip files.
    zlib.output compression is off. PDF files and text files are working well.

  17. Jaykishan Lathigara

    running gud on localhost but getting error on live site .
    -
    Server error
    The website encountered an error while retrieving. It may be down for maintenance or configured incorrectly.

  18. Jayanta Sarkar

    Sir, you didnt reply my previous query. Please reply this time. Is my query too bad to reply ?

    Query : “When I implemented your code to a live site, server load going too high to handle. Is there any way such that server load not going too high..”

  19. Jon

    Not sure why this script turns off compression, but when I comment out those lines it works fine.

    1. Jon

      Also, for those hosting on go-daddy or similar, change $file_path to:

      $file_path = $_SERVER['DOCUMENT_ROOT'].”/myfiles/” . $file_name;

  20. Nick Hill

    I’m trying to implement this as a wetransfer-style thing which will retrieve a file from an ftp site given a link like oursite.com/download?file=2Iv03Fkm79Yc9. I’m finding that when it’s installed on our web server, each download gets to 63.6MB, and then cuts off. If the file is smaller than this, it completes fine, but if it’s larger, that’s all you get. Downloading more than one file at once results in the group of files being cut off at 63.6MB in total. Oddly, it runs fine on my laptop with WAMP installed – I’ve tested it up to files of 2GB in size – so it seems it’s something to do with the way the server is configured, as in each case it’s trying to get the files from the same ftp server. I’ve been through php.ini and increased limits for default_socket_timeout, max_execution_time and memory_limit, but with no difference in outcome. Any ideas?