diff --git a/formwork/src/App.php b/formwork/src/App.php index df9c619c7..547437924 100644 --- a/formwork/src/App.php +++ b/formwork/src/App.php @@ -138,9 +138,11 @@ public function run(): Response DynamicFieldValue::$vars = $this->container->call(require $this->config()->get('system.fields.dynamic.vars.file')); + $request = $this->request(); + $response = $this->router()->dispatch(); - $response->send(); + $response->prepare($request)->send(); return $response; } diff --git a/formwork/src/ErrorHandlers.php b/formwork/src/ErrorHandlers.php index e01ecfed5..e2f3e33cc 100644 --- a/formwork/src/ErrorHandlers.php +++ b/formwork/src/ErrorHandlers.php @@ -34,11 +34,11 @@ public function displayErrorPage(ResponseStatus $responseStatus = ResponseStatus Response::cleanOutputBuffers(); if ($this->request->isXmlHttpRequest()) { - JsonResponse::error('Error', $responseStatus)->send(); + JsonResponse::error('Error', $responseStatus)->prepare($this->request)->send(); } else { $view = $this->viewFactory->make('errors.error', ['status' => $responseStatus->code(), 'message' => $responseStatus->message(), 'throwable' => $throwable]); $response = new Response($view->render(), $responseStatus); - $response->send(); + $response->prepare($this->request)->send(); // Don't exit, otherwise the error will not be logged } } diff --git a/formwork/src/Http/FileResponse.php b/formwork/src/Http/FileResponse.php index b3eff6900..75ee06d99 100644 --- a/formwork/src/Http/FileResponse.php +++ b/formwork/src/Http/FileResponse.php @@ -4,20 +4,32 @@ use Formwork\Http\Utils\Header; use Formwork\Utils\FileSystem; +use RuntimeException; class FileResponse extends Response { + protected const CHUNK_SIZE = 512 * 1024; + + protected int $fileSize; + + protected int $offset = 0; + + protected int $length; + /** * @inheritdoc */ - public function __construct(string $path, ResponseStatus $responseStatus = ResponseStatus::OK, array $headers = [], bool $download = false) + public function __construct(protected string $path, ResponseStatus $responseStatus = ResponseStatus::OK, array $headers = [], bool $download = false) { + $this->fileSize = FileSystem::fileSize($path); + $headers += [ 'Content-Type' => FileSystem::mimeType($path), 'Content-Disposition' => $download ? Header::make(['attachment', 'filename' => basename($path)]) : 'inline', - 'Content-Length' => (string) FileSystem::fileSize($path), + 'Content-Length' => (string) $this->fileSize, ]; - parent::__construct(FileSystem::read($path), $responseStatus, $headers); + + parent::__construct('', $responseStatus, $headers); } /** @@ -26,6 +38,80 @@ public function __construct(string $path, ResponseStatus $responseStatus = Respo public function send(): void { parent::cleanOutputBuffers(); - parent::send(); + + $this->sendHeaders(); + + $file = fopen($this->path, 'r'); + $output = fopen('php://output', 'w'); + + if ($output === false) { + throw new RuntimeException('Unable to open output stream'); + } + + if ($file === false) { + throw new RuntimeException('Unable to open file: ' . $this->path); + } + + ignore_user_abort(true); + + if ($this->offset > 0) { + fseek($file, $this->offset); + } + + $length = $this->length ?? $this->fileSize; + + while ($length > 0 && !feof($file)) { + $read = fread($file, self::CHUNK_SIZE); + + if ($read === false) { + break; + } + + $written = fwrite($output, $read); + + if (connection_aborted() || $written === false) { + break; + } + + $length -= $written; + } + + fclose($output); + fclose($file); + } + + public function prepare(Request $request): static + { + parent::prepare($request); + + if (!isset($this->headers['Accept-Ranges']) && $request->method() === RequestMethod::GET) { + $this->headers['Accept-Ranges'] = 'bytes'; + } + + if ($request->method() === RequestMethod::GET && preg_match('/^bytes=(\d+)?-(\d+)?$/', $request->headers()->get('Range', ''), $matches, PREG_UNMATCHED_AS_NULL)) { + [, $start, $end] = $matches; + + if ($start === null) { + $start = max(0, $this->fileSize - (int) $end); + $end = $this->fileSize - 1; + } elseif ($end === null || $end > $this->fileSize - 1) { + $end = $this->fileSize - 1; + } + + $this->offset = (int) $start; + + if ($start > $end) { + $this->length = 0; + $this->responseStatus = ResponseStatus::RangeNotSatisfiable; + $this->headers['Content-Range'] = sprintf('bytes */%s', $this->fileSize); + } else { + $this->length = (int) ($end - $start + 1); + $this->responseStatus = ResponseStatus::PartialContent; + $this->headers['Content-Range'] = sprintf('bytes %s-%s/%s', $start, $end, $this->fileSize); + $this->headers['Content-Length'] = sprintf('%s', $this->length); + } + } + + return $this; } } diff --git a/formwork/src/Http/Response.php b/formwork/src/Http/Response.php index 556180327..4c7f198d8 100644 --- a/formwork/src/Http/Response.php +++ b/formwork/src/Http/Response.php @@ -56,6 +56,14 @@ public function headers(): array return $this->headers; } + /** + * Prepare response according to the given HTTP request + */ + public function prepare(Request $request): static + { + return $this; + } + /** * Send HTTP status */ diff --git a/formwork/src/Http/ResponseInterface.php b/formwork/src/Http/ResponseInterface.php index 17c587ba1..99cf2dc3e 100644 --- a/formwork/src/Http/ResponseInterface.php +++ b/formwork/src/Http/ResponseInterface.php @@ -35,6 +35,11 @@ public function status(): ResponseStatus; */ public function headers(): array; + /** + * Prepare response according to the given HTTP request + */ + public function prepare(Request $request): static; + /** * Send HTTP status */ diff --git a/formwork/src/Panel/Controllers/AbstractController.php b/formwork/src/Panel/Controllers/AbstractController.php index 43750dd23..84e12e1d7 100644 --- a/formwork/src/Panel/Controllers/AbstractController.php +++ b/formwork/src/Panel/Controllers/AbstractController.php @@ -224,6 +224,7 @@ protected function ensurePermission(string $permission): void if (!$this->user()->permissions()->has($permission)) { $this->container->build(ErrorsController::class) ->forbidden() + ->prepare($this->request) ->send(); exit; } diff --git a/formwork/src/Services/Loaders/PanelServiceLoader.php b/formwork/src/Services/Loaders/PanelServiceLoader.php index 4d6fd5f46..485ee737f 100644 --- a/formwork/src/Services/Loaders/PanelServiceLoader.php +++ b/formwork/src/Services/Loaders/PanelServiceLoader.php @@ -66,7 +66,7 @@ public function onResolved(object $service, Container $container): void if ($service->isLoggedIn() && $this->config->get('system.errors.setHandlers')) { $errorsController = $this->container->build(ErrorsController::class); set_exception_handler(function (Throwable $throwable) use ($errorsController): never { - $errorsController->internalServerError($throwable)->send(); + $errorsController->internalServerError($throwable)->prepare($this->request)->send(); throw $throwable; }); } diff --git a/panel/views/fields/partials/filelist.php b/panel/views/fields/partials/filelist.php index dc5e2d49a..bececfec4 100644 --- a/panel/views/fields/partials/filelist.php +++ b/panel/views/fields/partials/filelist.php @@ -14,7 +14,7 @@ type() === 'video') : ?>