How to securely control access to file downloads using php

Post Reply
User avatar
Neo
Site Admin
Site Admin
Posts: 2642
Joined: Wed Jul 15, 2009 2:07 am
Location: Colombo

How to securely control access to file downloads using php

Post by Neo » Sun Feb 28, 2010 11:27 pm

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:

Code: Select all

<?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:

Code: Select all

   // 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

Code: Select all

   // 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:

Code: Select all

   // 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:

Code: Select all

   // 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:

Code: Select all

   // 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:

Code: Select all

   // 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:

Code: Select all

<?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:

Code: Select all

   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:

Code: Select all

   // 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

Code: Select all

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

Return to “PHP & MySQL”