From af2891f568a8ad6b03a74cb1025ee8daa39ef68d Mon Sep 17 00:00:00 2001 From: Billtec Date: Fri, 7 Jun 2024 22:46:14 +0800 Subject: [PATCH 01/14] fix: make the jsonHalt a static function as the user guide indicates --- flight/Flight.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flight/Flight.php b/flight/Flight.php index 207d44b3..ecba0402 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -68,7 +68,7 @@ * @method static void redirect(string $url, int $code = 303) Redirects to another URL. * @method static void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * Sends a JSON response. - * @method void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * @method static void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) * Sends a JSON response and immediately halts the request. * @method static void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * Sends a JSONP response. From f06febdb2d12ce25a06639443090bcb545b9356d Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Sat, 29 Jun 2024 16:11:07 -0600 Subject: [PATCH 02/14] changed default cache behavior --- flight/net/Response.php | 29 ++++++++++++++++++-------- tests/ResponseTest.php | 46 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/flight/net/Response.php b/flight/net/Response.php index 1798de51..b02535fd 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -288,13 +288,7 @@ public function cache($expires): self { if ($expires === false) { $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; - - $this->headers['Cache-Control'] = [ - 'no-store, no-cache, must-revalidate', - 'post-check=0, pre-check=0', - 'max-age=0', - ]; - + $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'; $this->headers['Pragma'] = 'no-cache'; } else { $expires = \is_int($expires) ? $expires : strtotime($expires); @@ -437,8 +431,14 @@ public function send(): void $this->processResponseCallbacks(); } - if (headers_sent() === false) { - $this->sendHeaders(); // @codeCoverageIgnore + if ($this->headersSent() === false) { + + // If you haven't set a Cache-Control header, we'll assume you don't want caching + if($this->getHeader('Cache-Control') === null) { + $this->cache(false); + } + + $this->sendHeaders(); } echo $this->body; @@ -446,6 +446,17 @@ public function send(): void $this->sent = true; } + /** + * Headers have been sent + * + * @return bool + * @codeCoverageIgnore + */ + public function headersSent(): bool + { + return headers_sent(); + } + /** * Adds a callback to process the response body before it's sent. These are processed in the order * they are added diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index a163e7ea..de461ebf 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -164,11 +164,7 @@ public function testCacheFalseExpiresValue() $response->cache(false); $this->assertEquals([ 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', - 'Cache-Control' => [ - 'no-store, no-cache, must-revalidate', - 'post-check=0, pre-check=0', - 'max-age=0', - ], + 'Cache-Control' => 'no-store, no-cache, must-revalidate', 'Pragma' => 'no-cache' ], $response->headers()); } @@ -239,6 +235,46 @@ public function setRealHeader(string $header_string, bool $replace = true, int $ $this->assertTrue($response->sent()); } + public function testSendWithNoHeadersSent() + { + $response = new class extends Response { + protected $test_sent_headers = []; + + public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): self + { + $this->test_sent_headers[] = $header_string; + return $this; + } + + public function getSentHeaders(): array + { + return $this->test_sent_headers; + } + + public function headersSent(): bool + { + return false; + } + }; + $response->header('Content-Type', 'text/html'); + $response->header('X-Test', 'test'); + $response->write('Something'); + + $this->expectOutputString('Something'); + + $response->send(); + $sent_headers = $response->getSentHeaders(); + $this->assertEquals([ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html', + 'X-Test: test', + 'Expires: Mon, 26 Jul 1997 05:00:00 GMT', + 'Cache-Control: no-store, no-cache, must-revalidate', + 'Pragma: no-cache', + 'Content-Length: 9' + ], $sent_headers); + } + public function testClearBody() { $response = new Response(); From ba80e047b1bd7459cdd9029a6b104b46324be600 Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Sat, 29 Jun 2024 16:14:08 -0600 Subject: [PATCH 03/14] prettified and added max-age=0 --- flight/net/Response.php | 31 +++++++++++++++---------------- tests/FlightTest.php | 3 +-- tests/ResponseTest.php | 22 +++++++++++----------- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/flight/net/Response.php b/flight/net/Response.php index b02535fd..362fe5d6 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -288,7 +288,7 @@ public function cache($expires): self { if ($expires === false) { $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; - $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'; + $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'; $this->headers['Pragma'] = 'no-cache'; } else { $expires = \is_int($expires) ? $expires : strtotime($expires); @@ -432,11 +432,10 @@ public function send(): void } if ($this->headersSent() === false) { - - // If you haven't set a Cache-Control header, we'll assume you don't want caching - if($this->getHeader('Cache-Control') === null) { - $this->cache(false); - } + // If you haven't set a Cache-Control header, we'll assume you don't want caching + if ($this->getHeader('Cache-Control') === null) { + $this->cache(false); + } $this->sendHeaders(); } @@ -446,16 +445,16 @@ public function send(): void $this->sent = true; } - /** - * Headers have been sent - * - * @return bool - * @codeCoverageIgnore - */ - public function headersSent(): bool - { - return headers_sent(); - } + /** + * Headers have been sent + * + * @return bool + * @codeCoverageIgnore + */ + public function headersSent(): bool + { + return headers_sent(); + } /** * Adds a callback to process the response body before it's sent. These are processed in the order diff --git a/tests/FlightTest.php b/tests/FlightTest.php index 5d196d6f..cf0c9f01 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -361,8 +361,7 @@ public function testDoesNotPreserveVarsWhenFlagIsDisabled( string $output, array $renderParams, string $regexp - ): void - { + ): void { Flight::view()->preserveVars = false; $this->expectOutputString($output); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index de461ebf..b6462943 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -164,7 +164,7 @@ public function testCacheFalseExpiresValue() $response->cache(false); $this->assertEquals([ 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', - 'Cache-Control' => 'no-store, no-cache, must-revalidate', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma' => 'no-cache' ], $response->headers()); } @@ -235,8 +235,8 @@ public function setRealHeader(string $header_string, bool $replace = true, int $ $this->assertTrue($response->sent()); } - public function testSendWithNoHeadersSent() - { + public function testSendWithNoHeadersSent() + { $response = new class extends Response { protected $test_sent_headers = []; @@ -251,16 +251,16 @@ public function getSentHeaders(): array return $this->test_sent_headers; } - public function headersSent(): bool - { - return false; - } + public function headersSent(): bool + { + return false; + } }; $response->header('Content-Type', 'text/html'); $response->header('X-Test', 'test'); $response->write('Something'); - $this->expectOutputString('Something'); + $this->expectOutputString('Something'); $response->send(); $sent_headers = $response->getSentHeaders(); @@ -268,9 +268,9 @@ public function headersSent(): bool 'HTTP/1.1 200 OK', 'Content-Type: text/html', 'X-Test: test', - 'Expires: Mon, 26 Jul 1997 05:00:00 GMT', - 'Cache-Control: no-store, no-cache, must-revalidate', - 'Pragma: no-cache', + 'Expires: Mon, 26 Jul 1997 05:00:00 GMT', + 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0', + 'Pragma: no-cache', 'Content-Length: 9' ], $sent_headers); } From 4a93c661a852f4241ad90eaf8ddde8fab19ca313 Mon Sep 17 00:00:00 2001 From: n0nag0n Date: Fri, 12 Jul 2024 21:40:58 -0600 Subject: [PATCH 04/14] corrected the cache behavior in some areas --- flight/Engine.php | 5 +++++ flight/net/Response.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/flight/Engine.php b/flight/Engine.php index a1378a61..4dd595f3 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -610,6 +610,7 @@ public function _error(Throwable $e): void try { $this->response() + ->cache(0) ->clearBody() ->status(500) ->write($msg) @@ -736,6 +737,10 @@ public function _delete(string $pattern, $callback, bool $pass_route = false, st */ public function _halt(int $code = 200, string $message = '', bool $actuallyExit = true): void { + if ($this->response()->getHeader('Cache-Control') === null) { + $this->response()->cache(0); + } + $this->response() ->clearBody() ->status($code) diff --git a/flight/net/Response.php b/flight/net/Response.php index 362fe5d6..73be7705 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -286,7 +286,7 @@ public function clear(): self */ public function cache($expires): self { - if ($expires === false) { + if ($expires === false || $expires === 0) { $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; $this->headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'; $this->headers['Pragma'] = 'no-cache'; From f08b9bcbfbd94645c2050255f579951232fff468 Mon Sep 17 00:00:00 2001 From: Pierre Clavequin Date: Sat, 27 Jul 2024 00:02:36 +0800 Subject: [PATCH 05/14] 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 06/14] 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( << Date: Wed, 21 Aug 2024 09:00:30 -0600 Subject: [PATCH 07/14] Added ability to handle file uploads in a simple way --- flight/Engine.php | 49 +++-------- flight/net/Request.php | 59 ++++++++++++++ flight/net/Response.php | 38 +++++++++ flight/net/UploadedFile.php | 157 ++++++++++++++++++++++++++++++++++++ tests/RequestTest.php | 116 ++++++++++++++++++-------- tests/UploadedFileTest.php | 56 +++++++++++++ tests/server/index.php | 2 +- 7 files changed, 406 insertions(+), 71 deletions(-) create mode 100644 flight/net/UploadedFile.php create mode 100644 tests/UploadedFileTest.php diff --git a/flight/Engine.php b/flight/Engine.php index 3f08e653..ebc23192 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -896,43 +896,18 @@ public function _jsonp( } } - /** - * 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($filePath); - - $mimeType = mime_content_type($filePath); - $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; - - $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 - ob_clean(); - flush(); - - // // Read the file and send it to the output buffer - readfile($filePath); - if(empty(getenv('PHPUNIT_TEST'))) { - exit; // @codeCoverageIgnore - } + /** + * 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 + { + $this->response()->downloadFile($filePath); } /** diff --git a/flight/net/Request.php b/flight/net/Request.php index fd9194b8..9cdc64be 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -414,4 +414,63 @@ public static function getScheme(): string return 'http'; } + + /** + * Retrieves the array of uploaded files. + * + * @return array|array>> The array of uploaded files. + */ + public function getUploadedFiles(): array + { + $files = []; + $correctedFilesArray = $this->reArrayFiles($this->files); + foreach ($correctedFilesArray as $keyName => $files) { + foreach ($files as $file) { + $UploadedFile = new UploadedFile( + $file['name'], + $file['type'], + $file['size'], + $file['tmp_name'], + $file['error'] + ); + if (count($files) > 1) { + $files[$keyName][] = $UploadedFile; + } else { + $files[$keyName] = $UploadedFile; + } + } + } + + return $files; + } + + /** + * Re-arranges the files in the given files collection. + * + * @param Collection $filesCollection The collection of files to be re-arranged. + * + * @return array>> The re-arranged files collection. + */ + protected function reArrayFiles(Collection $filesCollection): array + { + + $fileArray = []; + foreach ($filesCollection as $fileKeyName => $file) { + $isMulti = is_array($file['name']) === true && count($file['name']) > 1; + $fileCount = $isMulti === true ? count($file['name']) : 1; + $fileKeys = array_keys($file); + + for ($i = 0; $i < $fileCount; $i++) { + foreach ($fileKeys as $key) { + if ($isMulti === true) { + $fileArray[$fileKeyName][$i][$key] = $file[$key][$i]; + } else { + $fileArray[$fileKeyName][$i][$key] = $file[$key]; + } + } + } + } + + return $fileArray; + } } diff --git a/flight/net/Response.php b/flight/net/Response.php index 73be7705..264174ec 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -480,4 +480,42 @@ protected function processResponseCallbacks(): void $this->body = $callback($this->body); } } + + /** + * Downloads a file. + * + * @param string $filePath The path to the file to be downloaded. + * + * @return void + */ + public function downloadFile(string $filePath): void + { + if (file_exists($filePath) === false) { + throw new Exception("$filePath cannot be found."); + } + + $fileSize = filesize($filePath); + + $mimeType = mime_content_type($filePath); + $mimeType = $mimeType !== false ? $mimeType : 'application/octet-stream'; + + $this->send(); + $this->setRealHeader('Content-Description: File Transfer'); + $this->setRealHeader('Content-Type: ' . $mimeType); + $this->setRealHeader('Content-Disposition: attachment; filename="' . basename($filePath) . '"'); + $this->setRealHeader('Expires: 0'); + $this->setRealHeader('Cache-Control: must-revalidate'); + $this->setRealHeader('Pragma: public'); + $this->setRealHeader('Content-Length: ' . $fileSize); + + // // Clear the output buffer + ob_clean(); + flush(); + + // // Read the file and send it to the output buffer + readfile($filePath); + if (empty(getenv('PHPUNIT_TEST'))) { + exit; // @codeCoverageIgnore + } + } } diff --git a/flight/net/UploadedFile.php b/flight/net/UploadedFile.php new file mode 100644 index 00000000..2b3947be --- /dev/null +++ b/flight/net/UploadedFile.php @@ -0,0 +1,157 @@ +name = $name; + $this->mimeType = $mimeType; + $this->size = $size; + $this->tmpName = $tmpName; + $this->error = $error; + } + + /** + * Retrieves the client-side filename of the uploaded file. + * + * @return string The client-side filename. + */ + public function getClientFilename(): string + { + return $this->name; + } + + /** + * Retrieves the media type of the uploaded file as provided by the client. + * + * @return string The media type of the uploaded file. + */ + public function getClientMediaType(): string + { + return $this->mimeType; + } + + /** + * Returns the size of the uploaded file. + * + * @return int The size of the uploaded file. + */ + public function getSize(): int + { + return $this->size; + } + + /** + * Retrieves the temporary name of the uploaded file. + * + * @return string The temporary name of the uploaded file. + */ + public function getTempName(): string + { + return $this->tmpName; + } + + /** + * Get the error code associated with the uploaded file. + * + * @return int The error code. + */ + public function getError(): int + { + return $this->error; + } + + /** + * Moves the uploaded file to the specified target path. + * + * @param string $targetPath The path to move the file to. + * + * @return void + */ + public function moveTo(string $targetPath): void + { + if ($this->error !== UPLOAD_ERR_OK) { + throw new Exception($this->getUploadErrorMessage($this->error)); + } + + $isUploadedFile = is_uploaded_file($this->tmpName) === true; + if ( + $isUploadedFile === true + && + move_uploaded_file($this->tmpName, $targetPath) === false + ) { + throw new Exception('Cannot move uploaded file'); // @codeCoverageIgnore + } elseif ($isUploadedFile === false && getenv('PHPUNIT_TEST')) { + rename($this->tmpName, $targetPath); + } + } + + /** + * Retrieves the error message for a given upload error code. + * + * @param int $error The upload error code. + * + * @return string The error message. + */ + protected function getUploadErrorMessage(int $error): string + { + switch ($error) { + case UPLOAD_ERR_INI_SIZE: + return 'The uploaded file exceeds the upload_max_filesize directive in php.ini.'; + case UPLOAD_ERR_FORM_SIZE: + return 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.'; + case UPLOAD_ERR_PARTIAL: + return 'The uploaded file was only partially uploaded.'; + case UPLOAD_ERR_NO_FILE: + return 'No file was uploaded.'; + case UPLOAD_ERR_NO_TMP_DIR: + return 'Missing a temporary folder.'; + case UPLOAD_ERR_CANT_WRITE: + return 'Failed to write file to disk.'; + case UPLOAD_ERR_EXTENSION: + return 'A PHP extension stopped the file upload.'; + default: + return 'An unknown error occurred. Error code: ' . $error; + } + } +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php index a8b4310d..9b4c2344 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -41,23 +41,23 @@ protected function tearDown(): void public function testDefaults() { - self::assertEquals('/', $this->request->url); - self::assertEquals('/', $this->request->base); - self::assertEquals('GET', $this->request->method); - self::assertEquals('', $this->request->referrer); - self::assertTrue($this->request->ajax); - self::assertEquals('http', $this->request->scheme); - self::assertEquals('', $this->request->type); - self::assertEquals(0, $this->request->length); - self::assertFalse($this->request->secure); - self::assertEquals('', $this->request->accept); - self::assertEquals('example.com', $this->request->host); + $this->assertEquals('/', $this->request->url); + $this->assertEquals('/', $this->request->base); + $this->assertEquals('GET', $this->request->method); + $this->assertEquals('', $this->request->referrer); + $this->assertTrue($this->request->ajax); + $this->assertEquals('http', $this->request->scheme); + $this->assertEquals('', $this->request->type); + $this->assertEquals(0, $this->request->length); + $this->assertFalse($this->request->secure); + $this->assertEquals('', $this->request->accept); + $this->assertEquals('example.com', $this->request->host); } public function testIpAddress() { - self::assertEquals('8.8.8.8', $this->request->ip); - self::assertEquals('32.32.32.32', $this->request->proxy_ip); + $this->assertEquals('8.8.8.8', $this->request->ip); + $this->assertEquals('32.32.32.32', $this->request->proxy_ip); } public function testSubdirectory() @@ -66,7 +66,7 @@ public function testSubdirectory() $request = new Request(); - self::assertEquals('/subdir', $request->base); + $this->assertEquals('/subdir', $request->base); } public function testQueryParameters() @@ -75,9 +75,9 @@ public function testQueryParameters() $request = new Request(); - self::assertEquals('/page?id=1&name=bob', $request->url); - self::assertEquals(1, $request->query->id); - self::assertEquals('bob', $request->query->name); + $this->assertEquals('/page?id=1&name=bob', $request->url); + $this->assertEquals(1, $request->query->id); + $this->assertEquals('bob', $request->query->name); } public function testCollections() @@ -91,11 +91,11 @@ public function testCollections() $request = new Request(); - self::assertEquals(1, $request->query->q); - self::assertEquals(1, $request->query->id); - self::assertEquals(1, $request->data->q); - self::assertEquals(1, $request->cookies->q); - self::assertEquals(1, $request->files->q); + $this->assertEquals(1, $request->query->q); + $this->assertEquals(1, $request->query->id); + $this->assertEquals(1, $request->data->q); + $this->assertEquals(1, $request->cookies->q); + $this->assertEquals(1, $request->files->q); } public function testJsonWithEmptyBody() @@ -104,7 +104,7 @@ public function testJsonWithEmptyBody() $request = new Request(); - self::assertSame([], $request->data->getData()); + $this->assertSame([], $request->data->getData()); } public function testMethodOverrideWithHeader() @@ -113,7 +113,7 @@ public function testMethodOverrideWithHeader() $request = new Request(); - self::assertEquals('PUT', $request->method); + $this->assertEquals('PUT', $request->method); } public function testMethodOverrideWithPost() @@ -122,38 +122,38 @@ public function testMethodOverrideWithPost() $request = new Request(); - self::assertEquals('PUT', $request->method); + $this->assertEquals('PUT', $request->method); } public function testHttps() { $_SERVER['HTTPS'] = 'on'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['HTTPS'] = 'off'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'http'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); $_SERVER['HTTP_FRONT_END_HTTPS'] = 'on'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['HTTP_FRONT_END_HTTPS'] = 'off'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); $_SERVER['REQUEST_SCHEME'] = 'https'; $request = new Request(); - self::assertEquals('https', $request->scheme); + $this->assertEquals('https', $request->scheme); $_SERVER['REQUEST_SCHEME'] = 'http'; $request = new Request(); - self::assertEquals('http', $request->scheme); + $this->assertEquals('http', $request->scheme); } public function testInitUrlSameAsBaseDirectory() @@ -279,4 +279,54 @@ public function testGetBaseUrlWithHttps() $request = new Request(); $this->assertEquals('https://localhost:8000', $request->getBaseUrl()); } + + public function testGetSingleFileUpload() + { + $_FILES['file'] = [ + 'name' => 'file.txt', + 'type' => 'text/plain', + 'size' => 123, + 'tmp_name' => '/tmp/php123', + 'error' => 0 + ]; + + $request = new Request(); + + $file = $request->getUploadedFiles()['file']; + + $this->assertEquals('file.txt', $file->getClientFilename()); + $this->assertEquals('text/plain', $file->getClientMediaType()); + $this->assertEquals(123, $file->getSize()); + $this->assertEquals('/tmp/php123', $file->getTempName()); + $this->assertEquals(0, $file->getError()); + } + + public function testGetMultiFileUpload() + { + $_FILES['files'] = [ + 'name' => ['file1.txt', 'file2.txt'], + 'type' => ['text/plain', 'text/plain'], + 'size' => [123, 456], + 'tmp_name' => ['/tmp/php123', '/tmp/php456'], + 'error' => [0, 0] + ]; + + $request = new Request(); + + $files = $request->getUploadedFiles()['files']; + + $this->assertCount(2, $files); + + $this->assertEquals('file1.txt', $files[0]->getClientFilename()); + $this->assertEquals('text/plain', $files[0]->getClientMediaType()); + $this->assertEquals(123, $files[0]->getSize()); + $this->assertEquals('/tmp/php123', $files[0]->getTempName()); + $this->assertEquals(0, $files[0]->getError()); + + $this->assertEquals('file2.txt', $files[1]->getClientFilename()); + $this->assertEquals('text/plain', $files[1]->getClientMediaType()); + $this->assertEquals(456, $files[1]->getSize()); + $this->assertEquals('/tmp/php456', $files[1]->getTempName()); + $this->assertEquals(0, $files[1]->getError()); + } } diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php new file mode 100644 index 00000000..94d9f75f --- /dev/null +++ b/tests/UploadedFileTest.php @@ -0,0 +1,56 @@ +moveTo('file.txt'); + $this->assertFileExists('file.txt'); + } + + public function getFileErrorMessageTests(): array + { + return [ + [ UPLOAD_ERR_INI_SIZE, 'The uploaded file exceeds the upload_max_filesize directive in php.ini.', ], + [ UPLOAD_ERR_FORM_SIZE, 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', ], + [ UPLOAD_ERR_PARTIAL, 'The uploaded file was only partially uploaded.', ], + [ UPLOAD_ERR_NO_FILE, 'No file was uploaded.', ], + [ UPLOAD_ERR_NO_TMP_DIR, 'Missing a temporary folder.', ], + [ UPLOAD_ERR_CANT_WRITE, 'Failed to write file to disk.', ], + [ UPLOAD_ERR_EXTENSION, 'A PHP extension stopped the file upload.', ], + [ -1, 'An unknown error occurred. Error code: -1' ] + ]; + } + + /** + * @dataProvider getFileErrorMessageTests + */ + public function testMoveToFailureMessages($error, $message) + { + file_put_contents('tmp_name', 'test'); + $uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', $error); + $this->expectException(Exception::class); + $this->expectExceptionMessage($message); + $uploadedFile->moveTo('file.txt'); + } +} diff --git a/tests/server/index.php b/tests/server/index.php index 8bb04984..a572ed8a 100644 --- a/tests/server/index.php +++ b/tests/server/index.php @@ -177,7 +177,7 @@ // Download a file Flight::route('/download', function () { - Flight::download('test_file.txt'); + Flight::download('test_file.txt'); }); Flight::map('error', function (Throwable $e) { From c5caa0d7e233a6e1beb62c2f3c798c04cfcf21ce Mon Sep 17 00:00:00 2001 From: lubiana Date: Tue, 27 Aug 2024 18:41:41 +0200 Subject: [PATCH 08/14] add case for Macos in ResponseTest::testResponseBodyCallbackGzip --- tests/ResponseTest.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index b6462943..fade3226 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -318,7 +318,16 @@ public function testResponseBodyCallbackGzip() ob_start(); $response->send(); $gzip_body = ob_get_clean(); - $expected = PHP_OS === 'WINNT' ? 'H4sIAAAAAAAACitJLS4BAAx+f9gEAAAA' : 'H4sIAAAAAAAAAytJLS4BAAx+f9gEAAAA'; + switch (PHP_OS) { + case 'WINNT': + $expected = 'H4sIAAAAAAAACitJLS4BAAx+f9gEAAAA'; + break; + case 'Darwin': + $expected = 'H4sIAAAAAAAAEytJLS4BAAx+f9gEAAAA'; + break; + default: + $expected = 'H4sIAAAAAAAAAytJLS4BAAx+f9gEAAAA'; + } $this->assertEquals($expected, base64_encode($gzip_body)); $this->assertEquals(strlen(gzencode('test')), strlen($gzip_body)); } From 720deac36ee15868bd187eba1fba87853d34aefd Mon Sep 17 00:00:00 2001 From: lubiana Date: Tue, 27 Aug 2024 20:25:55 +0200 Subject: [PATCH 09/14] add unit-test workflow --- .github/workflows/test.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..75f03e18 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Pull Request Check +on: [pull_request] + +jobs: + unit-test: + name: Unit testing + strategy: + fail-fast: false + matrix: + php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4] + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring + tools: composer:v2 + - run: composer install + - run: composer test \ No newline at end of file From 2d36b79f3215323a376f153a4d7a43c32e7a314e Mon Sep 17 00:00:00 2001 From: lubiana Date: Tue, 27 Aug 2024 21:31:11 +0200 Subject: [PATCH 10/14] fix tests on different php versions --- tests/EngineTest.php | 11 +++++++---- tests/commands/ControllerCommandTest.php | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/EngineTest.php b/tests/EngineTest.php index d4bf2430..82b7294a 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -813,12 +813,15 @@ public function testContainerDicePdoWrapperTestBadParams() { $engine->request()->url = '/container'; // php 7.4 will throw a PDO exception, but php 8 will throw an ErrorException - if(version_compare(PHP_VERSION, '8.0.0', '<')) { - $this->expectException(PDOException::class); - $this->expectExceptionMessageMatches("/invalid data source name/"); - } else { + if(version_compare(PHP_VERSION, '8.1.0') >= 0) { $this->expectException(ErrorException::class); $this->expectExceptionMessageMatches("/Passing null to parameter/"); + } elseif(version_compare(PHP_VERSION, '8.0.0') >= 0) { + $this->expectException(PDOException::class); + $this->expectExceptionMessageMatches("/must be a valid data source name/"); + } else { + $this->expectException(PDOException::class); + $this->expectExceptionMessageMatches("/invalid data source name/"); } $engine->start(); diff --git a/tests/commands/ControllerCommandTest.php b/tests/commands/ControllerCommandTest.php index 82fb0c1c..c333b755 100644 --- a/tests/commands/ControllerCommandTest.php +++ b/tests/commands/ControllerCommandTest.php @@ -68,6 +68,8 @@ public function testControllerAlreadyExists() public function testCreateController() { + + $this->markTestIncomplete('does not work on php > 8.0'); $app = $this->newApp('test', '0.0.1'); $app->add(new ControllerCommand(['app_root' => 'tests/commands/'])); $app->handle(['runway', 'make:controller', 'Test']); From 6c365a00e9e0b365d880cca90021215431fd39dd Mon Sep 17 00:00:00 2001 From: lubiana Date: Tue, 27 Aug 2024 21:37:49 +0200 Subject: [PATCH 11/14] remove unit tests on 8.4 (for now) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75f03e18..30ff6e36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: strategy: fail-fast: false matrix: - php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4] + php: [7.4, 8.0, 8.1, 8.2, 8.3] runs-on: ubuntu-latest steps: - name: Checkout repository From 0fdc300f334ae828c22695f7825aa022541cbdd8 Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Thu, 29 Aug 2024 07:32:35 -0600 Subject: [PATCH 12/14] updated runway dependency version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bf59bbf4..6fec7c52 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ }, "require-dev": { "ext-pdo_sqlite": "*", - "flightphp/runway": "^0.2.0", + "flightphp/runway": "^0.2.3", "league/container": "^4.2", "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.3", From 4276c2baeb182b08cd7b8cc31ad1f9cba135507a Mon Sep 17 00:00:00 2001 From: lubiana Date: Thu, 29 Aug 2024 15:39:01 +0200 Subject: [PATCH 13/14] reenable comtroller command test --- tests/commands/ControllerCommandTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/commands/ControllerCommandTest.php b/tests/commands/ControllerCommandTest.php index c333b755..82fb0c1c 100644 --- a/tests/commands/ControllerCommandTest.php +++ b/tests/commands/ControllerCommandTest.php @@ -68,8 +68,6 @@ public function testControllerAlreadyExists() public function testCreateController() { - - $this->markTestIncomplete('does not work on php > 8.0'); $app = $this->newApp('test', '0.0.1'); $app->add(new ControllerCommand(['app_root' => 'tests/commands/'])); $app->handle(['runway', 'make:controller', 'Test']); From 06fa9e5e515fdc16aa80c45693e63f220aa39617 Mon Sep 17 00:00:00 2001 From: lubiana Date: Thu, 29 Aug 2024 15:56:30 +0200 Subject: [PATCH 14/14] allow newer runway version in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6fec7c52..23b6b9ee 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ }, "require-dev": { "ext-pdo_sqlite": "*", - "flightphp/runway": "^0.2.3", + "flightphp/runway": "^0.2.3 || ^1.0", "league/container": "^4.2", "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.3",