'OK', 201 => 'Created', 204 => 'No Content', 206 => 'Partial', 207 => 'Multi-Status', // WTF ?? 301 => 'Moved Permanently', 302 => 'Found', 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', 409 => 'Conflict', 412 => 'Precondition Failed', 415 => 'Unsupported Media Type', 416 => 'Request Range Not Satisfiable', 423 => 'Locked', 500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway', 505 => 'Bad Gateway', ); protected $filepath = null; protected $name = null; protected $type = null; protected $handle = null; protected $ranges = null; protected $block = 1048576; // 1KB protected $size = 0; protected $sent = 0; protected $start = null; protected $end = null; protected $throttle = null; protected $multipart_separator = null; public function __construct($filepath, $name=null, $type=null) { if (!file_exists($filepath) || !is_readable($filepath)) { throw new Exception("unable to transfer file '{$filepath}'"); } $this->filepath = $filepath; $this->name = $name ? $name : basename($filepath); $this->type = $type ? $type : $this->determineMimetype(); $this->size = filesize($filepath); $this->parseRangeHeader(); } public function send($throttle=null) { $this->throttle = $throttle; $this->handle = fopen($this->filepath, 'rb'); // ignore_user_abort(true); // disable output buffering while (ob_get_level()) { ob_end_clean(); } // Required for IE, otherwise Content-Disposition may be ignored if (ini_get('zlib.output_compression')) { ini_set('zlib.output_compression', 'Off'); } // no range - send eveything if (!$this->ranges) { $this->sendHeaders(200, $this->size); $this->passthru(); return; } // single range if (!isset($this->ranges[1])) { // only end-point given (requested last $x bytes) if (isset($range['last'])) { $this->sendHeaders(200, $range['last']); $this->end = -$range['last']; return $this->passthru(); } // abort if out of range if ($range['start'] >= $this->size) { $this->sendHeaders(416); return 0; } // start - end range given if (!empty($range['end'])) { $size = $range['end'] - $range['start'] + 1; $this->sendHeaders(206, $size); header('Content-Range: ' . $range['start'] . '-' . $range['end'] . '/' . $this->size); $this->start = $range['start']; $this->end = $range['end']; return $this->passthru(); } // only start (offset) given $this->sendHeaders(206, $this->size - $range['start']); header('Content-Range: ' . $range['start'] . '-' . ($this->size - $range['start']) . '/' . $this->size); $this->start = $range['start']; return $this->passthru(); } // multiple ranges $this->multipartByterangeHeaderBegin(); $this->sendHeaders(206); foreach ($this->ranges as $range) { // TODO what if size unknown? 500? if (isset($range['start'])) { $from = $range['start']; $to = !empty($range['end']) ? $range['end'] : $this->size - 1; } else { $from = $this->size - $range['last'] - 1; $to = $this->size -1; } $total = isset($this->size) ? $this->size : "*"; $size = $to - $from + 1; $this->multipartByterangeHeader($from, $to, $total); $this->start = $from; $this->end = $to; $this->passthru(); } $this->multipartByterangeHeaderEnd(); return $this->sent; } protected function determineMimetype() { require_once dirname(__FILE__) . '/Mimetype.php'; return Mimetype::get($this->filepath); } protected function parseRangeHeader() { if ($this->ranges === null) { if (!empty($_SERVER['HTTP_RANGE'])) { // we only support standard "bytes" range specifications for now if (preg_match('/bytes\s*=\s*(.+)/', $_SERVER['HTTP_RANGE'], $matches)) { $this->ranges = array(); // ranges are comma separated foreach (explode(",", $matches[1]) as $range) { // ranges are either from-to pairs or just end positions list($start, $end) = explode("-", $range); $this->ranges[] = $start === "" ? array("last" => $end) : array("start" => $start, "end" => $end); } } } } return $this->ranges; } protected function sendHeaders($status, $length=null) { $_status = $status . ' ' . self::$_response_status[$status]; switch (PHP_SAPI) { case 'cgi': // php-cgi < 5.3 case 'cgi-fcgi': // php-cgi >= 5.3 case 'fpm-fcgi': // php-fpm >= 5.3.3 header("Status: $_status"); break; case 'cli': break; default: header("HTTP/1.1 $_status"); break; } if ($status >= 300) { return; } if (!$this->multipart_separator) { header('Content-Type: ' . $this->type); } header('Content-Disposition: attachment; filename="' . rawurlencode($this->name) . '"'); header('Content-Transfer-Encoding: binary'); header('Accept-Ranges: bytes'); if ($length !== null) { header("Content-Length: $length"); } } protected function multipartByterangeHeaderBegin() { // a little naive, this sequence *might* be part of the content // but it's really not likely and rather expensive to check $this->multipart_separator = "SEPARATOR_" . md5(microtime()); header('Content-Type: multipart/byteranges; boundary=' . $this->multipart_separator); } protected function multipartByterangeHeader($from, $to, $total=null) { echo "\n--{$this->multipart_separator}\n"; echo "Content-type: {$this->type}\n"; echo "Content-range: $from-$to/". ($total === null ? "*" : $total); echo "\n\n"; } protected function multipartByterangeHeaderEnd() { echo "\n--{$this->multipart_separator}--"; } protected function passthru() { $offset = 0; if ($this->start !== null) { $offset = (int) $this->start; fseek($this->handle, $offset, SEEK_SET); } if (!$this->throttle && !$this->end) { // take the easy route $this->sent += fpassthru($this->handle); } else { $block = max($this->end ? min($this->block, $this->end - $offset) : $this->block, 0); $start = microtime(true); $_sent = 0; while (!feof($this->handle) && $block && !connection_aborted()) { // read data $buffer = fread($this->handle, $block); $length = self::bytes($buffer); // TODO: throttling // update meter for throttling $_sent += $length; // update meter for block size $offset += $length; // update total bytes sent $this->sent += $length; // send data echo $buffer; flush(); // re-evaluate block size $block = max($this->end ? min($this->block, $this->end - $offset) : $this->block, 0); } } } public static function bytes($data) { static $overloaded = null; if ($overloaded === null) { $overloaded = function_exists('mb_strlen') ? ini_get('mbstring.func_overload') & 2 : false; } return $overloaded ? mb_strlen($data, 'ascii') : strlen($data); } } class MonitoredFileDownload extends FileDownload { public function __construct($filepath, $name=null, $type=null) { parent::__construct($filepath, $name, $type); // inform database } public function __destruct() { // $this->sent and $this->filepath are still available // inform database } } // Do some authentication. // like 2 DLs per sha1(IP/Agent) and stuff $dl = new FileDownload('/tmp/foo.mp4'); $dl->send(200); // send at 200KB/s max unset($dl); // force __destruct() immediately