* @copyright 2003-2005 Michael Wallner * @license BSD, revised * @version CVS: $Id: Download.php 304423 2010-10-15 13:36:46Z clockwerx $ * @link http://pear.php.net/package/HTTP_Download */ // {{{ includes /** * Requires PEAR */ require_once 'PEAR.php'; /** * Requires HTTP_Header */ require_once 'HTTP/Header.php'; // }}} // {{{ constants /**#@+ Use with HTTP_Download::setContentDisposition() **/ /** * Send data as attachment */ define('HTTP_DOWNLOAD_ATTACHMENT', 'attachment'); /** * Send data inline */ 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'); /** * Send as zip archive */ define('HTTP_DOWNLOAD_ZIP', 'ZIP'); /**#@-**/ /**#@+ * Error constants */ 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. * * ATTENTION: * 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! * * @access public * @version $Revision: 304423 $ */ class HTTP_Download { // {{{ protected member variables /** * Path to file for download * * @see HTTP_Download::setFile() * @access protected * @var string */ var $file = ''; /** * Data for download * * @see HTTP_Download::setData() * @access protected * @var string */ var $data = null; /** * Resource handle for download * * @see HTTP_Download::setResource() * @access protected * @var int */ var $handle = null; /** * Whether to gzip the download * * @access protected * @var bool */ var $gzip = false; /** * Whether to allow caching of the download on the clients side * * @access protected * @var bool */ var $cache = true; /** * Size of download * * @access protected * @var int */ var $size = 0; /** * Last modified * * @access protected * @var int */ var $lastModified = 0; /** * HTTP headers * * @access protected * @var array */ var $headers = array( 'Content-Type' => 'application/x-octetstream', 'Pragma' => 'cache', 'Cache-Control' => 'public, must-revalidate, max-age=0', 'Accept-Ranges' => 'bytes', //'X-Sent-By' => 'PEAR::HTTP::Download' ); /** * HTTP_Header * * @access protected * @var object */ var $HTTP = null; /** * ETag * * @access protected * @var string */ var $etag = ''; /** * Buffer Size * * @access protected * @var int */ var $bufferSize = 2097152; /** * Throttle Delay * * @access protected * @var float */ var $throttleDelay = 0; /** * Sent Bytes * * @access public * @var int */ var $sentBytes = 0; /** * Startup error * * @var PEAR_Error * @access protected */ var $_error = null; // }}} // {{{ constructor /** * Constructor * * Set supplied parameters. * * @access public * @param array $params associative array of parameters * one of: *
* Array(
* 'throttledelay' => 1,
* '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.
*
* @access public
* @return void
* @param float $seconds Amount of seconds to sleep after each
* chunk that has been sent.
*/
function setThrottleDelay($seconds = 0)
{
$this->throttleDelay = abs($seconds) * 1000;
}
/**
* Set "Last-Modified"
*
* 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
* method.
*
* @access public
* @return void
* @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
*
* @access public
* @return void
* @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
*
* Example:
*
* $HTTP_Download->setContentDisposition(
* HTTP_DOWNLOAD_ATTACHMENT,
* 'download.tgz'
* );
*
*/
function setContentDisposition( $disposition = HTTP_DOWNLOAD_ATTACHMENT,
$file_name = null)
{
$cd = $disposition;
if (isset($file_name)) {
$cd .= '; filename="' . $file_name . '"';
} elseif ($this->file) {
$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.
*
* @access public
* @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 ($error !== null) {
return $error;
}
if (!preg_match('/^[a-z]+\w*\/[a-z]+[\w.;= -]*$/', $content_type)) {
return PEAR::raiseError(
"Invalid content type '$content_type' supplied.",
HTTP_DOWNLOAD_E_INVALID_CONTENT_TYPE
);
}
$this->headers['Content-Type'] = $content_type;
return true;
}
/**
* 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.
*
* Returns PEAR_Error if:
* 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)
*
* @access public
* @return mixed Returns true on success or PEAR_Error on failure.
*/
function guessContentType()
{
$error = $this->_getError();
if ($error !== null) {
return $error;
}
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(),
HTTP_DOWNLOAD_E_INVALID_CONTENT_TYPE);
}
return $this->setContentType($mime_type);
}
if (!function_exists('mime_content_type')) {
return PEAR::raiseError(
'This feature requires ext/mime_magic!',
HTTP_DOWNLOAD_E_NO_EXT_MMAGIC
);
}
if (!is_file(ini_get('mime_magic.magicfile'))) {
return PEAR::raiseError(
'ext/mime_magic is loaded but not properly configured!',
HTTP_DOWNLOAD_E_NO_EXT_MMAGIC
);
}
if (!$content_type = @mime_content_type($this->file)) {
return PEAR::raiseError(
'Couldn\'t guess content type with mime_content_type().',
HTTP_DOWNLOAD_E_INVALID_CONTENT_TYPE
);
}
return $this->setContentType($content_type);
}
/**
* Send
*
* Returns PEAR_Error if:
* o HTTP headers were already sent (HTTP_DOWNLOAD_E_HEADERS_SENT)
* o HTTP Range was invalid (HTTP_DOWNLOAD_E_INVALID_REQUEST)
*
* @access public
* @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 ($error !== null) {
return $error;
}
if (headers_sent()) {
return PEAR::raiseError(
'Headers already sent.',
HTTP_DOWNLOAD_E_HEADERS_SENT
);
}
if (!ini_get('safe_mode')) {
@set_time_limit(0);
}
if ($autoSetContentDisposition &&
!isset($this->headers['Content-Disposition'])) {
$this->setContentDisposition();
}
if ($this->cache) {
$this->headers['ETag'] = $this->generateETag();
if ($this->isCached()) {
$this->HTTP->sendStatusCode(304);
$this->sendHeaders();
return true;
}
} else {
unset($this->headers['Last-Modified']);
}
if (ob_get_level()) {
while (@ob_end_clean());
}
if ($this->gzip) {
@ob_start('ob_gzhandler');
} else {
ob_start();
}
$this->sentBytes = 0;
// Known content length?
$end = ($this->size >= 0) ? max($this->size - 1, 0) : '*';
if ($end != '*' && $this->isRangeRequest()) {
$chunks = $this->getChunks();
if (empty($chunks)) {
$this->HTTP->sendStatusCode(200);
$chunks = array(array(0, $end));
} elseif (PEAR::isError($chunks)) {
ob_end_clean();
$this->HTTP->sendStatusCode(416);
return $chunks;
} else {
$this->HTTP->sendStatusCode(206);
}
} else {
$this->HTTP->sendStatusCode(200);
$chunks = array(array(0, $end));
if (!$this->gzip && count(ob_list_handlers()) < 2 && $end != '*') {
$this->headers['Content-Length'] = $this->size;
}
}
$this->sendChunks($chunks);
ob_end_flush();
flush();
return true;
}
/**
* Static send
*
* @see HTTP_Download::HTTP_Download()
* @see HTTP_Download::send()
*
* @static
* @access public
* @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()
* should be called
*/
static function staticSend($params, $guess = false)
{
$d = new HTTP_Download();
$e = $d->setParams($params);
if (PEAR::isError($e)) {
return $e;
}
if ($guess) {
$e = $d->guessContentType();
if (PEAR::isError($e)) {
return $e;
}
}
return $d->send();
}
/**
* Send a bunch of files or directories as an archive
*
* Example:
*
* require_once 'HTTP/Download.php';
* HTTP_Download::sendArchive(
* 'myArchive.tgz',
* '/var/ftp/pub/mike',
* HTTP_DOWNLOAD_TGZ,
* '',
* '/var/ftp/pub'
* );
*
*
* @see Archive_Tar::createModify()
* @deprecated use HTTP_Download_Archive::send()
* @static
* @access public
* @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
*/
static function sendArchive( $name,
$files,
$type = HTTP_DOWNLOAD_TGZ,
$add_path = '',
$strip_path = '')
{
require_once 'HTTP/Download/Archive.php';
return HTTP_Download_Archive::send($name, $files, $type,
$add_path, $strip_path);
}
// }}}
// {{{ protected methods
/**
* Generate ETag
*
* @access protected
* @return string
*/
function generateETag()
{
if (!$this->etag) {
if ($this->data) {
$md5 = md5($this->data);
} else {
$mtime = time();
$ino = 0;
$size = mt_rand();
extract(is_resource($this->handle) ? fstat($this->handle)
: stat($this->file));
$md5 = md5($mtime .'='. $ino .'='. $size);
}
$this->etag = '"' . $md5 . '-' . crc32($md5) . '"';
}
return $this->etag;
}
/**
* Send multiple chunks
*
* @access protected
* @return mixed Returns true on success or PEAR_Error on failure.
* @param array $chunks
*/
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;
$this->sendHeaders();
foreach ($chunks as $chunk){
$this->sendChunk($chunk, $cType, $bound);
}
#echo "\r\n--$bound--\r\n";
return true;
}
/**
* Send chunk of data
*
* @access protected
* @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)) {
echo "\r\n--$bound\r\n",
"Content-Type: $cType\r\n",
"Content-Range: bytes $range\r\n\r\n";
} else {
if ($lastbyte != '*' && $this->isRangeRequest()) {
$this->headers['Content-Length'] = $length;
$this->headers['Content-Range'] = 'bytes '. $range;
}
$this->sendHeaders();
}
if ($this->data) {
while (($length -= $this->bufferSize) > 0) {
$this->flush(substr($this->data, $offset, $this->bufferSize));
$this->throttleDelay and $this->sleep();
$offset += $this->bufferSize;
}
if ($length) {
$this->flush(substr($this->data, $offset, $this->bufferSize + $length));
}
} else {
if (!is_resource($this->handle)) {
$this->handle = fopen($this->file, 'rb');
}
fseek($this->handle, $offset);
if ($lastbyte == '*') {
while (!feof($this->handle)) {
$this->flush(fread($this->handle, $this->bufferSize));
$this->throttleDelay and $this->sleep();
}
} else {
while (($length -= $this->bufferSize) > 0) {
$this->flush(fread($this->handle, $this->bufferSize));
$this->throttleDelay and $this->sleep();
}
if ($length) {
$this->flush(fread($this->handle, $this->bufferSize + $length));
}
}
}
return true;
}
/**
* Get chunks to send
*
* @access protected
* @return array Chunk list or PEAR_Error on invalid range request
* @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
*/
function getChunks()
{
$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)
if ($end == '*') {
return array();
}
$ranges = $this->getRanges();
if (empty($ranges)) {
return array();
}
$parts = array();
$satisfiable = false;
foreach (explode(',', $ranges) as $chunk){
list($o, $e) = explode('-', trim($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) {
return array();
}
// 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) {
$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 ($o === '') {
// 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);
$e = $end;
} elseif ($o <= $end) {
// 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
// is satisfiable.
$satisfiable = true;
} else {
continue;
}
$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).
if (!$satisfiable) {
$error = PEAR::raiseError('Error processing range request',
HTTP_DOWNLOAD_E_INVALID_REQUEST);
return $error;
}
//$this->sortChunks($parts);
return $this->mergeChunks($parts);
}
/**
* Sorts the ranges to be in ascending order
*
* @param array &$chunks ranges to sort
*
* @return void
* @access protected
* @static
* @author Philippe Jausions