PHP File Downloads
PHP can be used to securely control access to file downloads. This tutorial will show how you can send file through a PHP script and limit the download rate. The function we will write accepts the path to the file to send and optionally a rate in kB/s to limit the transfer speed. The function should also be able to handle range headers from clients that allow stopping and resuming downloads.
Sending Files
First, we will set up our function:
1 2 3 4 5 6 7 8 9 10 11 | <?php /* send_file( string $file [, int $rate ] ) param $file - Path to the file to send param $rate - Speed limit of download in kB/s */ function send_file($file, $rate = 0) { // Send the file } ?> |
The first part of the functions needs to make sure the file exists before continuing:
10 11 12 13 14 | // Check if the file exists if (!is_file($file)) { die('404 File Not Found'); } |
If the file does not exists, the script exits with an error. You can replace die with any other error handling methods.
Now let's collect some important info about the file:
16 17 18 19 | // Get the filename, extension, and size $filename = basename($file); $file_extension = strtolower(substr(strrchr($filename, '.'), 1)); $size = filesize($file); |
Here, we found the file name, extension and size, which will be useful later.
We should also determine the most accurate MIME type to send based on the extension:
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 | // Set the mime type based on the extension switch($file_extension) { case 'exe': $ctype = 'application/octet-stream'; break; case 'zip': $ctype = 'application/zip'; break; case 'mp3': $ctype = 'audio/mpeg'; break; case 'mpg': $ctype = 'video/mpeg'; break; case 'avi': $ctype = 'video/x-msvideo'; break; // Block access to sensitive file types case 'php': case 'inc': exit; break; default: $ctype='application/force-download'; } |
These are just some common files that may be downloaded, you can add any other types in there. If no matches are found a generic force-download type is used, which does the job just fine. You can also add file types you do not want downloaded before the exit line.
Before we send the file, we need to send the appropriate response headers:
48 49 50 51 52 53 | // Begin writing headers header('Cache-Control: private'); header('Content-Type: ' . $ctype); header('Content-Disposition: attachment; filename=' . $filename); header('Content-Transfer-Encoding: binary'); header('Content-Length: ' . $size); |
These basically tell the browser that we are sending a file, along with the name, type and size.
Now we need to open the file, send it, then close it:
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | // Open the file for reading $fp = fopen($file, 'rb'); // Set up the size of each piece of data we send $block_size = 1024; // Prevent the script from timing out set_time_limit(0); // Start sending the file while(!feof($fp)) { // Output data print(fread($fp, $block_size)); flush(); } // Close the file fclose($fp); |
We send the file in chunks, which will make limiting the speed possible.
Speed Limit
Now let's add the code for the speed limit:
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 | // Open the file for reading $fp = fopen($file, 'rb'); // Set up the size of each piece of data we send $block_size = 1024; if($rate > 0) { // Multiply by rate if specified $block_size *= $rate; } // Prevent the script from timing out set_time_limit(0); // Start sending the file while(!feof($fp)) { // Output data print(fread($fp, $block_size)); flush(); if($rate > 0) { // Wait one second before next block if rate is specified sleep(1); } } // Close the file fclose($fp); |
The limit is accomplished by sending the number of kB specified, waiting 1 second, then sending the next piece.
Here is what the code looks like at this point:
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 | <?php /* send_file( string $file [, int $rate ] ) param $file - Path to the file to send param $rate - Speed limit of download in kB/s */ function send_file($file, $rate = 0) { // Check if the file exists if (!is_file($file)) { die('404 File Not Found'); } // Get the filename, extension, and size $filename = basename($file); $file_extension = strtolower(substr(strrchr($filename, '.'), 1)); $size = filesize($file); // Set the mime type based on the extension switch($file_extension) { case 'exe': $ctype = 'application/octet-stream'; break; case 'zip': $ctype = 'application/zip'; break; case 'mp3': $ctype = 'audio/mpeg'; break; case 'mpg': $ctype = 'video/mpeg'; break; case 'avi': $ctype = 'video/x-msvideo'; break; // Block access to sensitive file types case 'php': case 'inc': exit; break; default: $ctype='application/force-download'; } // Begin writing headers header('Cache-Control: private'); header('Content-Type: ' . $ctype); header('Content-Disposition: attachment; filename=' . $filename); header('Content-Transfer-Encoding: binary'); header('Content-Length: ' . $size); // Open the file for reading $fp = fopen($file, 'rb'); // Set up the size of each piece of data we send $block_size = 1024; if($rate > 0) { // Multiply by rate if specified $block_size *= $rate; } // Prevent the script from timing out set_time_limit(0); // Start sending the file while(!feof($fp)) { // Output data print(fread($fp, $block_size)); flush(); if($rate > 0) { // Wait one second before next block if rate is specified sleep(1); } } // Close the file fclose($fp); } ?> |
Download Ranges
Now we need to add support for download managers that allow resuming partial downloads.
First we need to tell the client that we accept ranges:
53 | header('Accept-Ranges: bytes'); |
We also removed the Content-Length header for now since it might change.
After we open the file, we need to check for the HTTP_RANGE request header and figure out where to start the download:
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 | // Check if http_range was sent by client if(isset($_SERVER['HTTP_RANGE'])) { // If so, calculate the range to use $seek_range = substr($_SERVER['HTTP_RANGE'], 6); $range = explode('-', $seek_range); if($range[0] > 0){ $seek_start = intval($range[0]); } if($range[1] > 0){ $seek_end = intval($range[1]); } // Seek to the requested position in the file fseek($fp, $seek_start); // Set the range response headers header('HTTP/1.1 206 Partial Content'); header('Content-Length: ' . ($seek_end - $seek_start + 1)); header(sprintf('Content-Range: bytes %d-%d/%d', $seek_start, $seek_end, $size)); } else { // Set default response headers header('Content-Length: ' . $size); } |
If the range header exists, we parse the value (ex. bytes=100-4564) and seek to the part of the file requested. We also send the appropriate headers, including the new Content-Lenght and Content-Range headers. If a range was not requested, we just send the full Content-Length.
Final Product
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 | <?php /* send_file( string $file [, int $rate ] ) param $file - Path to the file to send param $rate - Speed limit of download in kB/s */ function send_file($file, $rate = 0) { // Check if the file exists if (!is_file($file)) { die('404 File Not Found'); } // Get the filename, extension, and size $filename = basename($file); $file_extension = strtolower(substr(strrchr($filename, '.'), 1)); $size = filesize($file); // Set the mime type based on the extension switch($file_extension) { case 'exe': $ctype = 'application/octet-stream'; break; case 'zip': $ctype = 'application/zip'; break; case 'mp3': $ctype = 'audio/mpeg'; break; case 'mpg': $ctype = 'video/mpeg'; break; case 'avi': $ctype = 'video/x-msvideo'; break; // Block access to sensitive file types case 'php': case 'inc': exit; break; default: $ctype='application/force-download'; } // Begin writing headers header('Cache-Control: private'); header('Content-Type: ' . $ctype); header('Content-Disposition: attachment; filename=' . $filename); header('Content-Transfer-Encoding: binary'); header('Accept-Ranges: bytes'); // Open the file for reading $fp = fopen($file, 'rb'); // Check if http_range was sent by client if(isset($_SERVER['HTTP_RANGE'])) { // If so, calculate the range to use $seek_range = substr($_SERVER['HTTP_RANGE'], 6); $range = explode('-', $seek_range); if($range[0] > 0){ $seek_start = intval($range[0]); } if($range[1] > 0){ $seek_end = intval($range[1]); } // Seek to the requested position in the file fseek($fp, $seek_start); // Set the range response headers header('HTTP/1.1 206 Partial Content'); header('Content-Length: ' . ($seek_end - $seek_start + 1)); header(sprintf('Content-Range: bytes %d-%d/%d', $seek_start, $seek_end, $size)); } else { // Set default response headers header('Content-Length: ' . $size); } // Set up the size of each piece of data we send $block_size = 1024; if($rate > 0) { // Multiply by rate if specified $block_size *= $rate; } // Prevent the script from timing out set_time_limit(0); // Start sending the file while(!feof($fp)) { // Output data print(fread($fp, $block_size)); flush(); if($rate > 0) { // Wait one second before next block if rate is specified sleep(1); } } // Close the file fclose($fp); } ?> |
This is a fairly simple script, but it is a little raw. If you have any ideas for an improvement or a potential flaw in the function, please comment!

October 4th, 2009 - 22:51
A great article! I suggest moving set_time_limit(0); out of the while() loop.
October 6th, 2009 - 20:28
Good suggestion. I’m not sure why I put that there…
January 8th, 2011 - 13:27
Hello,
The code below works on my localhost to download a file. However, on top of my downloaded file there are 160 lines of HTML tags. How can I eliminate these tags and just get the plain file?
I appreciate your advice.
$fd = basename($csv);
header(“Content-type: application/octet-stream”);
header(“Content-Disposition: attachment; filename=\”".$fd.”\”");
header(“Content-Description: Download”);
readfile($csv);
May 27th, 2011 - 10:44
use ob_end_clean()
and exit() at the end of the file