From b6220f7dc8c96875a372fcbe3df84148e95270ab Mon Sep 17 00:00:00 2001 From: Austin Collier Date: Wed, 21 Aug 2024 09:00:30 -0600 Subject: [PATCH] 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) {