PHP Download Script with Resume option

Share on facebook
Share on twitter
Share on linkedin
Share on reddit

A 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. <!--?php 
  2. /**
  3.  * Copyright 2012 Armand Niculescu - media-division.com
  4.  * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
  5.  * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
  6.  * 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.
  7.  * 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.
  8.  */
  9. // get the file request, throw error if nothing supplied
  10.  
  11. // hide notices
  12. @ini_set('error_reporting', E_ALL & ~ E_NOTICE);
  13.  
  14. //- turn off compression on the server
  15. @apache_setenv('no-gzip', 1);
  16. @ini_set('zlib.output_compression', 'Off');
  17.  
  18. if(!isset($_REQUEST['file']) || empty($_REQUEST['file'])) 
  19. {
  20. 	header("HTTP/1.0 400 Bad Request");
  21. 	exit;
  22. }
  23.  
  24. // sanitize the file request, keep just the name and extension
  25. // also, replaces the file location with a preset one ('./myfiles/' in this example)
  26. $file_path  = $_REQUEST['file'];
  27. $path_parts = pathinfo($file_path);
  28. $file_name  = $path_parts['basename'];
  29. $file_ext   = $path_parts['extension'];
  30. $file_path  = './myfiles/' . $file_name;
  31.  
  32. // allow a file to be streamed instead of sent as an attachment
  33. $is_attachment = isset($_REQUEST['stream']) ? false : true;
  34.  
  35. // make sure the file exists
  36. if (is_file($file_path))
  37. {
  38. 	$file_size  = filesize($file_path);
  39. 	$file = @fopen($file_path,"rb");
  40. 	if ($file)
  41. 	{
  42. 		// set the headers, prevent caching
  43. 		header("Pragma: public");
  44. 		header("Expires: -1");
  45. 		header("Cache-Control: public, must-revalidate, post-check=0, pre-check=0");
  46. 		header("Content-Disposition: attachment; filename=\"$file_name\"");
  47.  
  48.         // set appropriate headers for attachment or streamed file
  49.         if ($is_attachment)
  50.                 header("Content-Disposition: attachment; filename=\"$file_name\"");
  51.         else
  52.                 header('Content-Disposition: inline;');
  53.  
  54.         // set the mime type based on extension, add yours if needed.
  55.         $ctype_default = "application/octet-stream";
  56.         $content_types = array(
  57.                 "exe" =--> "application/octet-stream",
  58.                 "zip" => "application/zip",
  59.                 "mp3" => "audio/mpeg",
  60.                 "mpg" => "video/mpeg",
  61.                 "avi" => "video/x-msvideo",
  62.         );
  63.         $ctype = isset($content_types[$file_ext]) ? $content_types[$file_ext] : $ctype_default;
  64.         header("Content-Type: " . $ctype);
  65.  
  66. 		//check if http_range is sent by browser (or download manager)
  67. 		if(isset($_SERVER['HTTP_RANGE']))
  68. 		{
  69. 			list($size_unit, $range_orig) = explode('=', $_SERVER['HTTP_RANGE'], 2);
  70. 			if ($size_unit == 'bytes')
  71. 			{
  72. 				//multiple ranges could be specified at the same time, but for simplicity only serve the first range
  73. 				//http://tools.ietf.org/id/draft-ietf-http-range-retrieval-00.txt
  74. 				list($range, $extra_ranges) = explode(',', $range_orig, 2);
  75. 			}
  76. 			else
  77. 			{
  78. 				$range = '';
  79. 				header('HTTP/1.1 416 Requested Range Not Satisfiable');
  80. 				exit;
  81. 			}
  82. 		}
  83. 		else
  84. 		{
  85. 			$range = '';
  86. 		}
  87.  
  88. 		//figure out download piece from range (if set)
  89. 		list($seek_start, $seek_end) = explode('-', $range, 2);
  90.  
  91. 		//set start and end based on range (if set), else set defaults
  92. 		//also check for invalid ranges.
  93. 		$seek_end   = (empty($seek_end)) ? ($file_size - 1) : min(abs(intval($seek_end)),($file_size - 1));
  94. 		$seek_start = (empty($seek_start) || $seek_end < abs(intval($seek_start))) ? 0 : max(abs(intval($seek_start)),0);
  95.  
  96. 		//Only send partial content header if downloading a piece of the file (IE workaround)
  97. 		if ($seek_start > 0 || $seek_end < ($file_size - 1))
  98. 		{
  99. 			header('HTTP/1.1 206 Partial Content');
  100. 			header('Content-Range: bytes '.$seek_start.'-'.$seek_end.'/'.$file_size);
  101. 			header('Content-Length: '.($seek_end - $seek_start + 1));
  102. 		}
  103. 		else
  104. 		  header("Content-Length: $file_size");
  105.  
  106. 		header('Accept-Ranges: bytes');
  107.  
  108. 		set_time_limit(0);
  109. 		fseek($file, $seek_start);
  110.  
  111. 		while(!feof($file)) 
  112. 		{
  113. 			print(@fread($file, 1024*8));
  114. 			ob_flush();
  115. 			flush();
  116. 			if (connection_status()!=0) 
  117. 			{
  118. 				@fclose($file);
  119. 				exit;
  120. 			}			
  121. 		}
  122.  
  123. 		// file save was a success
  124. 		@fclose($file);
  125. 		exit;
  126. 	}
  127. 	else 
  128. 	{
  129. 		// file couldn't be opened
  130. 		header("HTTP/1.0 500 Internal Server Error");
  131. 		exit;
  132. 	}
  133. }
  134. else
  135. {
  136. 	// file does not exist
  137. 	header("HTTP/1.0 404 Not Found");
  138. 	exit;
  139. }
  140. ?>

