Skip to content

Instantly share code, notes, and snippets.

@tbl0605
Last active April 3, 2023 08:57
Show Gist options
  • Save tbl0605/00179b9edd84400092b08dbcc2de4d86 to your computer and use it in GitHub Desktop.
Save tbl0605/00179b9edd84400092b08dbcc2de4d86 to your computer and use it in GitHub Desktop.
How to support download with resume in PHP (the right way)

How to support download with resume in PHP (the right way)

Since I found a lot of buggy or incomplete PHP implementations, I wrote my own implementation which should work in most situations. Works with PHP >= 7.1 and allows to download files > 2 GB, even on a 32 bits OS or PHP version!

  1. copy download.php in a folder served by your Apache
  2. optionally modify the value of \Downloader\Download::LOCAL_CHARSET to match the charset used by your PHP installation
  3. store a big file that will be accessible for download, in this example it will be C:\temp\kubuntu-22.04-desktop-amd64.iso
  4. open your favorite browser and type:
localhost/path_to_your_php_folder/download.php?path=C:/temp/kubuntu-22.04-desktop-amd64.iso
  1. the file will start downloading after a short time
<?php
namespace Downloader;
// auto-load all your libraries and global config
//require_once ...
if (isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false) {
ob_start('ob_gzhandler');
} else {
ob_start();
}
// allow from any origin
if (isset($_SERVER['HTTP_ORIGIN'])) {
// Decide if the origin in $_SERVER['HTTP_ORIGIN'] is one
// you want to allow, and if so:
header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Max-Age: 86400'); // cache for 1 day
}
// Access-Control headers are received during OPTIONS requests
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) {
// May also be using PUT, DELETE, PATCH, HEAD, etc...
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
}
if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {
header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}");
}
exit(0);
}
class Download
{
// Cache size (8 MB):
const CACHE_SIZE = 8 * 1024 * 1024;
const LOCAL_CHARSET = 'Windows-1252';
/**
* Very basic error handler.
*
* @param boolean $condition
* @param string $message
*/
private function validate($condition, $message = '')
{
if ($condition) {
return;
}
if ($message === '') {
http_response_code(400);
echo 'Bad request';
} else {
http_response_code(500);
//throw new \Exception($this->encodeToUtf8($message), 400);
echo $this->encodeToUtf8($message);
}
exit(1);
}
/**
* Checks if the request body contains JSON data.
* If yes, decode the JSON data and add the content to the $_REQUEST array.
*/
private function decodeJsonRequest()
{
if (! empty($_SERVER['CONTENT_TYPE']) && preg_match('@^application/json(;.*)?$@i', $_SERVER['CONTENT_TYPE'])) {
$inputData = file_get_contents('php://input');
$postDataArray = json_decode($inputData, true);
if (is_array($postDataArray)) {
array_walk_recursive($postDataArray, function (&$paramValue) {
if (self::LOCAL_CHARSET !== 'UTF-8' && is_string($paramValue)) {
$paramValue = mb_convert_encoding($paramValue, self::LOCAL_CHARSET, 'UTF-8');
}
});
$_REQUEST = array_replace($_REQUEST, $postDataArray);
}
}
}
private function getFileInfos()
{
if (! key_exists('path', $_REQUEST)) {
return false;
}
$path = (string) $_REQUEST['path'];
$dirname = dirname($path);
$filename = basename($path);
if (trim($dirname) === '' || trim($filename) === '') {
return false;
}
return array(
'dirname' => $dirname,
'filename' => $filename
);
}
private function encodeToUtf8(string $paramValue)
{
if (self::LOCAL_CHARSET === 'UTF-8') {
return $paramValue;
}
return mb_convert_encoding($paramValue, 'UTF-8', self::LOCAL_CHARSET);
}
/**
*
* @see https://bugs.php.net/bug.php?id=78238
*/
private function removeNegativeSignOnZero(string $number): string
{
if (preg_match('/^-[0]*[.]?[0]*$/', $number)) {
return substr($number, 1);
}
return $number;
}
private function bcround(string $number): string
{
return $this->removeNegativeSignOnZero(bcadd($number, '0', 0));
}
private function closeOutputBuffers(int $targetLevel, bool $flush): void
{
$status = ob_get_status(true);
$level = \count($status);
$flags = PHP_OUTPUT_HANDLER_REMOVABLE | ($flush ? PHP_OUTPUT_HANDLER_FLUSHABLE : PHP_OUTPUT_HANDLER_CLEANABLE);
while ($level -- > $targetLevel && ($s = $status[$level]) && (! isset($s['del']) ? ! isset($s['flags']) || ($s['flags'] & $flags) === $flags : $s['del'])) {
if ($flush) {
ob_end_flush();
} else {
ob_end_clean();
}
}
}
private function resetAcceptedEncoding()
{
if (isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false) {
// needed for PHP 8.1.0 to PHP 8.1.5, to avoid the following error in web browsers: net::ERR_CONTENT_DECODING_FAILED 200 (OK)
header('Content-Encoding: identity');
header('Accept-Encoding: *');
}
}
/**
* Wworkaround: fseek() does not work on 32-bit systems (or with 32-bit PHP) when $offsetStr >= PHP_INT_MAX.
* <b>Important:</b> Only allows to emulate <code>fseek($filePointer, $offsetStr, SEEK_CUR)</code>
*
* @param resource $filePointer
* @param string $offsetStr
* @return int 0 if successful, -1 if not (and the $filePointer resource will be closed)
*/
private function setOffsetOnBigFiles($filePointer, $offsetStr)
{
// read file in segments
$bufferSizeStr = (string) self::CACHE_SIZE;
while (bccomp($offsetStr, '0') > 0 && ! feof($filePointer) && connection_status() === CONNECTION_NORMAL) {
$readStr = bccomp($offsetStr, $bufferSizeStr) > 0 ? $bufferSizeStr : $offsetStr;
$offsetStr = bcsub($offsetStr, $readStr);
if (fread($filePointer, (int) $readStr) === false) {
fclose($filePointer);
return - 1;
}
}
if (connection_status() !== CONNECTION_NORMAL || bccomp($offsetStr, '0') != 0) {
fclose($filePointer);
return - 1;
}
return 0;
}
/**
* Return file size (even for file > 2 Gb).
* For file size over PHP_INT_MAX (2 147 483 647), PHP filesize function loops from -PHP_INT_MAX to PHP_INT_MAX.
*
* @param resource $filePointer
* file pointer
* @param int $size
* file size retrieved using filesize(). Can be negative on 32 bits OS or PHP version.
* @return string|false file size or false on error (and resource $filePointer will be closed)
* @see https://www.php.net/manual/en/function.filesize.php#121406
*/
private function realFileSize($filePointer, $size)
{
if ($size >= 0) {
// Check if it really is a small file (< 2 GB).
if (fseek($filePointer, 0, SEEK_END) === 0) {
// Go back to the beginning of the file.
if (fseek($filePointer, 0) !== 0) {
fclose($filePointer);
return false;
}
// It really is a small file.
return (string) $size;
}
}
// Quickly jump the first 2 GB with fseek. After that fseek is not working on 32 bit php (it uses int internally).
$realSizeStr = (string) (PHP_INT_MAX - 1);
if (fseek($filePointer, (int) $realSizeStr) !== 0) {
fclose($filePointer);
return false;
}
$content = '';
$bufferSizeStr = (string) self::CACHE_SIZE;
// Read the file until end.
while (! feof($filePointer) && connection_status() === CONNECTION_NORMAL) {
$content = fread($filePointer, (int) $bufferSizeStr);
$realSizeStr = bcadd($realSizeStr, $bufferSizeStr);
}
$realSizeStr = bcsub($realSizeStr, $bufferSizeStr);
$realSizeStr = bcadd($realSizeStr, (string) strlen($content));
// Go back to the beginning of the file.
if (connection_status() !== CONNECTION_NORMAL || fseek($filePointer, 0) !== 0) {
fclose($filePointer);
return false;
}
return $realSizeStr;
}
public function sendFile()
{
// ob_start('ob_gzhandler') + Blob don't work well together, we close all the buffers...
$this->closeOutputBuffers(0, false);
// to avoid any PHP error or warning "leak"
ob_start();
$this->resetAcceptedEncoding();
// no maximum execution time,
// in case the file is very big and the connection very slow
set_time_limit(0);
// in case the current request has a body containing json data
$this->decodeJsonRequest();
// NB: we want the information on the file to be processed with the local encoding
// (i.e. normally the same as the underlying filesystem):
$this->validate(($fileInfos = $this->getFileInfos()) !== false);
$accessErrorMsg = sprintf('The file "%s" is non-existent or inaccessible.', $fileInfos['filename']);
$readErrorMsg = sprintf('Error while reading file "%s"', $fileInfos['filename']);
$this->validate(($normalizedPath = realpath($fileInfos['dirname'])) !== false, $accessErrorMsg);
$fullPath = $normalizedPath . DIRECTORY_SEPARATOR . $fileInfos['filename'];
$fileSize = 0;
if (! file_exists($fullPath) || ($fileSize = @filesize($fullPath)) === false) {
$this->validate(false, $accessErrorMsg);
}
if (($contentType = @mime_content_type($fullPath)) === false) {
$contentType = 'application/octet-stream';
}
$filenameUtf8 = $fileInfos['filename'];
$filenameUtf8 = rawurlencode($this->encodeToUtf8($filenameUtf8));
$this->validate(($fp = @fopen($fullPath, 'rb')) !== false, $readErrorMsg);
$this->validate(($fileSizeStr = $this->realFileSize($fp, $fileSize)) !== false, $readErrorMsg);
// https://blog.cpming.top/p/php-resumable-downloads
// https://blog.actorsfit.com/a?ID=01600-675b2822-26e4-42c0-bb2d-9c4b1ac1e681
// https://gist.github.com/fzerorubigd/3899077
// https://stackoverflow.com/questions/157318/resumable-downloads-when-using-php-to-send-the-file
$startStr = '0';
$endStr = bcsub($fileSizeStr, '1');
$partialContent = false;
if (isset($_SERVER['HTTP_RANGE'])) {
// if the HTTP_RANGE header is set we're dealing with partial content
$partialContent = true;
$matches = array();
// find the requested range
// this might be too simplistic, apparently the client can request
// multiple ranges, which can become pretty complex, so ignore it for now
preg_match('/bytes=(\d+)-(\d+)?/', $_SERVER['HTTP_RANGE'], $matches);
$startStr = $matches[1];
if (isset($matches[2])) {
$endStr = $matches[2];
}
}
$lengthStr = bcadd(bcsub($endStr, $startStr), '1');
// make sure the range is correct
$this->validate(bccomp($lengthStr, '0') >= 0 && bccomp($endStr, $fileSizeStr) < 0, sprintf('HTTP_RANGE error for file "%s"', $fileInfos['filename']));
// ob_start() doesn't work well with file downloads, all buffers are closed...
$this->closeOutputBuffers(0, false);
// XXX: optional, only for security after calling $this->closeOutputBuffers()
$this->resetAcceptedEncoding();
// essential for Chrome to retry downloading the same file following an error or cancellation of registration
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
// https://stackoverflow.com/a/68824724/2332350
header('Content-Disposition: attachment; filename*=UTF-8\'\'' . $filenameUtf8 . '; filename="' . $filenameUtf8 . '"');
header('Content-Type: ' . $contentType);
header('Content-Length: ' . $this->bcround($lengthStr));
header('Accept-Ranges: bytes');
if ($partialContent) {
// output the right headers for partial content
header('HTTP/1.1 206 Partial Content');
// https://github.com/SitePen/dstore/issues/109 (Right Content-range value for Empty Files)
header('Content-Range: bytes ' . $this->bcround($startStr) . '-' . $this->bcround($endStr) . '/' . $this->bcround($fileSizeStr));
}
if (bccomp($startStr, '0') > 0) {
// NB: fseek() will not work if $startStr > PHP_INT_MAX, i.e. if the file is larger than 2 GB (with 32-bit PHP).
if (bccomp($startStr, (string) PHP_INT_MAX) < 0) {
$this->validate(fseek($fp, $startStr) === 0, $readErrorMsg);
} else {
$this->validate($this->setOffsetOnBigFiles($fp, $startStr) === 0, $readErrorMsg);
}
}
// read file in segments
$bufferSizeStr = (string) self::CACHE_SIZE;
while (bccomp($lengthStr, '0') > 0 && ! feof($fp) && connection_status() === CONNECTION_NORMAL) {
$readStr = bccomp($lengthStr, $bufferSizeStr) > 0 ? $bufferSizeStr : $lengthStr;
$lengthStr = bcsub($lengthStr, $readStr);
$this->validate(($content = fread($fp, (int) $readStr)) !== false, $readErrorMsg);
echo $content;
}
$this->validate(fclose($fp), sprintf('Error while closing file "%s"', $fileInfos['filename']));
$this->validate(connection_status() === CONNECTION_NORMAL, sprintf('Connection interrupted while downloading file "%s"', $fullPath));
exit(0);
}
}
$d = new Download();
$d->sendFile();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment