From f08b9bcbfbd94645c2050255f579951232fff468 Mon Sep 17 00:00:00 2001 From: Pierre Clavequin Date: Sat, 27 Jul 2024 00:02:36 +0800 Subject: [PATCH 1/2] feat: method to download files easily --- flight/Engine.php | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/flight/Engine.php b/flight/Engine.php index 4dd595f3..e84a7429 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -76,7 +76,7 @@ class Engine private const MAPPABLE_METHODS = [ 'start', 'stop', 'route', 'halt', 'error', 'notFound', 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp', - 'post', 'put', 'patch', 'delete', 'group', 'getUrl' + 'post', 'put', 'patch', 'delete', 'group', 'getUrl', 'download' ]; /** @var array Stored variables. */ @@ -895,6 +895,31 @@ public function _jsonp( } } + public function _download(string $file): void { + if (!file_exists($file)) { + throw new Exception("$file cannot be found."); + } + + $fileSize = filesize($file); + + $mimeType = mime_content_type($file); + + header('Content-Description: File Transfer'); + header('Content-Type: ' . $mimeType); + header('Content-Disposition: attachment; filename="' . basename($file) . '"'); + header('Expires: 0'); + header('Cache-Control: must-revalidate'); + header('Pragma: public'); + header('Content-Length: ' . $fileSize); + + // Clear the output buffer + ob_clean(); + flush(); + + // Read the file and send it to the output buffer + readfile($file); + } + /** * Handles ETag HTTP caching. * From f697e30afa89dfaf9b1a2c796d080e7bf123a1e3 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 26 Jul 2024 21:07:27 -0600 Subject: [PATCH 2/2] added unit and integration tests --- flight/Engine.php | 47 ++++++++++++++++++++----------- flight/Flight.php | 3 +- tests/EngineTest.php | 34 ++++++++++++++++++++++ tests/server/LayoutMiddleware.php | 1 + tests/server/index.php | 5 ++++ tests/server/test_file.txt | 1 + 6 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 tests/server/test_file.txt diff --git a/flight/Engine.php b/flight/Engine.php index e84a7429..3f08e653 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -62,9 +62,10 @@ * @method void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) * Sends a JSONP response. * - * # HTTP caching + * # HTTP methods * @method void etag(string $id, ('strong'|'weak') $type = 'strong') Handles ETag HTTP caching. * @method void lastModified(int $time) Handles last modified HTTP caching. + * @method void download(string $filePath) Downloads a file * * phpcs:disable PSR2.Methods.MethodDeclaration.Underscore */ @@ -895,29 +896,43 @@ public function _jsonp( } } - public function _download(string $file): void { - if (!file_exists($file)) { - throw new Exception("$file cannot be found."); + /** + * Downloads a file + * + * @param string $filePath The path to the file to download + * @throws Exception If the file cannot be found + * + * @return void + */ + public function _download(string $filePath): void { + if (file_exists($filePath) === false) { + throw new Exception("$filePath cannot be found."); } - $fileSize = filesize($file); + $fileSize = filesize($filePath); - $mimeType = mime_content_type($file); + $mimeType = mime_content_type($filePath); + $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; - header('Content-Description: File Transfer'); - header('Content-Type: ' . $mimeType); - header('Content-Disposition: attachment; filename="' . basename($file) . '"'); - header('Expires: 0'); - header('Cache-Control: must-revalidate'); - header('Pragma: public'); - header('Content-Length: ' . $fileSize); + $response = $this->response(); + $response->send(); + $response->setRealHeader('Content-Description: File Transfer'); + $response->setRealHeader('Content-Type: ' . $mimeType); + $response->setRealHeader('Content-Disposition: attachment; filename="' . basename($filePath) . '"'); + $response->setRealHeader('Expires: 0'); + $response->setRealHeader('Cache-Control: must-revalidate'); + $response->setRealHeader('Pragma: public'); + $response->setRealHeader('Content-Length: ' . $fileSize); - // Clear the output buffer + // // Clear the output buffer ob_clean(); flush(); - // Read the file and send it to the output buffer - readfile($file); + // // Read the file and send it to the output buffer + readfile($filePath); + if(empty(getenv('PHPUNIT_TEST'))) { + exit; // @codeCoverageIgnore + } } /** diff --git a/flight/Flight.php b/flight/Flight.php index ecba0402..7002e66a 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -75,9 +75,10 @@ * @method static void error(Throwable $exception) Sends an HTTP 500 response. * @method static void notFound() Sends an HTTP 404 response. * - * # HTTP caching + * # HTTP methods * @method static void etag(string $id, ('strong'|'weak') $type = 'strong') Performs ETag HTTP caching. * @method static void lastModified(int $time) Performs last modified HTTP caching. + * @method static void download(string $filePath) Downloads a file */ class Flight { diff --git a/tests/EngineTest.php b/tests/EngineTest.php index 93f3ff75..d4bf2430 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -952,4 +952,38 @@ public function setRealHeader( $this->assertEquals('Method Not Allowed', $engine->response()->getBody()); } + public function testDownload() + { + $engine = new class extends Engine { + public function getLoader() + { + return $this->loader; + } + }; + // doing this so we can overwrite some parts of the response + $engine->getLoader()->register('response', function () { + return new class extends Response { + public function setRealHeader( + string $header_string, + bool $replace = true, + int $response_code = 0 + ): self { + return $this; + } + }; + }); + $tmpfile = tmpfile(); + fwrite($tmpfile, 'I am a teapot'); + $streamPath = stream_get_meta_data($tmpfile)['uri']; + $this->expectOutputString('I am a teapot'); + $engine->download($streamPath); + } + + public function testDownloadBadPath() { + $engine = new Engine(); + $this->expectException(Exception::class); + $this->expectExceptionMessage("/path/to/nowhere cannot be found."); + $engine->download('/path/to/nowhere'); + } + } diff --git a/tests/server/LayoutMiddleware.php b/tests/server/LayoutMiddleware.php index 2d55f242..719d8cc6 100644 --- a/tests/server/LayoutMiddleware.php +++ b/tests/server/LayoutMiddleware.php @@ -86,6 +86,7 @@ public function before()
  • Dice Container
  • No Container Registered
  • Pascal_Snake_Case
  • +
  • Download File
  • HTML; echo '
    '; diff --git a/tests/server/index.php b/tests/server/index.php index 5c86d114..8bb04984 100644 --- a/tests/server/index.php +++ b/tests/server/index.php @@ -175,6 +175,11 @@ Flight::jsonHalt(['message' => 'JSON rendered and halted successfully with no other body content!']); }); +// Download a file +Flight::route('/download', function () { + Flight::download('test_file.txt'); +}); + Flight::map('error', function (Throwable $e) { echo sprintf( <<