You can also download it:  Download PHP File Download Script

Armand Niculescu

Armand Niculescu

As the Senior Project manager, Armand is one of the rare kind of developers that can do both design and programming with equal skill. This, coupled with a solid background and many years of experience, enables him to see the big picture and plan for the small details.

37 Responses

  1. 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.

      1. Armand, hi and thanks for this.

        This is just a warning for users using cut and paste – not a problem with your code!

        I, like William, did the copy/paste route rather than download your zip and hit a similar problem.

        When I pasted into my PHP I failed to get my “” as absolute start and end items on the page – your example is correct. I had to delete some tags that my editor “helpfully” added and managed to leave some extraneous whitespace. My suspicion is that php adds this whitespace into the stream, like it would add normal html content.

        I managed to spot this as I’m using text-based files, but for other file types this will probably be a lot more important.

        Regards

        1. The content within the quotes (“”) in my comment got stripped. I was referring to the php start and end tags – left-angle, question-mark etc. Reading other comments the “ob_clean()” approach i.e. flushing the output buffer before writing content may also be a solution to the same “self-inflicted” problem.

          Interestingly this “user copy error” aligns with your original “right-way” post about copying code and not understanding what is happening (my bad!) – in this case PHP server rendering every character outside the php tags as “page content” – whitespace included.

  2. 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!

    1. You’re so right, I modified my production-ready script a little to make it more readable but I had to manually rename the variables (I need to get myself a PHP editor with refactoring support) and I missed that var. It’s fixed now.

  3. 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!!

    1. Shaun, I haven’t tested with files over 30 Mb, but in my tests the downloaded files are 1:1 identical to the originals. I’m using a very similar script to download apps, after ~7000 downloads I never had any complaint. Can you try downloading a small text file and compare it to the original? I suspect there’s a server configuration issue, I couldn’t test my script in all scenarios (in all honesty I made it to suit my purposes only).

  4. :)
    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!! :)

  5. 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.

    1. Very nice, thanks for your contributions. I mentioned turning off gzip but didn’t code it. I’m used to set all these in php.ini but indeed not everyone has access to it, especially in shared hosting. And yes, content type is handled neater in your version. I will integrate your changes in my code.

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

  6. 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

  7. 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.)

  8. 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 https://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.

  9. 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????

  10. 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. Not with the script as-is. Some changes are required. Personally I prefer to use a database and specify just the file ID. I would NEVER specify the path to the file in the request, it’s a security vulnerability (I have a previous post on that).

  11. 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.

    1. Does this happen in all browsers? Does it happen only with ZIP files? What web server do you use?
      Load a simple PHP file in browser and check its headers (via developer’s tools), see if it’s being delivered compressed.

  12. 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.

  13. 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..”

    1. Sorry, I did not understand your question. Are you saying that the CPU load is too high? That should never happen.

      You have to understand though I cannot possibly think of all OS-WebServer-PHP configurations. PHP behaves differently depending on OS (Debian/RH/Windows), web server (Apache/nginx/IIS) and PHP configuration, plus versioning.

      The code I provided works on 99% configurations, but especially shared hosts like to tinker with PHP configurations – some of them are truly weird and underpowered.

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

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

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

  15. 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?

Comments are closed.