Skip to content

Instantly share code, notes, and snippets.

@ArrayIterator
Created October 24, 2023 19:26
Show Gist options
  • Save ArrayIterator/902874f5d2b81a851c2ed683fa070158 to your computer and use it in GitHub Desktop.
Save ArrayIterator/902874f5d2b81a851c2ed683fa070158 to your computer and use it in GitHub Desktop.
File Serve Responder
<?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