Wednesday, July 14, 2010

PHP Force Download Dialogue Box

Forcing a Download Dialog


A fairly common question is, "How can I force a file to
download to the user?" The answer is, you really cannot.
What you can do is force a download dialog box that gives the
user a choice to open or save a linked file, or to cancel the
request. Picky? Yes, but the distinction needs to be made.
You can't force anything on the user except to force the user
to make a choice.


If you, perchance, stumbled upon this page hoping to find
a way to have a file silently download to the user without
the user's knowledge or approval, this page won't help. With
newer browsers' security measures and anti-virus/spyware programs,
you should not be able to do that. Forcing a download dialog,
on the other hand, is a fairly simple procedure, but it does
require an intermediate file that will send the appropriate
headers.



How Files are Transferred on the Internet


Before I get into that, I'll bore you with just a little
bit of theory. You should understand the way files are requested and viewed
on the web. Let's say you type the URI of this page into your browser's
address bar. The first thing that happens is that your browser looks up
the I/P address of the domain name. Once the browser has that address,
it sends a request to that address for this file. That request is
composed of headers that might look something like this:


GET /phptools/force-download.php HTTP/1.1
Host: apptools.com
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.0; en-US; rv:1.8.1.6)
Gecko/20070725 Firefox/2.0.0.6
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9
,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive

I won't get into all of that, but the noteworthy thing is that the request
is to GET the file /phptools/force-download.php from the host apptools.com.
The server, before sending the actual page, will send response headers that
tell the browser about the result of that request. In this example, those
headers might look similar to this:


HTTP/1.x 200 OK
Date: Thu, 09 Aug 2007 19:16:17 GMT
Server: Apache/1.3.37 (Unix) mod_throttle/3.1.2 DAV/1.0.3
mod_fastcgi/2.4.2 mod_gzip/1.3.26.1a PHP/4.4.7 mod_ssl/2.8.22 OpenSSL/0.9.7e
X-Powered-By: PHP/4.4.7
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html
Content-Encoding: gzip
Content-Length: 6210

This lets the browser know what to expect next. In this case,
the HTTP/1.x 200 OK tells the browser that the
file was found okay and that no errors were encountered. The
browser should expect 6,210 bytes of plain text that is to
be rendered as HTML. It determines that from the Content-Length
and Content-Type headers.



As the browser parses the HTML content it receives, it may
encounter the need to request additional files like style sheets,
external JavaScript files, images, etc. Each time it finds
one of those, it sends an additional request comprised of a
set of headers and the server will send back a set of headers
before sending the file. Displaying a simple page typically
consists of several requests and responses.


One of the more important headers is the Content-Type header.
If the browser knows what to do with the type of content it
is to receive, it does it. For example, with text/html, the
browser renders the page in its viewport. If it's an image,
it renders that. If, on the other hand, the browser encounters
a type of content that it doesn't know how to handle, it displays
the aforementioned dialog box, asking the user what to do with
this file.


How to Send Headers


Okay, boring stuff over. Servers are configured to send appropriate
headers automatically for file types that it recognizes.
These are referred to as Internet media types or sometimes
called MIME types, from their origins in Internet mail
specifications. The MIME acronym stands for Multipurpose
Internet Mail Extensions. One method of doing this involves
reconfiguring the server to send a header that will force
the dialog. However, you may not want all files of a given
type to download.


The way around this is to use an intermediate file with the
appropriate PHP code to send the headers and then send the
file. You use, appropriately enough, the header() function
to do that. One thing to remember is that you cannot send any headers
after any non-header content has been sent to the browser.
That means that you can have nothing else in the file, including
blank lines, that goes to the browser before your call to the
header function.



How to Do It Correctly


