UltraMega Blog
29Sep/094

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);
}
?>

See an example

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!

Posted by Steve

Comments (4) Trackbacks (0)
  1. A great article! I suggest moving set_time_limit(0); out of the while() loop.

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

  3. use ob_end_clean()

    and exit() at the end of the file


Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

No trackbacks yet.