Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Idiot-proof, cross-browser force download in PHP

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.

like image 401
Adam Kiss Avatar asked Feb 08 '10 16:02

Adam Kiss


3 Answers

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.

like image 80
Adam Kiss Avatar answered Nov 17 '22 22:11

Adam Kiss


Okay, this is an old question and Adam already accepted his own answer, so presumably he got this working for himself, but he didn't explain why it worked. One thing the I noticed was in the question he used the headers:

header("Pragma: public");
header("Cache-Control: public",FALSE);

Whereas in the solution he used:

header("Cache-control: private");
header('Pragma: private');

He didn't explain why he changed these but I suspect it relates to the use of SSL. I recently solved a similar problem in software that needs to enable download over both HTTP and HTTPS, using the following to add the correct header:

if(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) {
    header("Cache-control: private");
    header('Pragma: private');
} else {
    header('Pragma: public');
}

Hopefully someone will find the information in this answer a useful addition to the above.

like image 32
AntonChanning Avatar answered Nov 17 '22 22:11

AntonChanning


There's one thing I find weird: You are calling ob_end_flush() at the start of the function. This actually cleans the output buffer, but it also outputs everything to the client first (I assume including Content-Headers set by CodeIgniter). Change the call to ob_end_clean(), it clears the buffer and discards it. This will give you a clean start for generating your own headers.

Another tip:

Instead of reading the file as a stream and passing it on block-wise, you could give this function a try:

// ...
if (file_exists("dir-with-files/$filename")) {
   readfile($file);
}

This takes care of nearly everything.

like image 1
Dan Soap Avatar answered Nov 17 '22 21:11

Dan Soap