diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..30ff6e3 --- /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] + 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 diff --git a/composer.json b/composer.json index bf59bbf..23b6b9e 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 || ^1.0", "league/container": "^4.2", "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.3", diff --git a/flight/Engine.php b/flight/Engine.php index 1235229..df06a8c 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -64,9 +64,10 @@ use flight\net\Route; * @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 */ @@ -78,7 +79,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', 'resource' + 'post', 'put', 'patch', 'delete', 'group', 'getUrl', 'download', 'resource' ]; /** @var array Stored variables. */ @@ -600,7 +601,10 @@ class Engine public function _error(Throwable $e): void { $msg = sprintf( - <<500 Internal Server Error

%s (%s)

%s
@@ -612,6 +616,7 @@ class Engine try { $this->response() + ->cache(0) ->clearBody() ->status(500) ->write($msg) @@ -752,6 +757,10 @@ class Engine */ 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) @@ -906,6 +915,20 @@ class Engine } } + /** + * 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); + } + /** * Handles ETag HTTP caching. * diff --git a/flight/Flight.php b/flight/Flight.php index 287d43a..61de281 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -70,16 +70,17 @@ require_once __DIR__ . '/autoload.php'; * @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. * @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/flight/net/Request.php b/flight/net/Request.php index fd9194b..9cdc64b 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -414,4 +414,63 @@ class Request 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 1798de5..264174e 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -286,15 +286,9 @@ class Response */ 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', - 'post-check=0, pre-check=0', - 'max-age=0', - ]; - + $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); @@ -437,8 +431,13 @@ class Response $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 +445,17 @@ class Response $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 @@ -470,4 +480,42 @@ class Response $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 0000000..2b3947b --- /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/EngineTest.php b/tests/EngineTest.php index 93f3ff7..82b7294 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -813,12 +813,15 @@ class EngineTest extends TestCase $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(); @@ -952,4 +955,38 @@ class EngineTest extends TestCase $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/RequestTest.php b/tests/RequestTest.php index a8b4310..9b4c234 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -41,23 +41,23 @@ class RequestTest extends TestCase 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 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('/subdir', $request->base); + $this->assertEquals('/subdir', $request->base); } public function testQueryParameters() @@ -75,9 +75,9 @@ class RequestTest extends TestCase $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 @@ class RequestTest extends TestCase $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 @@ class RequestTest extends TestCase $request = new Request(); - self::assertSame([], $request->data->getData()); + $this->assertSame([], $request->data->getData()); } public function testMethodOverrideWithHeader() @@ -113,7 +113,7 @@ class RequestTest extends TestCase $request = new Request(); - self::assertEquals('PUT', $request->method); + $this->assertEquals('PUT', $request->method); } public function testMethodOverrideWithPost() @@ -122,38 +122,38 @@ class RequestTest extends TestCase $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 @@ class RequestTest extends TestCase $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/ResponseTest.php b/tests/ResponseTest.php index a163e7e..fade322 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -164,11 +164,7 @@ class ResponseTest extends TestCase $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, max-age=0', 'Pragma' => 'no-cache' ], $response->headers()); } @@ -239,6 +235,46 @@ class ResponseTest extends TestCase $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, max-age=0', + 'Pragma: no-cache', + 'Content-Length: 9' + ], $sent_headers); + } + public function testClearBody() { $response = new Response(); @@ -282,7 +318,16 @@ class ResponseTest extends TestCase 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)); } diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php new file mode 100644 index 0000000..94d9f75 --- /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/LayoutMiddleware.php b/tests/server/LayoutMiddleware.php index 2d55f24..719d8cc 100644 --- a/tests/server/LayoutMiddleware.php +++ b/tests/server/LayoutMiddleware.php @@ -86,6 +86,7 @@ class LayoutMiddleware
  • 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 5c86d11..a572ed8 100644 --- a/tests/server/index.php +++ b/tests/server/index.php @@ -175,6 +175,11 @@ Flight::route('/json-halt', function () { 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( <<