Idiot-proof, cross-browser force download in PHP

Adam Kiss picture Adam Kiss · Feb 8, 2010 · Viewed 9k times · Source

I'm using forced download to download mostly zips and mp3s on site i did (http://pr1pad.kissyour.net) - to track downloads in google analytics, in database and to hide real download path:

It's this:

extending CI model

... - bunch of code

function _fullread ($sd, $len) {
 $ret = '';
 $read = 0;
 while ($read < $len && ($buf = fread($sd, $len - $read))) {
  $read += strlen($buf);
  $ret .= $buf;
 }
 return $ret;
}

function download(){    
    /* DOWNLOAD ITSELF */

    ini_set('memory_limit', '160M');
    apache_setenv('no-gzip', '1');
    ob_end_flush();

    header("Pragma: public");
    header("Expires: 0");
    header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
    header("Cache-Control: public",FALSE);
    header("Content-Description: File Transfer");
    header("Content-type: application/octet-stream");
     if (isset($_SERVER['HTTP_USER_AGENT']) && 
      (strpos($_SERVER['HTTP_USER_AGENT'], 'MSIE') !== false))
      header('Content-Type: application/force-download'); //IE HEADER
    header("Accept-Ranges: bytes");
    header("Content-Disposition: attachment; filename=\"" . basename("dir-with-    files/".$filename) . "\";");
    header("Content-Transfer-Encoding: binary");
    header("Content-Length: " . filesize("dir-with-files/".$filename));

    // Send file for download
    if ($stream = fopen("dir-with-files/$filename", 'rb')){
     while(!feof($stream) && connection_status() == 0){
      //reset time limit for big files
      set_time_limit(0);
      print($this->_fullread($stream,1024*16));
      flush();
     }
     fclose($stream);
    }
}

It's on LAMP with CI 1.7.2 - It's my own method put together from various how-tos all over the internet, because during developement, these problems came up: - server limit. ini_set haven't helped, so I used buffered _fullread instead normal fread, which was used insted of @readonly - ob_end_flush(), because site is did in CI1.7.2 and i needed to clean buffer

Now... It doesn't work. It did, then it stopped showing expected size/download time - I tried to clean it up and while I was cleaning up the code, something happened, I don't know what and in any previous version - it haven't worked (no change in settings whatsoever) - edit: don't work = outputs everything into browser window.

So I said, screw it, I'll look here.

So, I basically look for script or function, which i can put to my output model and will do:

  • Call force-download (in Chrome start download, in IE,FF,Safari open the modal open/save/cancel)
  • Show size of file and estimated dl time (that's up to browser, i know, but first, browser must know filesize
  • WORK (tested & confirmed!) in IE6,7,8, FF3, Opera, Chrome & and safari on PC + Mac (Linux... I don't really care) - that's for header part
  • on server, I have also something like 56MB memory limit, which i can't add to, so that's also important

Thank you in advance.

Edit: Now I feel more screwed then ever/before, since I tried to force download with .htaccess - while it worked, it had few minor/major (pick yours) problems

  • it showed full path (minor for me)
  • it waits until whole download is finished (showing as "connecting") and then just show it's downloading - and downloads in one second (major for me)

Now, although I deleted .htaccess, it still waits until download is complete (just as if it was downloading to cache first) and it just get's connected and show open/save dialog.

Answer

Adam Kiss picture Adam Kiss · Feb 10, 2010

So, I used this code (It's modified version of resumable http download found on internet)

function _output_file($file, $path)
{
    $size = filesize($path.$file);

    @ob_end_clean(); //turn off output buffering to decrease cpu usage

    // required for IE, otherwise Content-Disposition may be ignored
    if(ini_get('zlib.output_compression'))
    ini_set('zlib.output_compression', 'Off');

    header('Content-Type: application/force-download');
    header('Content-Disposition: attachment; filename="'.basename($file).'"');
    header("Content-Transfer-Encoding: binary");
    header('Accept-Ranges: bytes');

    /* The three lines below basically make the 
    download non-cacheable */
    header("Cache-control: no-cache, pre-check=0, post-check=0");
    header("Cache-control: private");
    header('Pragma: private');
    header("Expires: Mon, 26 Jul 1997 05:00:00 GMT");

    // multipart-download and download resuming support
    if(isset($_SERVER['HTTP_RANGE']))
    {
        list($a, $range) = explode("=",$_SERVER['HTTP_RANGE'],2);
        list($range) = explode(",",$range,2);
        list($range, $range_end) = explode("-", $range);
        $range=intval($range);
        if(!$range_end) {
            $range_end=$size-1;
        } else {
            $range_end=intval($range_end);
        }

        $new_length = $range_end-$range+1;
        header("HTTP/1.1 206 Partial Content");
        header("Content-Length: $new_length");
        header("Content-Range: bytes $range-$range_end/$size");
    } else {
        $new_length=$size;
        header("Content-Length: ".$size);
    }

    /* output the file itself */
    $chunksize = 1*(1024*1024); //you may want to change this
    $bytes_send = 0;
    if ($file = fopen($path.$file, 'rb'))
    {
        if(isset($_SERVER['HTTP_RANGE']))
        fseek($file, $range);

        while
            (!feof($file) && 
             (!connection_aborted()) && 
             ($bytes_send<$new_length) )
        {
            $buffer = fread($file, $chunksize);
            print($buffer); //echo($buffer); // is also possible
            flush();
            $bytes_send += strlen($buffer);
        }
    fclose($file);
    } else die('Error - can not open file.');

die();
}

and then in model:

function download_file($filename){
    /*
        DOWNLOAD
    */
    $path = "datadirwithmyfiles/"; //directory

    //track analytics

    include('includes/Galvanize.php'); //great plugin
    $GA = new Galvanize('UA-XXXXXXX-7');
    $GA->trackPageView();

    $this->_output_file($filename, $path);

}

It works as expected in all mentiond browser on Win / MAC - so far, no problems with it.