PHP: Offline Downloads

It is not uncommon to want your site users to be able to download files, but only the specific ones you want them to. To make this possible, you need to store the files somewhere offline, where there isn't a direct URL to them, and you have to be able to insert security. There are, I've found, about a jillion ways to skin this cat, but this one worked for me.

First, let's assume you have a directory structure something like this:

/www
  /includes
  /offlinefiles
  /site

The public website runs out of "/www/site", while your downloads are stored in a parallel directory at "/www/offlinefiles". Because they are not beneath the "/site" directory, there is no direct URL to the files, so anyone wanting to access them will have to go through your application. To enable this, we need a download script.

Let's assume you have a file you've mysteriously named "11132199139" stored in "/www/offlinefiles". You might have a URL to reach it that looks something like this:

http://yoursite.com/down.php?q=2/13/11132199139/somefile.gif

Now, we need a page that will do something with that. We start off just describing what we're attempting to accomplish. I usually put stuff like modification history in here, as well.

/*************************************************************
down.php
Notes:  Manages file downloads such that files can be kept offline and
  security applied to the download.
**************************************************************/

On most of my pages, I have included processes that automatically turn URLs into usable arrays, but I've found that a download script doesn't like includes - they seem to cause problems with the headers - so we'll manually decode that string:

$sQry = $_GET['q'];  // get qry string
$sQry = preg_replace('[<>"]', '', $sQry ); // clean up unsafe qry stuff
$sURL = explode("/", $sQry ); // extract query parameters

Now, I don't know what sorts of parameters you might want to track for your files, but let’s say I need to know "where" they belong and "what" they belong to. Let’s assign our file variables from the area created above.

$a = $sURL[0];  // area
$o = $sURL[1]; // object
$fid = $sURL[2]; // file ID to be download
$filename = $sURL[3]; // file name to be download
$fo = "../offlinefiles/". $fid;  // full path to download file

Here's where we handle security. This will doubltlessly be done differently on your site, but I've found it best to store access information in a session variable, so that I can call it directly from anywhere. Here, we test the particulars of this file against the user's permissions, and decide what sort of access type to allow for this attempt:

if($_SESSION['curraccess']['oid']== $o){$at=$_SESSION['curraccess']['at'];}
// reject attempts to download w\o proper access
if (!$at == "download"){return false;}

Again, how this plays out will depend upon your site set-up, but basically, we're just confirming that the user is allowed to view the file. If not, we return a null and they see essentially nothing happen. No feedback is good feedback for an unauthorized direct-link hacking attempt.

Now, here's the meat. if they know the magic password, we set HTTP headers to push a download to them, using the standard file browser object:

        if(file_exists($fo)){
        // Create the headers used for downloading the file
        header("Content-Transfer-Encoding: binary");
        header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
        header("Content-type: application/octet-stream");
        header("Content-length: ".filesize($fo));
        header("Content-Disposition:attachment; filename=$filename");
        }

The "Content-Transfer-Encoding: binary" allows this to work with, essentially, all file types. The "Content-type: application/octet-stream" keeps your browser from attempting to open the file. "Content-Disposition:attachment; filename=$filename" sets the downloaded files suggested name.

Finally, we break the file into bite-sized chunks to keep from choking the browser, and push it to the user a little bit at a time, until it all gone.

        // open file and print in chunks
        if ($file = fopen($fo, 'rb')) {
                while(!feof($file) and (connection_status()==0)) {
                        print(fread($file, 1024*8));
                        flush();
        }
        fclose($file);
        return((connection_status()==0) and !connection_aborted());
        }else{
        echo "File \"$fo\" cannot be opened";
}
}else{
        echo "File \"$fo\" was not found.";
}

Clean up the garbage, return any error messages, and exit. If it works as planned, the "down.php" page is never displayed to the user - just the download dialogue.

Your mileage may vary, but it works for me.

Tags: