I’ve posted a few snippets from the client-side that have touched on a project I’ve been working on recently. This project is an e-commerce based website that takes a payment (via WorldPay) and then generates a download of the product the shopper has just purchased.
Offering paid-for downloads via the web is a tricky proposition. There are lots of security factors that need to be considered. You don’t want your product to be actually on the webserver for example as its possible for someone to stumble across it and download it. On the other hand, you don’t want to make it very difficult for a user to get to the right place to download their product from – that would defeat the whole point.
The product we’re downloading is approaching 60mb in size so we have another issue – that PHP doesn’t handle downloads of big files very well. So we need to find some way of making sure PHP doesn’t choke.
Further complicating this particular solution was the fact that WorldPay doesn’t offer a ‘download’ solution – it only offers solutions for objects that are sent offline (books, CD’s, clothes, whatever) so I was limited in that respect.
The whole process is complicated. The shopper fills out their details (name, billing and shipping address, email, tel etc) on our site. These details first get input to our database and then get sent to WorldPay along with a unique id for this purchase. The shopper then fills out their card details which WorldPay then process. If the transaction is successful then a variable called ‘transStatus’ is set to ‘Y’. If its declined its set to ‘C’. This variable is sent via POST to a callback script which sits on our server (this part of the process is invisible to the shopper). The appropriate markup is generated via this callback script and sent back to the shopper at WorldPay. Part of this generated markup (assuming a successful transaction) includes a link with a variable appended to it (the unique id for this purchase) which points to a script back on our server. When the shopper clicks this link they get returned to our server and the download process starts in earnest.
At this point the script needs to do the following. First, it needs to compare the sent back value to a value in the db and pull out the purchase details that apply to that value. It then needs to generate a unique serial number (quick side note: this is returned to my script via an XML-RPC script that connects to a Java web service one of the developers wrote). It also needs to create a PDF invoice (quick side note no. 2: I used the rather marvelous R & OS PDF Class to generate the PDF).
What it also needs to do is create a new directory, copy a file from an old directory to this newly created directory and then send the newly created URI to the user. As a point of interest, you should note that the file that is being copied is _not_ the downloadable file. As this is over 60mb in size it makes no sense to copy this across for every shopper. Instead we copy across a very small file that triggers a download of the downloadable product file. OK so first of all I uploaded the downloadable file _and_ the file to be copied into a directory _above_ the web root, thus ensuring they can’t be browsed to. I then set about creating the PHP to store the download script:
$oldPath = "/home/somedir/"; $filename = "myscript.php";
So, first we set two variables; the path to the directory above the web root that contains the download script and the name of the download script itself.
if (file_exists($oldPath . $filename)){
$mdy = date("mdy");
$hms = date("His");
$rightnow = $mdy.$hms;
$dir = md5($rightnow);
Next we open a check to ensure the file exists,r eferencing the two variables we set up first. If the file _does_ exist then we create a fairly random string of letters and numbers by taking the current date, current time, appending them together (no spaces or punctuation marks) and then hashing them using the md5() function. This pretty randomised string of letters and numbers will be the name of the directory we’re going to create.
$newDir = "/home/somedir/public_html/dowmloads/" . $dir; mkdir($newDir, 0777);
So here we actually create the new directory – first we use the new name and append it to the location we want the new directory to live in and then we do the actual creation using mkdir(). Note that we set the access rights to this new directory in the second parameter of the function.
$newLoc = $newDir . "/" . $filename;
$oldLoc = $oldPath . $filename;
copy ($oldLoc, $newLoc) or die ("Could not copy file");
$uri = "http://www.blah.co.uk/downloads/" . $dir . "/" . $filename;
echo $uri;
Next we create two variables which contain a string referencing the new and old locations (i.e. where the file to copy exists now and where we want it to be copied to) and we then go ahead and actually copy the file over.
After that its a simple case of building the URI from a string and then doing what you want with it. Your original file (myscript.php) will now have been copied into the newly created directory. Just put an ‘else’ clause in to tidy up and the code in full is:
$oldPath = "/home/somedir/";
$filename = "myscript.php";
if (file_exists($oldPath . $filename)){
$mdy = date("mdy");
$hms = date("His");
$rightnow = $mdy.$hms;
$dir = md5($rightnow);
$newDir = "/home/somedir/public_html/dowmloads/" . $dir;
mkdir($newDir, 0777);
$newLoc = $newDir . "/" . $filename;
$oldLoc = $oldPath . $filename;
copy ($oldLoc, $newLoc) or die ("Could not copy file");
$uri = "http://www.blah.co.uk/downloads/" . $dir . "/" . $filename;
echo $uri;
} else {
echo "Uh-oh, it went bad.";
}
So what about the file ‘myscript.php’? What does this do? Well, its there to actually grab the file that needs to be downloaded. Using lots of small files instead of lots of large ones makes a lot of sense. It also means we can script solutions for large files.
$filename = "/home/somedir/myprogram.exe";
$filename = realpath($filename);
if (!file_exists($filename)) {
die("NO FILE HERE");
}
header("Pragma: public");
header("Expires: 0");
header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
header("Cache-Control: private",false);
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename="".basename($filename)."";");
header("Content-Transfer-Encoding: binary");
header("Content-Length: ".@filesize($filename));
set_time_limit(0);
@readfile("$filename") or die("File not found.");
The first two lines are straightforward. First create a reference to where the file to be downloaded resides. then use realpath() to get the absolute path. Now, after that we perform a simple check to ensure that the file is there. if not, error out. If yes, then proceed.
The interesting bits of this script are the Header declarations. These are necessary for various thigns to make sure the download is fetched properly.
header("Pragma: public") works with header("Content-Disposition") as a known issue with IE6 is that the filename attribute of Content-Disposition is not always recognised. Using Pragma: public resolves this.
Cache-Control: must-revalidate and Cache-Control: private, falseensures the file is never cached.
Content-Type: application/octet-stream is the content type for application files and Content-Transfer-Encoding: binary ensures the correct transfer type is used (binary in this case).
The all-important line here is set_time_limit(0);. The time limit refers to the amount of seconds that PHP allows a script to execute. The default is 30 seconds which is obviously no good for a 60mb file. By setting the time limit to zero we set no upper limit on script execution time.
Lastly, we use the readfile() function to actually start the download off. Job done.
Seem interesting, I have a few questions
Do you actually need to create a real directory, could you not fake one with .htaccess?
Also what happens if the download is corrupt, can I re-download the file?
You could indeed fake ones and in a lot of instances that would be preferrable but (and this ties into your second point) we needed actual directories as the download needed to be present for a week – we have a cronjob set up that deletes the directories after that time.