diff --git a/src/Filesystem/F.php b/src/Filesystem/F.php index 1cd05db712..423050a8db 100644 --- a/src/Filesystem/F.php +++ b/src/Filesystem/F.php @@ -783,6 +783,39 @@ public static function size(string|array $file): int } } + /** + * Continously outputs the file between the provided range + */ + public static function stream( + string $file, + int $start = 0, + int $end = null + ): void { + $size = static::size($file); + $end ??= $size; + + if ($end === $size) { + echo static::read($file); + return; + } + + $handle = fopen($file, 'rb'); + fseek($handle, $start); + + if (!feof($handle)) { + throw new Exception('Invalid file handle'); + } + + while ($start < $end) { + $chunk = fread($handle, min(8 * 1024, $end - $start)); + $start += strlen($chunk); + echo $chunk; + flush(); + } + + fclose($handle); + } + /** * Categorize the file * diff --git a/src/Http/Response.php b/src/Http/Response.php index 984aed4a56..84ae8614bb 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -4,6 +4,7 @@ use Closure; use Exception; +use Kirby\Cms\App; use Kirby\Exception\LogicException; use Kirby\Filesystem\F; use Throwable; @@ -30,7 +31,7 @@ class Response /** * The response body */ - protected string $body; + protected string|Closure $body; /** * The HTTP response code @@ -51,7 +52,7 @@ class Response * Creates a new response object */ public function __construct( - string|array $body = '', + string|Closure|array $body = '', string|null $type = null, int|null $code = null, array|null $headers = null, @@ -107,6 +108,10 @@ public function __toString(): string */ public function body(): string { + if (is_callable($this->body) === true) { + return call_user_func($this->body); + } + return $this->body; } @@ -169,14 +174,27 @@ public static function download( * * @param array $props Custom overrides for response props (e.g. headers) */ - public static function file(string $file, array $props = []): static - { - $props = array_merge([ - 'body' => F::read($file), - 'type' => F::extensionToMime(F::extension($file)) - ], $props); + public static function file( + string $file, + array $props = [], + string|false|null $range = null + ): static { + // if no range is specified, lazily check request for HTTP_RANGE header + $range ??= App::instance(null, true)?->request()->header('range'); - return new static($props); + if (is_string($range) === true) { + if ($response = static::range($file, $range, $props)) { + return $response; + } + } + + return new static(array_merge([ + 'body' => F::read($file), + 'type' => F::extensionToMime(F::extension($file)), + 'headers' => [ + 'Content-Length' => F::size($file), + ] + ], $props)); } @@ -250,6 +268,53 @@ public static function json( ]); } + /** + * Creates a bytes range response for a file + * based on the passed range string + */ + protected static function range( + string $file, + string $range, + array $props = [] + ): static|null { + preg_match("/^bytes=(\d*)-(\d*)/", $range, $matches); + + if ($matches === false) { + return null; + } + + $size = F::size($file); + $start = $matches[1]; + $end = $matches[2]; + + if ($start === '') { + $start = $size - (int)$end; + $end = $size; + } + + if ($end === '') { + $end = $size; + } + + $start = (int)$start; + $end = (int)$end; + + // range out of bounds: provide specific response + if ($start < 0 || $start >= $end || $end > $size) { + return new static(['code' => 416]); + } + + return new static(array_merge([ + 'code' => 206, + 'body' => fn () => F::stream($file, $start, $end), + 'type' => F::extensionToMime(F::extension($file)), + 'headers' => [ + 'Content-Length' => $size, + 'Content-Range' => 'bytes ' . $start . '-' . $end . '/' . $size, + ] + ], $props)); + } + /** * Creates a redirect response, * which will send the visitor to the