Merge pull request #590 from getformwork/feature/proper-file-responses

Proper `FileResponse`
This commit is contained in:
Giuseppe Criscione 2024-10-21 22:23:18 +02:00 committed by GitHub
commit dff5387074
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 111 additions and 9 deletions

View File

@ -138,9 +138,11 @@ final class App
DynamicFieldValue::$vars = $this->container->call(require $this->config()->get('system.fields.dynamic.vars.file')); DynamicFieldValue::$vars = $this->container->call(require $this->config()->get('system.fields.dynamic.vars.file'));
$request = $this->request();
$response = $this->router()->dispatch(); $response = $this->router()->dispatch();
$response->send(); $response->prepare($request)->send();
return $response; return $response;
} }

View File

@ -34,11 +34,11 @@ class ErrorHandlers
Response::cleanOutputBuffers(); Response::cleanOutputBuffers();
if ($this->request->isXmlHttpRequest()) { if ($this->request->isXmlHttpRequest()) {
JsonResponse::error('Error', $responseStatus)->send(); JsonResponse::error('Error', $responseStatus)->prepare($this->request)->send();
} else { } else {
$view = $this->viewFactory->make('errors.error', ['status' => $responseStatus->code(), 'message' => $responseStatus->message(), 'throwable' => $throwable]); $view = $this->viewFactory->make('errors.error', ['status' => $responseStatus->code(), 'message' => $responseStatus->message(), 'throwable' => $throwable]);
$response = new Response($view->render(), $responseStatus); $response = new Response($view->render(), $responseStatus);
$response->send(); $response->prepare($this->request)->send();
// Don't exit, otherwise the error will not be logged // Don't exit, otherwise the error will not be logged
} }
} }

View File

@ -4,20 +4,32 @@ namespace Formwork\Http;
use Formwork\Http\Utils\Header; use Formwork\Http\Utils\Header;
use Formwork\Utils\FileSystem; use Formwork\Utils\FileSystem;
use RuntimeException;
class FileResponse extends Response class FileResponse extends Response
{ {
protected const CHUNK_SIZE = 512 * 1024;
protected int $fileSize;
protected int $offset = 0;
protected int $length;
/** /**
* @inheritdoc * @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 += [ $headers += [
'Content-Type' => FileSystem::mimeType($path), 'Content-Type' => FileSystem::mimeType($path),
'Content-Disposition' => $download ? Header::make(['attachment', 'filename' => basename($path)]) : 'inline', '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 @@ class FileResponse extends Response
public function send(): void public function send(): void
{ {
parent::cleanOutputBuffers(); 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;
} }
} }

View File

@ -56,6 +56,14 @@ class Response implements ResponseInterface
return $this->headers; return $this->headers;
} }
/**
* Prepare response according to the given HTTP request
*/
public function prepare(Request $request): static
{
return $this;
}
/** /**
* Send HTTP status * Send HTTP status
*/ */

View File

@ -35,6 +35,11 @@ interface ResponseInterface extends ArraySerializable
*/ */
public function headers(): array; public function headers(): array;
/**
* Prepare response according to the given HTTP request
*/
public function prepare(Request $request): static;
/** /**
* Send HTTP status * Send HTTP status
*/ */

View File

@ -224,6 +224,7 @@ abstract class AbstractController extends BaseAbstractController
if (!$this->user()->permissions()->has($permission)) { if (!$this->user()->permissions()->has($permission)) {
$this->container->build(ErrorsController::class) $this->container->build(ErrorsController::class)
->forbidden() ->forbidden()
->prepare($this->request)
->send(); ->send();
exit; exit;
} }

View File

@ -66,7 +66,7 @@ class PanelServiceLoader implements ResolutionAwareServiceLoaderInterface
if ($service->isLoggedIn() && $this->config->get('system.errors.setHandlers')) { if ($service->isLoggedIn() && $this->config->get('system.errors.setHandlers')) {
$errorsController = $this->container->build(ErrorsController::class); $errorsController = $this->container->build(ErrorsController::class);
set_exception_handler(function (Throwable $throwable) use ($errorsController): never { set_exception_handler(function (Throwable $throwable) use ($errorsController): never {
$errorsController->internalServerError($throwable)->send(); $errorsController->internalServerError($throwable)->prepare($this->request)->send();
throw $throwable; throw $throwable;
}); });
} }

View File

@ -14,7 +14,7 @@
<?php endif ?> <?php endif ?>
<?php if ($file->type() === 'video') : ?> <?php if ($file->type() === 'video') : ?>
<video class="file-thumbnail"> <video class="file-thumbnail">
<source src="<?= $file->uri() ?>" type="<?= $file->mimeType() ?>" /> <source src="<?= $file->uri() ?>" type="<?= $file->mimeType() ?>" preload="metadata" />
</video> </video>
<?php endif ?> <?php endif ?>
<div class="file-icon"><?= $this->icon(is_null($file->type()) ? 'file' : 'file-' . $file->type()) ?></div> <div class="file-icon"><?= $this->icon(is_null($file->type()) ? 'file' : 'file-' . $file->type()) ?></div>