If you look around, you may find some people suggesting to
change the MIME (Content-Type) to application/octet-stream.
Of course this will always force the download dialog, but it
will display the wrong information about the file in that dialog.
It works, but it's a bad practice. The preferred method, and
one that will display correct information about the file is
to add an additional header, specifying Content-Disposition:
attachment
. By doing that, you can still send the correct
content type header and tell the browser to offer a download
of the requested file. An important point to remember is that
the server will automatically send back a set of headers for
this intermediate file. Your intermediate file must send all the
headers appropriate to the type of file you want users to download.
A simple example might look like this:


<?php
header('Content-Type: image/jpeg');
header('Content-Length: 1234);
header('Content-Disposition: attachment;filename="test.jpg"');
$fp=fopen('an_image.jpg','r');
fpassthru($fp);
fclose($fp);
?>

This code sends the correct content type for a JPEG image,
sends the file size then tells the browser to offer a download
dialog with the suggested name "test.jpg". It then
opens the file, sends the content to the browser and closes
the file. You may notice that the file name we read is different
from the file name suggested to the user for the file. That
was done only to illustrate that they need not be the same.
They can, in fact, be the same. It makes no difference. Normally,
if you link to a .jpg image, the browser will simply display
that image. Using the above, the browser will display the desired
dialog asking the user what to do with the file.


Now the above is fine if you have only one specific file
you want users to download, you know the file will always
be available, you know the size of that file and you know
none of that will change. Suppose you don't meet all of
that criteria. Let's not reinvent the wheel every time
we want the user to be able to download a file. Let's write
a script that's a bit more versatile. Let's have a PHP
script that accepts a parameter that tells it which file
to send to the user.



Let's begin by saying that there are hazards inherent with
this type of script. You have to have some error checking
in place to prevent someone from gaining access to a file
they should not get. With that thought in mind, let's start
out with this:


<?php
// this is a relative path from this file to the
// directory where the download files are stored.
$path='files';

// first, we'll build an array of files that are legal to download
chdir($path);
$files=glob('*.*');

// next we'll build an array of commonly used content types
$mime_types=array();
$mime_types['ai'] ='application/postscript';
$mime_types['asx'] ='video/x-ms-asf';
$mime_types['au'] ='audio/basic';
$mime_types['avi'] ='video/x-msvideo';
$mime_types['bmp'] ='image/bmp';
$mime_types['css'] ='text/css';
$mime_types['doc'] ='application/msword';
$mime_types['eps'] ='application/postscript';
$mime_types['exe'] ='application/octet-stream';
$mime_types['gif'] ='image/gif';
$mime_types['htm'] ='text/html';
$mime_types['html'] ='text/html';
$mime_types['ico'] ='image/x-icon';
$mime_types['jpe'] ='image/jpeg';
$mime_types['jpeg'] ='image/jpeg';
$mime_types['jpg'] ='image/jpeg';
$mime_types['js'] ='application/x-javascript';
$mime_types['mid'] ='audio/mid';
$mime_types['mov'] ='video/quicktime';
$mime_types['mp3'] ='audio/mpeg';
$mime_types['mpeg'] ='video/mpeg';
$mime_types['mpg'] ='video/mpeg';
$mime_types['pdf'] ='application/pdf';
$mime_types['pps'] ='application/vnd.ms-powerpoint';
$mime_types['ppt'] ='application/vnd.ms-powerpoint';
$mime_types['ps'] ='application/postscript';
$mime_types['pub'] ='application/x-mspublisher';
$mime_types['qt'] ='video/quicktime';
$mime_types['rtf'] ='application/rtf';
$mime_types['svg'] ='image/svg+xml';
$mime_types['swf'] ='application/x-shockwave-flash';
$mime_types['tif'] ='image/tiff';
$mime_types['tiff'] ='image/tiff';
$mime_types['txt'] ='text/plain';
$mime_types['wav'] ='audio/x-wav';
$mime_types['wmf'] ='application/x-msmetafile';
$mime_types['xls'] ='application/vnd.ms-excel';
$mime_types['zip'] ='application/zip';

// did we get a parameter telling us what file to download?
if(!$_GET['file']){
// if not, create an error message
$error='No file specified to download';
}elseif(!in_array($_GET['file'],$files)){
// if the file requested is not in our array of legal
// downloads, create an error for that
$error='Requested file is not available';
}else{
// otherwise, get the file name and its extension
$file=$_GET['file'];
$ext=strtolower(substr(strrchr($file,'.'),1));
}
// did we get the extension and is it in our array of content types?
if($ext && array_key_exists($ext,$mime_types)){
// if so, grab the content type
$mime=$mime_types[$ext];
}else{
// otherwise, create an error for that
$error=$error?$error:"Invalid MIME type";
}

// if we didn't get any errors above
if(!$error){
// if the file exists
if(file_exists("$file")){
// and the file is readable
if(is_readable("$file")){
// get the file size
$size=filesize("$file");
// open the file for reading
if($fp=@fopen("$file",'r')){
// send the headers
header("Content-type: $mime");
header("Content-Length: $size");
header("Content-Disposition: attachment; filename=\"$file\"");
// send the file content
fpassthru($fp);
// close the file
fclose($fp);
// and quit
exit;
}
}else{ // file is not readable
$error='Cannot read file';
}
}else{ // the file does not exist
$error='File not found';
}
}
// if all went well, the exit above will prevent anything below from showing
// otherwise, we'll display an error message we created above
?>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
"http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;
charset=iso-8859-1">
<title>Image Download</title>

</head>
<body>
<h1>Download Failed</h1>
<?php
if($error) print "<p>The error message is: $error</p>\n";
?>
</body>
</html>


Okay, that should do it. The script first defines a variable
to hold the path where the files are stored. Then fills
an array with the file names of those files in that directory.
Next, it creates an array of file extensions and their
corresponding content types. Next, it checks to see if
it got a parameter telling it what file to download. If
not, it generates an error. Next, it checks to see of a
received parameter matches any of the files in that directory.
If not, it generates an error. If everything is okay so
far, it gets the file extension of the requested file and
then checks to see if it has a content type to match it.
If everything is still alright, it get the file size, opens
the file. If that's successful, it sends the needed headers
of Content-Type, Content-Length and Content-Disposition,
then uses the PHP fpassthru function to send the file to
the browser. It then closes the file and exits. If anything
has gone wrong throughout the process, it sends the error
message to the browser instead. It's all pretty simple,
really.


By the way, if you need a content type that's not included
above, W3Schools has a pretty good list
of MIME types
.


Now, let's take it a step
further. We're going to create a directory called downloads.
Save the above code in that directory as index.php. Create
a subdirectory under that downloads directory called files.
Anything you drop in that files directory can be downloaded
by simply linking to downloads/filename. Here is an example
to download an Adobe Acrobat file named HelloWorld.pdf.
The link, in this case looks like downloads/index.php?file=HelloWorld.pdf.
As long as the file is in that downloads/files directory
and the file type is in our array of file types, it will
work. Here is a similar link to a .jpg image: downloads/index.php?file=image024.jpg.


Now, as slick as the above is, it's still just the slightest
bit messy. It still has those nasty url parameters in the
link. If you can use a .htaccess file on the server, you can
make it even slicker. The following .htaccess file in the
downloads directory will take care of that:



RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule (.*) index.php?file=$1 [PT,L,QSA]

What this .htaccess file does is tells the server that if
the requested file is not a regular file and it's not a
directory, to append the requested file name as a parameter
named "file" to the index.php file. So it will take
that request for HelloWorld.pdf and change it to index.php?file=HelloWorld.pdf
like we had above.


Now
look what we can do. In this case, we're going to link
to downloads/HelloWorld.pdf.
The .htaccess transparently changes that to downloads/index.php?file=HelloWorld.pdf..
It looks just like any other link, but still generates
the download dialog. Here is the .jpg image link downloads/image024.jpg.


Okay, I've rambled on long enough. Have fun with it!