Idiot-proof, cross-browser force download in PHP

2019-01-23 20:22发布

问题:

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.

回答1:

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.



回答2:

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.



回答3:

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.



回答4:

print($this->_fullread($stream,1024*16));

I assume _fullread is within a class? If the code looks like the above then $this-> wouldn't work.

Does it output the file contents to the screen if you commented out all of the header stuff?



回答5:

Just a shot in the dark... every header that I send in my 'force download' code (which is not as well tested as yours) is the same as yours, except I call: header("Cache-Control: private",false);

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

I don't know if that will help or not.



回答6:

If you are going to do this sort of "Echo it out with php" method, then you will not be able to show a remaining time, or an expected size to your users. Why? Because if the browser tries to resume your download in the middle, you have no way of handling that case in PHP.

If you have a normal file download, Apache is capable of supporting resumed downloads over HTTP, but in the case a download is paused, Apache has no way of figuring out where in your script things were executing when a client asks for the next chunk.

Essentially, when a browser pauses a download, it will terminate the connection to the webserver entirely. When you resume the download, the connection is reopened, and the request contains a flag saying "Start from byte number X". But to the webserver looking at your PHP above, where does byte X come from?

While in theory it might be possible for the server to identify where to resume your script in the event of an interrupted download, Apache does not attempt to figure out where to resume. As a result, the header sent to the browser states that the server does not support resume, which turns off the expected filesize and time limit items in most major browsers.

EDIT: It seems you might be able to handle this case, but it's going to take a LOT of code on your part. See http://www.php.net/manual/en/function.fread.php#84115 .



回答7:

Instead of trying to hide Your downloadpath from the world make it inaccessible from outside and only access the files with the above script. to do so you put a htaccess file ( a textfile named '.htaccess' don't forget leading dot) in the directory. Contents of the htaccess would be this:

order deny,allow
deny from all
allow from localhost

Now trying to access the path from *world will make the webserver create a 401 forbidden.

Security through obscurity is not what you want.