Created
October 24, 2023 19:26
-
-
Save ArrayIterator/902874f5d2b81a851c2ed683fa070158 to your computer and use it in GitHub Desktop.
File Serve Responder
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace MyNamespace; | |
class FileResponder | |
{ | |
protected $file = null; | |
protected $mimetype = 'application/octet-stream'; | |
/** | |
* @var string | |
*/ | |
protected $attachmentFileName = null; | |
/** | |
* @var int | |
*/ | |
protected $size = 0; | |
/** | |
* @var bool | |
*/ | |
protected $sendLastModifiedTime = true; | |
/** | |
* @var bool | |
*/ | |
protected $sendAsAttachment = false; | |
/** | |
* @var bool | |
*/ | |
protected $sendContentLength = true; | |
/** | |
* @var bool | |
*/ | |
protected $allowRange = true; | |
/** | |
* @var int | |
*/ | |
protected $maxRanges = 100; | |
/** | |
* @var ?string | |
*/ | |
protected $boundary = null; | |
/** | |
* @var string|null | |
*/ | |
private $eTag = null; | |
/** | |
* @var array | |
*/ | |
private $headerSent = []; | |
private static $extension_prefill = [ | |
'txt' => 'text/plain', | |
'css' => 'text/css', | |
'asice' => 'application/vnd.etsi.asic-e+zip', | |
'bz2' => 'application/x-bz2', | |
'csv' => 'text/csv', | |
'ecma' => 'application/ecmascript', | |
'flv' => 'video/x-flv', | |
'gif' => 'image/gif', | |
'gz' => 'application/x-gzip', | |
'html' => 'text/html', | |
'htm' => 'text/html', | |
'jar' => 'application/x-java-archive', | |
'jpg' => 'image/jpeg', | |
'js' => 'text/javascript', | |
'json' => 'application/json', | |
'jsonld' => 'application/ld+json', | |
'cdf' => 'application/x-cdf', | |
'avi' => 'video/x-msvideo', | |
'avif' => 'image/avif', | |
'keynote' => 'application/vnd.apple.keynote', | |
'3gp' => 'video/3gpp', | |
'7z' => 'application/x-7z-compressed', | |
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', | |
'm3u' => 'audio/x-mpegurl', | |
'aac' => 'audio/aac', | |
'm4a' => 'audio/mp4', | |
'mp4' => 'video/mp4', | |
'md' => 'text/markdown', | |
'mdb' => 'application/x-msaccess', | |
'mid' => 'audio/midi', | |
'mov' => 'video/quicktime', | |
'mp3' => 'audio/mpeg', | |
'ogg' => 'audio/ogg', | |
'pdf' => 'application/pdf', | |
'php' => 'text/x-php', | |
'sql' => 'application/sql', | |
'ppt' => 'application/vnd.ms-powerpoint', | |
'hqx' => 'application/stuffit', | |
'sit' => 'application/x-stuffit', | |
'xml' => 'application/xml', | |
'svg' => 'image/svg+xml', | |
'tar' => 'application/x-tar', | |
'tif' => 'image/tiff', | |
'ttf' => 'application/x-font-truetype', | |
'vcf' => 'text/x-vcard', | |
'wav' => 'audio/wav', | |
'wma' => 'audio/x-ms-wma', | |
'wmv' => 'audio/x-ms-wmv', | |
'xls' => 'application/vnd.ms-excel', | |
'zip' => 'application/zip', | |
'gzip' => 'application/gzip', | |
'rar' => 'application/vnd.rar', | |
'rtf' => 'application/rtf', | |
'png' => 'image/png', | |
'bmp' => 'image/bmp', | |
'ico' => 'image/ico', | |
'bzi2' => 'application/x-bzip', | |
'bzip2' => 'application/x-bzip2', | |
'doc' => 'application/msword', | |
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | |
'eot' => 'application/vnd.ms-fontobject', | |
'epub' => 'application/epub+zip', | |
]; | |
/** | |
* Default allowed method | |
*/ | |
const ALLOWED_METHODS = [ | |
'OPTIONS', | |
'HEAD', | |
'POST', | |
'GET' | |
]; | |
/** | |
* @param SplFileInfo|string $file | |
*/ | |
public function __construct($file, $mimetype = null) | |
{ | |
if ($file instanceof SplFileInfo ) { | |
$this->file = $file->getRealPath(); | |
$this->size = $file->getSize(); | |
$this->attachmentFileName = $file->getBasename(); | |
$extension = $file->getExtension(); | |
} elseif (is_string($file) && is_file($file)) { | |
$this->size = filesize($file); | |
$this->file = realpath($file)?:$file; | |
$this->attachmentFileName = pathinfo($file, PATHINFO_BASENAME); | |
$extension = pathinfo($file, PATHINFO_EXTENSION); | |
} | |
if (isset($extension)) { | |
if ( ! is_string( $mimetype ) || ! preg_match( '~^[a-z]/[a-z0-9+.-]+$~i', $mimetype ) ) { | |
$extension = strtolower( $extension ); | |
$mimetype = isset(self::$extension_prefill[$extension]) | |
? self::$extension_prefill[$extension] | |
: null; | |
if ( ! $mimetype ) { | |
// fallback default | |
$mimetype = $this->mimetype; | |
} | |
} | |
$this->mimetype = is_string($mimetype) ? $mimetype : $this->mimetype; | |
} | |
} | |
/** | |
* @return ?string | |
*/ | |
public function getFile() | |
{ | |
return $this->file; | |
} | |
/** | |
* @return bool | |
*/ | |
public function valid() | |
{ | |
return $this->file && is_file($this->file) && is_readable($this->file); | |
} | |
public function setAllowRange($enable) | |
{ | |
$this->allowRange = (bool) $enable; | |
} | |
/** | |
* @return bool | |
*/ | |
public function isAllowRange() | |
{ | |
return $this->allowRange; | |
} | |
/** | |
* @param bool $enable | |
* | |
* @return void | |
*/ | |
public function sendLastModifiedTime($enable) | |
{ | |
$this->sendLastModifiedTime = (bool) $enable; | |
} | |
/** | |
* @return bool | |
*/ | |
public function isSendLastModifiedTime() | |
{ | |
return $this->sendLastModifiedTime; | |
} | |
/** | |
* @param $enable | |
* | |
* @return void | |
*/ | |
public function sendAsAttachment($enable) | |
{ | |
$this->sendAsAttachment = (bool) $enable; | |
} | |
/** | |
* @return bool | |
*/ | |
public function isSendAsAttachment() | |
{ | |
return $this->sendAsAttachment; | |
} | |
/** | |
* @return bool | |
*/ | |
public function isSendContentLength() | |
{ | |
return $this->sendContentLength; | |
} | |
/** | |
* @param $enable | |
* | |
* @return void | |
*/ | |
public function sendContentLength($enable) | |
{ | |
$this->sendContentLength = (bool) $enable; | |
} | |
/** | |
* @param $fileName | |
* | |
* @return void | |
*/ | |
public function setAttachmentFileName($fileName) | |
{ | |
$this->attachmentFileName = (string) $fileName; | |
} | |
/** | |
* @return string | |
*/ | |
public function getAttachmentFileName() | |
{ | |
return $this->attachmentFileName; | |
} | |
/** | |
* @return void | |
*/ | |
public function resetFileName() | |
{ | |
if (is_string($this->file)) { | |
$this->attachmentFileName = pathinfo($this->file, PATHINFO_BASENAME); | |
} | |
} | |
/** | |
* @return string | |
*/ | |
public function getMimetype() | |
{ | |
return $this->mimetype; | |
} | |
/** | |
* @return int | |
*/ | |
public function getSize() | |
{ | |
return $this->size?:0; | |
} | |
public function setMaxRanges($ranges) | |
{ | |
if (!is_int($ranges)) { | |
return; | |
} | |
if ($ranges < 0) { | |
$ranges = 0; | |
} | |
$this->maxRanges = $ranges; | |
} | |
/** | |
* @return int | |
*/ | |
public function getMaxRanges() | |
{ | |
return $this->maxRanges; | |
} | |
/** | |
* @return string | |
*/ | |
public function getBoundary() | |
{ | |
if ($this->boundary) { | |
return $this->boundary; | |
} | |
$random = ''; | |
while (strlen($random) < 16) { | |
$random .= chr(mt_rand(0, 255)); | |
} | |
return $this->boundary = md5( $random ); | |
} | |
/** | |
* @return ?string | |
*/ | |
public function getEtag() | |
{ | |
if (!$this->valid()) { | |
return null; | |
} | |
if ($this->eTag) { | |
return $this->eTag; | |
} | |
/** | |
* @link http://lxr.nginx.org/ident?_i=ngx_http_set_etag | |
*/ | |
$time = filemtime($this->file); | |
$size = $this->size; | |
// hexadecimal using hex: modification time & hex: size | |
return $this->eTag = sprintf('%x-%x', $time, $size); | |
} | |
/** | |
* @param string $name | |
* @param scalar $value | |
* @param int $code | |
* @return bool | |
*/ | |
private function sendHeader( | |
$name, | |
$value, | |
$code = 0 | |
) { | |
if (headers_sent()) { | |
return false; | |
} | |
$name = trim($name); | |
if ($name === '') { | |
return false; | |
} | |
$name = ucwords(str_replace(' ', '-', strtolower($name)), '-'); | |
$value = is_string($value) ? trim($value) : $value; | |
$value = (string) $value; | |
$header = $value !== '' ? "$name: $value" : $name; | |
if (isset($this->headerSent[$name])) { | |
return false; | |
} | |
$this->headerSent[$name] = $header; | |
header($header, true, $code); | |
return true; | |
} | |
public function sendHeaderLastModified() | |
{ | |
if (!$this->isSendLastModifiedTime()) { | |
return false; | |
} | |
return $this->sendHeader( | |
'Last-Modified', | |
gmdate('Y-m-d H:i:s \G\M\T') | |
); | |
} | |
/** | |
* @param string|array|null $cacheType | |
* @param int|null $maxAge | |
* @return bool | |
*/ | |
public function sendHeaderCache( | |
$cacheType = null, | |
$maxAge = null | |
) { | |
if (!is_array($cacheType) && !is_string($cacheType) && !is_null($cacheType)) { | |
return false; | |
} | |
$maxAge = !is_int($maxAge) ? null : $maxAge; | |
$data = []; | |
if ($cacheType) { | |
$cacheType = !is_array($cacheType) ? [$cacheType] : $cacheType; | |
$cacheType = array_filter($cacheType, 'is_string'); | |
if (!empty($cacheType)) { | |
$cacheType = array_unique(array_map('strtolower', $cacheType)); | |
$data = array_values($cacheType); | |
} | |
} | |
if ($maxAge) { | |
$data[] = sprintf('max-age=%d', $maxAge); | |
} | |
return $this->sendHeader('Cache-Control', implode(', ', $data)); | |
} | |
/** | |
* Send accept ranges | |
* | |
* @return bool | |
*/ | |
public function sendHeaderAcceptRanges() | |
{ | |
return $this->sendHeader( | |
'Accept-Ranges', | |
$this->isAllowRange() || $this->getMaxRanges() < 1 | |
? 'bytes' | |
: 'none' | |
); | |
} | |
public function sendHeaderContentLength($length) | |
{ | |
if (!is_numeric($length) || $length < 1) { | |
return false; | |
} | |
$length = (int) $length; | |
if (!$this->isSendContentLength()) { | |
return false; | |
} | |
return $this->sendHeader('Content-Length', $length); | |
} | |
public function sendHeaderEtag() | |
{ | |
$etag = $this->getEtag(); | |
return $etag && $this->sendHeader('Etag', $etag); | |
} | |
public function sendHeaderContentType($contentType, $code = 0) | |
{ | |
return $this->sendHeader('Content-Type', $contentType, $code); | |
} | |
public function sendHeaderMimeType() | |
{ | |
$mimeType = trim($this->mimetype); | |
// header | |
if (!preg_match('~^[a-z]/[a-z0-9+.-]+$~i', $mimeType)) { | |
return false; | |
} | |
return $mimeType && $this->sendHeader('Content-Type', $mimeType); | |
} | |
/** | |
* @return bool | |
*/ | |
public function sendHeaderAttachment() | |
{ | |
if (!$this->isSendAsAttachment()) { | |
return false; | |
} | |
return $this->sendHeader( | |
'Content-Disposition', | |
sprintf( | |
'attachment; filename="%s"', | |
rawurlencode($this->getAttachmentFileName()) | |
) | |
); | |
} | |
public function displayRangeNotSatisfy() | |
{ | |
$this->sendHeaderContentType('text/html', 416); | |
$this->sendHeader('Content-Range', 'bytes */'.$this->size); | |
$this->stopRequest(); | |
} | |
public function send() | |
{ | |
$method = isset($_SERVER['REQUEST_METHOD']) | |
? $_SERVER['REQUEST_METHOD'] | |
: 'GET'; | |
$method = strtoupper($method); | |
if (!in_array($method, self::ALLOWED_METHODS)) { | |
$this->sendHeaderContentType('text/html', 405); | |
$this->stopRequest(); | |
} | |
if (!$this->valid()) { | |
// file not found | |
$this->sendHeaderContentType('text/html', 404); | |
$this->stopRequest(); | |
} | |
// remove all buffer | |
$count = 5; | |
while (--$count > 0 && ob_get_level() > 0) { | |
ob_end_clean(); | |
} | |
if (headers_sent()) { | |
// file header contain buffer | |
$this->sendHeaderContentType('text/html', 409); | |
$this->stopRequest(); | |
} | |
// send | |
$this->sendRequestData(); | |
} | |
private function sendRequestData() | |
{ | |
// remove x-powered-by php | |
header_remove('X-Powered-By'); | |
$method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET'; | |
$method = strtoupper($method); | |
if ($method === 'OPTIONS') { | |
$this->sendHeaderContentType('text/html'); | |
// just allow options get head post only | |
$this->sendHeaderAcceptRanges(); | |
// 604800 is 1 week | |
$this->sendHeaderCache(null, 604800); | |
$this->sendHeader('Allow', implode(', ', self::ALLOWED_METHODS)); | |
exit(0); | |
} | |
$rangeHeader = isset($_SERVER['HTTP_RANGE']) ? $_SERVER['HTTP_RANGE'] : ''; | |
$fileSize = $this->size; | |
$rangeHeader = trim($rangeHeader); | |
// multi-bytes boundary | |
$boundary = $this->getBoundary(); | |
$ranges = []; | |
// header for multi-bytes | |
$headers = []; | |
$total = $fileSize; | |
$rangeTotal = 0; | |
/** | |
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests | |
*/ | |
$rangeMimeType = $this->mimetype; | |
$totalRanges = 0; | |
$maxRanges = $this->getMaxRanges(); | |
// byte offset start from zero, minus 1 | |
$maxRange = ($fileSize - 1); | |
$maxRangeRequest = $maxRange; | |
$minRangeRequest = null; | |
if ($maxRanges > 0 && $rangeHeader && preg_match('~^bytes=(.+)$~i', $rangeHeader, $match)) { | |
$total = 0; | |
$rangeHeader = array_map('trim', explode(',', trim($match[1]))); | |
foreach ($rangeHeader as $range) { | |
$range = trim($range); | |
if ($range === '') { | |
continue; | |
} | |
$range = explode('-', $range, 2); | |
$start = array_shift($range); | |
$end = array_shift($range); | |
if (($start === '' && $end === '')) { | |
// stop | |
$this->displayRangeNotSatisfy(); | |
} | |
$start = $start === '' ? 0 : $start; | |
$end = $end === '' ? $maxRange : $end; | |
if (! is_numeric($start) | |
|| ! is_numeric($end) | |
|| (is_string($start) && str_contains('.', $start)) | |
|| (is_string($end) && str_contains('.', $end)) | |
|| ((int) $start) > ((int) $end) | |
|| ((int) $start) > $maxRange | |
) { | |
$headers = null; | |
$ranges = null; | |
// stop | |
$this->displayRangeNotSatisfy(); | |
} | |
$start = (int) $start; | |
$end = (int) $end; | |
// get minimum from maxRange | |
$end = min($end, $maxRange); | |
/** | |
* Determine range set min & max | |
*/ | |
$minRangeRequest = isset($minRangeRequest) ? $minRangeRequest : $start; | |
if ($maxRangeRequest >= $end) { | |
$maxRangeRequest = $end; | |
} | |
if ($minRangeRequest > $start) { | |
$minRangeRequest = $start; | |
} | |
// starting point is zero so append 1 on ending | |
$currentTotal = ($end + 1) - $start; | |
$total += $currentTotal; | |
// set start & max -> end | |
$ranges[$start][$end] = [$start, $end]; | |
// add headers | |
$header = sprintf("\r\n--%s\r\n", $boundary); | |
$header .= sprintf("Content-Type: %s\r\n", $rangeMimeType); | |
$header .= sprintf("Content-Range: bytes %d-%d/%d\r\n\r\n", $start, $end, $fileSize); | |
$rangeTotal += $currentTotal + strlen($header); | |
$headers[$start][$end] = $header; | |
$totalRanges++; | |
// break on max range limit | |
if ($totalRanges === $maxRanges) { | |
break; | |
} | |
} | |
// if contain offset start 0 && max range bytes is on range | |
// don't process ranges | |
if (empty($ranges) | |
|| $totalRanges === 1 | |
|| ($minRangeRequest === 0 && $maxRangeRequest >= $maxRange) | |
|| !$this->isAllowRange() | |
|| $this->getMaxRanges() < 1 | |
) { | |
$ranges = []; | |
$total = $fileSize; | |
} else { | |
ksort($ranges); | |
foreach ($ranges as $key => $range) { | |
ksort($range); | |
$ranges[$key] = $range; | |
} | |
} | |
} | |
// send accept | |
// $this->sendAcceptRanges(); | |
// if only 1 or empty ranges | |
if (($empty = empty($ranges)) || $totalRanges === 1) { | |
// send cache | |
// $this->sendCacheHeader(['public', 'must-revalidate'], maxAge: 604800); | |
$startingPoint = 0; | |
// if ranges | |
if (!$empty) { | |
$ranges = reset($ranges); | |
$ranges = array_shift($ranges); | |
$startingPoint = array_shift($ranges); | |
$end = array_shift($ranges); | |
$total = ($end + 1) - $startingPoint; | |
if ($total !== $fileSize) { | |
$this->sendHeader('Content-Range', "bytes $startingPoint-$end/$fileSize"); | |
} | |
} | |
// set content length | |
$this->sendHeaderContentLength($total); | |
// send mimetype header | |
$this->sendHeaderMimeType(); | |
// send etag | |
$this->sendHeaderEtag(); | |
// send last modifier | |
$this->sendHeaderLastModified(); | |
// send attachment header | |
$this->sendHeaderAttachment(); | |
// set etag | |
$this->sendHeaderEtag(); | |
if ($method === 'HEAD') { | |
$this->stopRequest(); | |
} | |
$sock = $this->getSock(); | |
fseek($sock, $startingPoint); | |
while (!feof($sock)) { | |
$read = 4096; | |
if ($total < $read) { | |
$read = $total; | |
$total = 0; | |
} | |
echo fread($sock, $read); | |
} | |
fclose($sock); | |
$this->stopRequest(); | |
} | |
if (!$this->isAllowRange()) { | |
$this->displayRangeNotSatisfy(); | |
} | |
// get socket | |
$sock = $this->getSock(); | |
// send boundary and status code -> partial content 206 | |
$this->sendHeaderContentType("multipart/byteranges; boundary=$boundary", 206); | |
// send range total | |
$this->sendHeaderContentLength($rangeTotal); | |
// send etag | |
$this->sendHeaderEtag(); | |
// send last modifier | |
$this->sendHeaderLastModified(); | |
// send attachment header | |
$this->sendHeaderAttachment(); | |
// no process if method header | |
if ($method === 'HEAD') { | |
$this->stopRequest(); | |
} | |
foreach ($ranges as $key => $range) { | |
// getting headers | |
$header = $headers[$key]; | |
unset($header[$key]); | |
foreach ($range as $ending => $rangeValue) { | |
$this->checkConnection($sock); | |
$start = $rangeValue[0]; | |
$end = $rangeValue[1]; | |
$total = ($end + 1) - $start; | |
fseek($sock, $start); | |
// print headers | |
echo $header[$ending]; | |
while ($total > 0 && !feof($sock)) { | |
$this->checkConnection($sock); | |
$read = 4096; | |
if ($total < $read) { | |
$read = $total; | |
$total = 0; | |
} | |
echo fread($sock, $read); | |
} | |
} | |
} | |
$this->stopRequest(); | |
} | |
/** | |
* @return resource | |
*/ | |
private function getSock() | |
{ | |
set_error_handler(static function() {}); | |
$sock = is_readable($this->file) && is_file($this->file) | |
? fopen($this->file, 'rb') | |
: null; | |
restore_error_handler(); | |
if (!$sock || !flock($sock, LOCK_SH|LOCK_NB)) { | |
// where can not lock, use unprocessable entity | |
header('Content-Type: text/html', true, 422); | |
$this->stopRequest(); | |
} | |
return $sock; | |
} | |
/** | |
* @return never-return | |
*/ | |
public function stopRequest() | |
{ | |
if (function_exists('fastcgi_finish_request')) { | |
fastcgi_finish_request(); | |
} | |
exit(0); | |
} | |
private function checkConnection($sock = null) | |
{ | |
if (is_resource($sock)) { | |
flock( $sock, LOCK_UN ); | |
} | |
if (connection_status() !== CONNECTION_NORMAL) { | |
$this->stopRequest(); | |
} | |
} | |
} | |
/* | |
$file = new FileResponder( __FILE__ ); | |
if (! $file->valid() ) { | |
// not found | |
$file->sendHeaderContentType( 'text/html', 404 ); | |
$file->stopRequest(); | |
} | |
$file->send(); | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment