Source for file Download.php
Documentation is available at Download.php
/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
* @author Michael Wallner <mike@php.net>
* @copyright 2003-2005 Michael Wallner
* @version CVS: $Id: Download.php 304423 2010-10-15 13:36:46Z clockwerx $
* @link http://pear.php.net/package/HTTP_Download
require_once 'HTTP/Header.php';
/**#@+ Use with HTTP_Download::setContentDisposition() **/
* Send data as attachment
define('HTTP_DOWNLOAD_ATTACHMENT', 'attachment');
define('HTTP_DOWNLOAD_INLINE', 'inline');
/**#@+ Use with HTTP_Download::sendArchive() **/
* Send as uncompressed tar archive
define('HTTP_DOWNLOAD_TAR', 'TAR');
* Send as gzipped tar archive
define('HTTP_DOWNLOAD_TGZ', 'TGZ');
* Send as bzip2 compressed tar archive
define('HTTP_DOWNLOAD_BZ2', 'BZ2');
define('HTTP_DOWNLOAD_ZIP', 'ZIP');
define('HTTP_DOWNLOAD_E_HEADERS_SENT', -1 );
define('HTTP_DOWNLOAD_E_NO_EXT_ZLIB', -2 );
define('HTTP_DOWNLOAD_E_NO_EXT_MMAGIC', -3 );
define('HTTP_DOWNLOAD_E_INVALID_FILE', -4 );
define('HTTP_DOWNLOAD_E_INVALID_PARAM', -5 );
define('HTTP_DOWNLOAD_E_INVALID_RESOURCE', -6 );
define('HTTP_DOWNLOAD_E_INVALID_REQUEST', -7 );
define('HTTP_DOWNLOAD_E_INVALID_CONTENT_TYPE', -8 );
define('HTTP_DOWNLOAD_E_INVALID_ARCHIVE_TYPE', -9 );
* Send HTTP Downloads/Responses.
* With this package you can handle (hidden) downloads.
* It supports partial downloads, resuming and sending
* raw data ie. from database BLOBs.
* You shouldn't use this package together with ob_gzhandler or
* zlib.output_compression enabled in your php.ini, especially
* if you want to send already gzipped data!
* @version $Revision: 304423 $
// {{{ protected member variables
* Path to file for download
* @see HTTP_Download::setFile()
* @see HTTP_Download::setData()
* Resource handle for download
* @see HTTP_Download::setResource()
* Whether to gzip the download
* Whether to allow caching of the download on the clients side
'Content-Type' => 'application/x-octetstream',
'Cache-Control' => 'public, must-revalidate, max-age=0',
'Accept-Ranges' => 'bytes',
'X-Sent-By' => 'PEAR::HTTP::Download'
var $bufferSize = 2097152;
* Set supplied parameters.
* @param array $params associative array of parameters
* <strong>one of:</strong>
* <li>'file' => path to file for download</li>
* <li>'data' => raw data for download</li>
* <li>'resource' => resource handle for download</li>
* <strong>and any of:</strong>
* <li>'cache' => whether to allow cs caching</li>
* <li>'gzip' => whether to gzip the download</li>
* <li>'lastmodified' => unix timestamp</li>
* <li>'contenttype' => content type of download</li>
* <li>'contentdisposition' => content disposition</li>
* <li>'buffersize' => amount of bytes to buffer</li>
* <li>'throttledelay' => amount of secs to sleep</li>
* <li>'cachecontrol' => cache privacy and validity</li>
* 'Content-Disposition' is not HTTP compliant, but most browsers
* follow this header, so it was borrowed from MIME standard.
* "Content-Disposition: attachment; filename=example.tgz".
* @see HTTP_Download::setContentDisposition()
function HTTP_Download ($params = array ())
$this->HTTP = &new HTTP_Header;
$this->_error = $this->setParams ($params);
* Set supplied parameters through its accessor methods.
* @return mixed Returns true on success or PEAR_Error on failure.
* @param array $params associative array of parameters
* @see HTTP_Download::HTTP_Download()
function setParams ($params)
$error = $this->_getError ();
foreach((array) $params as $param => $value){
" Method '$method' doesn't exist." ,
* Set path to file for download
* The Last-Modified header will be set to files filemtime(), actually.
* Returns PEAR_Error (HTTP_DOWNLOAD_E_INVALID_FILE) if file doesn't exist.
* Sends HTTP 404 or 403 status if $send_error is set to true.
* @return mixed Returns true on success or PEAR_Error on failure.
* @param string $file path to file for download
* @param bool $send_error whether to send HTTP/404 or 403 if
* the file wasn't found or is not readable
function setFile ($file, $send_error = true )
$error = $this->_getError ();
$this->HTTP->sendStatusCode (404 );
" File '$file' not found." ,
$this->HTTP->sendStatusCode (403 );
" Cannot read file '$file'." ,
* Set $data to null if you want to unset this.
* @param $data raw data to send
function setData ($data = null )
* Set resource for download
* The resource handle supplied will be closed after sending the download.
* Returns a PEAR_Error (HTTP_DOWNLOAD_E_INVALID_RESOURCE) if $handle
* is no valid resource. Set $handle to null if you want to unset this.
* @return mixed Returns true on success or PEAR_Error on failure.
* @param int $handle resource handle
function setResource ($handle = null )
$error = $this->_getError ();
$filestats = fstat($handle);
$this->size = isset ($filestats['size']) ? $filestats['size']
" Handle '$handle' is no valid resource." ,
* Whether to gzip the download
* Returns a PEAR_Error (HTTP_DOWNLOAD_E_NO_EXT_ZLIB)
* if ext/zlib is not available/loadable.
* @return mixed Returns true on success or PEAR_Error on failure.
* @param bool $gzip whether to gzip the download
function setGzip ($gzip = false )
$error = $this->_getError ();
if ($gzip && !PEAR ::loadExtension ('zlib')){
'GZIP compression (ext/zlib) not available.',
$this->gzip = (bool) $gzip;
* Whether to allow caching
* If set to true (default) we'll send some headers that are commonly
* used for caching purposes like ETag, Cache-Control and Last-Modified.
* If caching is disabled, we'll send the download no matter if it
* would actually be cached at the client side.
* @param bool $cache whether to allow caching
function setCache ($cache = true )
$this->cache = (bool) $cache;
* Whether to allow proxies to cache
* If set to 'private' proxies shouldn't cache the response.
* This setting defaults to 'public' and affects only cached responses.
* @param string $cache private or public
* @param int $maxage maximum age of the client cache entry
function setCacheControl ($cache = 'public', $maxage = 0 )
switch ($cache = strToLower ($cache))
$this->headers['Cache-Control'] =
$cache . ', must-revalidate, max-age='. abs($maxage);
* Sets a user-defined ETag for cache-validation. The ETag is usually
* generated by HTTP_Download through its payload information.
* @param string $etag Entity tag used for strong cache validation.
function setETag ($etag = null )
$this->etag = (string) $etag;
* The amount of bytes specified as buffer size is the maximum amount
* of data read at once from resources or files. The default size is 2M
* (2097152 bytes). Be aware that if you enable gzip compression and
* you set a very low buffer size that the actual file size may grow
* due to added gzip headers for each sent chunk of the specified size.
* Returns PEAR_Error (HTTP_DOWNLOAD_E_INVALID_PARAM) if $size is not
* @return mixed Returns true on success or PEAR_Error on failure.
* @param int $bytes Amount of bytes to use as buffer.
function setBufferSize ($bytes = 2097152 )
$error = $this->_getError ();
'Buffer size must be greater than 0 bytes ('. $bytes . ' given)',
$this->bufferSize = abs($bytes);
* Set the amount of seconds to sleep after each chunck that has been
* sent. One can implement some sort of throttle through adjusting the
* buffer size and the throttle delay. With the following settings
* HTTP_Download will sleep a second after each 25 K of data sent.
* 'buffersize' => 1024 * 25,
* Just be aware that if gzipp'ing is enabled, decreasing the chunk size
* too much leads to proportionally increased network traffic due to added
* gzip header and bottom bytes around each chunk.
* @param float $seconds Amount of seconds to sleep after each
* chunk that has been sent.
function setThrottleDelay ($seconds = 0 )
$this->throttleDelay = abs($seconds) * 1000;
* This is usually determined by filemtime() in HTTP_Download::setFile()
* If you set raw data for download with HTTP_Download::setData() and you
* want do send an appropiate "Last-Modified" header, you should call this
* @param int unix timestamp
function setLastModified ($last_modified)
$this->lastModified = $this->headers['Last-Modified'] = (int) $last_modified;
* Set Content-Disposition header
* @see HTTP_Download::HTTP_Download
* @param string $disposition whether to send the download
* inline or as attachment
* @param string $file_name the filename to display in
* the browser's download window
* $HTTP_Download->setContentDisposition(
* HTTP_DOWNLOAD_ATTACHMENT,
function setContentDisposition ( $disposition = HTTP_DOWNLOAD_ATTACHMENT ,
$cd .= '; filename="' . $file_name . '"';
$cd .= '; filename="' . basename($this->file) . '"';
$this->headers['Content-Disposition'] = $cd;
* Set content type of the download
* Default content type of the download will be 'application/x-octetstream'.
* Returns PEAR_Error (HTTP_DOWNLOAD_E_INVALID_CONTENT_TYPE) if
* $content_type doesn't seem to be valid.
* @return mixed Returns true on success or PEAR_Error on failure.
* @param string $content_type content type of file for download
function setContentType ($content_type = 'application/x-octetstream')
$error = $this->_getError ();
if (!preg_match('/^[a-z]+\w*\/[a-z]+[\w.;= -]*$/', $content_type)) {
" Invalid content type '$content_type' supplied." ,
$this->headers['Content-Type'] = $content_type;
* Guess content type of file
* First we try to use PEAR::MIME_Type, if installed, to detect the content
* type, else we check if ext/mime_magic is loaded and properly configured.
* o if PEAR::MIME_Type failed to detect a proper content type
* (HTTP_DOWNLOAD_E_INVALID_CONTENT_TYPE)
* o ext/magic.mime is not installed, or not properly configured
* (HTTP_DOWNLOAD_E_NO_EXT_MMAGIC)
* o mime_content_type() couldn't guess content type or returned
* a content type considered to be bogus by setContentType()
* (HTTP_DOWNLOAD_E_INVALID_CONTENT_TYPE)
* @return mixed Returns true on success or PEAR_Error on failure.
function guessContentType ()
$error = $this->_getError ();
if (class_exists('MIME_Type') || @include_once 'MIME/Type.php') {
if (PEAR ::isError ($mime_type = MIME_Type ::autoDetect ($this->file))) {
return PEAR ::raiseError ($mime_type->getMessage (),
return $this->setContentType ($mime_type);
'This feature requires ext/mime_magic!',
'ext/mime_magic is loaded but not properly configured!',
'Couldn\'t guess content type with mime_content_type().',
return $this->setContentType ($content_type);
* o HTTP headers were already sent (HTTP_DOWNLOAD_E_HEADERS_SENT)
* o HTTP Range was invalid (HTTP_DOWNLOAD_E_INVALID_REQUEST)
* @return mixed Returns true on success or PEAR_Error on failure.
* @param bool $autoSetContentDisposition Whether to set the
* Content-Disposition header if it isn't already.
function send ($autoSetContentDisposition = true )
$error = $this->_getError ();
if ($autoSetContentDisposition &&
!isset ($this->headers['Content-Disposition'])) {
$this->setContentDisposition ();
$this->headers['ETag'] = $this->generateETag ();
$this->HTTP->sendStatusCode (304 );
unset ($this->headers['Last-Modified']);
$end = ($this->size >= 0 ) ? max($this->size - 1 , 0 ) : '*';
if ($end != '*' && $this->isRangeRequest ()) {
$chunks = $this->getChunks ();
$this->HTTP->sendStatusCode (200 );
$chunks = array (array (0 , $end));
} elseif (PEAR ::isError ($chunks)) {
$this->HTTP->sendStatusCode (416 );
$this->HTTP->sendStatusCode (206 );
$this->HTTP->sendStatusCode (200 );
$chunks = array (array (0 , $end));
$this->headers['Content-Length'] = $this->size;
$this->sendChunks ($chunks);
* @see HTTP_Download::HTTP_Download()
* @see HTTP_Download::send()
* @return mixed Returns true on success or PEAR_Error on failure.
* @param array $params associative array of parameters
* @param bool $guess whether HTTP_Download::guessContentType()
function staticSend ($params, $guess = false )
$d = &new HTTP_Download ();
$e = $d->setParams ($params);
$e = $d->guessContentType ();
* Send a bunch of files or directories as an archive
* require_once 'HTTP/Download.php';
* HTTP_Download::sendArchive(
* @see Archive_Tar::createModify()
* @deprecated use HTTP_Download_Archive::send()
* @return mixed Returns true on success or PEAR_Error on failure.
* @param string $name name the sent archive should have
* @param mixed $files files/directories
* @param string $type archive type
* @param string $add_path path that should be prepended to the files
* @param string $strip_path path that should be stripped from the files
function sendArchive ( $name,
$type = HTTP_DOWNLOAD_TGZ ,
require_once 'HTTP/Download/Archive.php';
return HTTP_Download_Archive ::send ($name, $files, $type,
$md5 = md5($mtime . '='. $ino . '='. $size);
$this->etag = '"' . $md5 . '-' . crc32($md5) . '"';
* @return mixed Returns true on success or PEAR_Error on failure.
function sendChunks ($chunks)
if (count($chunks) == 1 ) {
return $this->sendChunk (current($chunks));
$bound = uniqid('HTTP_DOWNLOAD-', true );
$cType = $this->headers['Content-Type'];
$this->headers['Content-Type'] =
'multipart/byteranges; boundary=' . $bound;
foreach ($chunks as $chunk){
$this->sendChunk ($chunk, $cType, $bound);
#echo "\r\n--$bound--\r\n";
* @return mixed Returns true on success or PEAR_Error on failure.
* @param array $chunk start and end offset of the chunk to send
* @param string $cType actual content type
* @param string $bound boundary for multipart/byteranges
function sendChunk ($chunk, $cType = null , $bound = null )
list ($offset, $lastbyte) = $chunk;
$length = ($lastbyte - $offset) + 1;
$range = $offset . '-' . $lastbyte . '/'
. (($this->size >= 0 ) ? $this->size : '*');
if (isset ($cType, $bound)) {
" Content-Type: $cType\r\n" ,
" Content-Range: bytes $range\r\n\r\n";
if ($lastbyte != '*' && $this->isRangeRequest ()) {
$this->headers['Content-Length'] = $length;
$this->headers['Content-Range'] = 'bytes '. $range;
while (($length -= $this->bufferSize) > 0 ) {
$this->flush (substr($this->data, $offset, $this->bufferSize));
$this->throttleDelay and $this->sleep ();
$offset += $this->bufferSize;
$this->flush (substr($this->data, $offset, $this->bufferSize + $length));
$this->handle = fopen($this->file, 'rb');
fseek($this->handle, $offset);
while (!feof($this->handle)) {
$this->flush (fread($this->handle, $this->bufferSize));
$this->throttleDelay and $this->sleep ();
while (($length -= $this->bufferSize) > 0 ) {
$this->flush (fread($this->handle, $this->bufferSize));
$this->throttleDelay and $this->sleep ();
$this->flush (fread($this->handle, $this->bufferSize + $length));
* @return array Chunk list or PEAR_Error on invalid range request
* @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
$end = ($this->size >= 0 ) ? max($this->size - 1 , 0 ) : '*';
// Trying to handle ranges on content with unknown length is too
// big of a mess (impossible to determine if a range is valid)
$ranges = $this->getRanges ();
foreach (explode(',', $ranges) as $chunk){
// If the last-byte-pos value is present, it MUST be greater than
// or equal to the first-byte-pos in that byte-range-spec, or the
// byte- range-spec is syntactically invalid. The recipient of a
// byte-range- set that includes one or more syntactically invalid
// byte-range-spec values MUST ignore the header field that
// includes that byte-range- set.
if ($e !== '' && $o !== '' && $e < $o) {
// If the last-byte-pos value is absent, or if the value is
// greater than or equal to the current length of the entity-body,
// last-byte-pos is taken to be equal to one less than the current
// length of the entity- body in bytes.
if ($e === '' || $e > $end) {
// A suffix-byte-range-spec is used to specify the suffix of the
// entity-body, of a length given by the suffix-length value. (That
// is, this form specifies the last N bytes of an entity-body.) If
// the entity is shorter than the specified suffix-length, the
// entire entity-body is used.
// If a syntactically valid byte-range-set includes at least
// one suffix-byte-range-spec with a non-zero suffix-length,
// then the byte-range-set is satisfiable.
$satisfiable |= ($e != 0 );
$o = max($this->size - $e, 0 );
// If a syntactically valid byte-range-set includes at least
// one byte- range-spec whose first-byte-pos is less than the
// current length of the entity-body, then the byte-range-set
$parts[] = array ($o, $e);
// If the byte-range-set is unsatisfiable, the server SHOULD return a
// response with a status of 416 (Requested range not satisfiable).
$error = PEAR ::raiseError ('Error processing range request',
//$this->sortChunks($parts);
return $this->mergeChunks ($parts);
* Sorts the ranges to be in ascending order
* @param array &$chunks ranges to sort
* @author Philippe Jausions <jausions@php.net>
function sortChunks (&$chunks)
return (($a[1] != "*" && $a[1] < $b[1])
|| $b[1] == "*") ? -1 : 1;
return ($a[0] < $b[0]) ? -1 : 1;');
usort($chunks, $sortFunc);
* Merges consecutive chunks to avoid overlaps
* @param array $chunks Ranges to merge
* @return array merged ranges
* @author Philippe Jausions <jausions@php.net>
function mergeChunks ($chunks)
for ($i = 1; $i < count($chunks); ++ $i) {
list ($o, $e) = $chunks[$i];
if ($merged[$j][1 ] == '*') {
if ($merged[$j][0 ] <= $o) {
} elseif ($e == '*' || $merged[$j][0 ] <= $e) {
$merged[$j][0 ] = min($merged[$j][0 ], $o);
$merged[++ $j] = $chunks[$i];
} elseif ($merged[$j][0 ] <= $o && $o <= $merged[$j][1 ]) {
$merged[$j][1 ] = ($e == '*') ? '*' : max($e, $merged[$j][1 ]);
} elseif ($merged[$j][0 ] <= $e && $e <= $merged[$j][1 ]) {
$merged[$j][0 ] = min($o, $merged[$j][0 ]);
$merged[++ $j] = $chunks[$i];
if ($count == count($merged)) {
* Check if range is requested
function isRangeRequest ()
if (!isset ($_SERVER['HTTP_RANGE']) || !count($this->getRanges ())) {
return $this->isValidRange ();
return preg_match('/^bytes=((\d+-|\d+-\d+|-\d+)(, ?(\d+-|\d+-\d+|-\d+))*)$/',
@$_SERVER['HTTP_RANGE'], $matches) ? $matches[1 ] : array ();
* Check if entity is cached
(isset ($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&
';', $_SERVER['HTTP_IF_MODIFIED_SINCE'])))) ||
(isset ($_SERVER['HTTP_IF_NONE_MATCH']) &&
$this->compareAsterisk ('HTTP_IF_NONE_MATCH', $this->etag))
* Check if entity hasn't changed
if (isset ($_SERVER['HTTP_IF_MATCH']) &&
!$this->compareAsterisk ('HTTP_IF_MATCH', $this->etag)) {
if (isset ($_SERVER['HTTP_IF_RANGE']) &&
$_SERVER['HTTP_IF_RANGE'] !== $this->etag &&
strtotime($_SERVER['HTTP_IF_RANGE']) !== $this->lastModified) {
if (isset ($_SERVER['HTTP_IF_UNMODIFIED_SINCE'])) {
$lm = current($a = explode(';', $_SERVER['HTTP_IF_UNMODIFIED_SINCE']));
if (strtotime($lm) !== $this->lastModified) {
if (isset ($_SERVER['HTTP_UNLESS_MODIFIED_SINCE'])) {
$lm = current($a = explode(';', $_SERVER['HTTP_UNLESS_MODIFIED_SINCE']));
if (strtotime($lm) !== $this->lastModified) {
* Compare against an asterisk or check for equality
* @param string key for the $_SERVER array
* @param string string to compare
function compareAsterisk ($svar, $compare)
if ($request === '*' || $request === $compare) {
foreach ($this->headers as $header => $value) {
$this->HTTP->setHeader ($header, $value);
$this->HTTP->sendHeaders ();
/* NSAPI won't output anything if we did this */
function flush ($data = '')
$this->sentBytes += $dlen;
com_message_pump ($this->throttleDelay);
usleep($this->throttleDelay * 1000 );
* Returns and clears startup error
* @return NULL|PEAR_Errorstartup error if one exists
if (PEAR ::isError ($this->_error)) {
Documentation generated on Fri, 15 Oct 2010 15:00:18 +0000 by phpDocumentor 1.4.3. PEAR Logo Copyright © PHP Group 2004.
|