|
<?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(); |