Forcing a Download Dialog

Download dialogA 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!