Merge pull request #602 from flightphp/file-upload-handler

Added ability to handle file uploads in a simple way
pull/605/head v3.12.0
n0nag0n 5 months ago committed by GitHub
commit 63fbf9b031
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -896,43 +896,18 @@ class Engine
} }
} }
/** /**
* Downloads a file * Downloads a file
* *
* @param string $filePath The path to the file to download * @param string $filePath The path to the file to download
* @throws Exception If the file cannot be found *
* * @throws Exception If the file cannot be found
* @return void *
*/ * @return void
public function _download(string $filePath): void { */
if (file_exists($filePath) === false) { public function _download(string $filePath): void
throw new Exception("$filePath cannot be found."); {
} $this->response()->downloadFile($filePath);
$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
}
} }
/** /**

@ -414,4 +414,63 @@ class Request
return 'http'; return 'http';
} }
/**
* Retrieves the array of uploaded files.
*
* @return array<string, array<string,UploadedFile>|array<string,array<string,UploadedFile>>> 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<string, array<int, array<string, mixed>>> 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;
}
} }

@ -480,4 +480,42 @@ class Response
$this->body = $callback($this->body); $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
}
}
} }

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace flight\net;
use Exception;
class UploadedFile
{
/**
* @var string $name The name of the uploaded file.
*/
private string $name;
/**
* @var string $mimeType The MIME type of the uploaded file.
*/
private string $mimeType;
/**
* @var int $size The size of the uploaded file in bytes.
*/
private int $size;
/**
* @var string $tmpName The temporary name of the uploaded file.
*/
private string $tmpName;
/**
* @var int $error The error code associated with the uploaded file.
*/
private int $error;
/**
* Constructs a new UploadedFile object.
*
* @param string $name The name of the uploaded file.
* @param string $mimeType The MIME type of the uploaded file.
* @param int $size The size of the uploaded file in bytes.
* @param string $tmpName The temporary name of the uploaded file.
* @param int $error The error code associated with the uploaded file.
*/
public function __construct(string $name, string $mimeType, int $size, string $tmpName, int $error)
{
$this->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;
}
}
}

@ -41,23 +41,23 @@ class RequestTest extends TestCase
public function testDefaults() public function testDefaults()
{ {
self::assertEquals('/', $this->request->url); $this->assertEquals('/', $this->request->url);
self::assertEquals('/', $this->request->base); $this->assertEquals('/', $this->request->base);
self::assertEquals('GET', $this->request->method); $this->assertEquals('GET', $this->request->method);
self::assertEquals('', $this->request->referrer); $this->assertEquals('', $this->request->referrer);
self::assertTrue($this->request->ajax); $this->assertTrue($this->request->ajax);
self::assertEquals('http', $this->request->scheme); $this->assertEquals('http', $this->request->scheme);
self::assertEquals('', $this->request->type); $this->assertEquals('', $this->request->type);
self::assertEquals(0, $this->request->length); $this->assertEquals(0, $this->request->length);
self::assertFalse($this->request->secure); $this->assertFalse($this->request->secure);
self::assertEquals('', $this->request->accept); $this->assertEquals('', $this->request->accept);
self::assertEquals('example.com', $this->request->host); $this->assertEquals('example.com', $this->request->host);
} }
public function testIpAddress() public function testIpAddress()
{ {
self::assertEquals('8.8.8.8', $this->request->ip); $this->assertEquals('8.8.8.8', $this->request->ip);
self::assertEquals('32.32.32.32', $this->request->proxy_ip); $this->assertEquals('32.32.32.32', $this->request->proxy_ip);
} }
public function testSubdirectory() public function testSubdirectory()
@ -66,7 +66,7 @@ class RequestTest extends TestCase
$request = new Request(); $request = new Request();
self::assertEquals('/subdir', $request->base); $this->assertEquals('/subdir', $request->base);
} }
public function testQueryParameters() public function testQueryParameters()
@ -75,9 +75,9 @@ class RequestTest extends TestCase
$request = new Request(); $request = new Request();
self::assertEquals('/page?id=1&name=bob', $request->url); $this->assertEquals('/page?id=1&name=bob', $request->url);
self::assertEquals(1, $request->query->id); $this->assertEquals(1, $request->query->id);
self::assertEquals('bob', $request->query->name); $this->assertEquals('bob', $request->query->name);
} }
public function testCollections() public function testCollections()
@ -91,11 +91,11 @@ class RequestTest extends TestCase
$request = new Request(); $request = new Request();
self::assertEquals(1, $request->query->q); $this->assertEquals(1, $request->query->q);
self::assertEquals(1, $request->query->id); $this->assertEquals(1, $request->query->id);
self::assertEquals(1, $request->data->q); $this->assertEquals(1, $request->data->q);
self::assertEquals(1, $request->cookies->q); $this->assertEquals(1, $request->cookies->q);
self::assertEquals(1, $request->files->q); $this->assertEquals(1, $request->files->q);
} }
public function testJsonWithEmptyBody() public function testJsonWithEmptyBody()
@ -104,7 +104,7 @@ class RequestTest extends TestCase
$request = new Request(); $request = new Request();
self::assertSame([], $request->data->getData()); $this->assertSame([], $request->data->getData());
} }
public function testMethodOverrideWithHeader() public function testMethodOverrideWithHeader()
@ -113,7 +113,7 @@ class RequestTest extends TestCase
$request = new Request(); $request = new Request();
self::assertEquals('PUT', $request->method); $this->assertEquals('PUT', $request->method);
} }
public function testMethodOverrideWithPost() public function testMethodOverrideWithPost()
@ -122,38 +122,38 @@ class RequestTest extends TestCase
$request = new Request(); $request = new Request();
self::assertEquals('PUT', $request->method); $this->assertEquals('PUT', $request->method);
} }
public function testHttps() public function testHttps()
{ {
$_SERVER['HTTPS'] = 'on'; $_SERVER['HTTPS'] = 'on';
$request = new Request(); $request = new Request();
self::assertEquals('https', $request->scheme); $this->assertEquals('https', $request->scheme);
$_SERVER['HTTPS'] = 'off'; $_SERVER['HTTPS'] = 'off';
$request = new Request(); $request = new Request();
self::assertEquals('http', $request->scheme); $this->assertEquals('http', $request->scheme);
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
$request = new Request(); $request = new Request();
self::assertEquals('https', $request->scheme); $this->assertEquals('https', $request->scheme);
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'http'; $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'http';
$request = new Request(); $request = new Request();
self::assertEquals('http', $request->scheme); $this->assertEquals('http', $request->scheme);
$_SERVER['HTTP_FRONT_END_HTTPS'] = 'on'; $_SERVER['HTTP_FRONT_END_HTTPS'] = 'on';
$request = new Request(); $request = new Request();
self::assertEquals('https', $request->scheme); $this->assertEquals('https', $request->scheme);
$_SERVER['HTTP_FRONT_END_HTTPS'] = 'off'; $_SERVER['HTTP_FRONT_END_HTTPS'] = 'off';
$request = new Request(); $request = new Request();
self::assertEquals('http', $request->scheme); $this->assertEquals('http', $request->scheme);
$_SERVER['REQUEST_SCHEME'] = 'https'; $_SERVER['REQUEST_SCHEME'] = 'https';
$request = new Request(); $request = new Request();
self::assertEquals('https', $request->scheme); $this->assertEquals('https', $request->scheme);
$_SERVER['REQUEST_SCHEME'] = 'http'; $_SERVER['REQUEST_SCHEME'] = 'http';
$request = new Request(); $request = new Request();
self::assertEquals('http', $request->scheme); $this->assertEquals('http', $request->scheme);
} }
public function testInitUrlSameAsBaseDirectory() public function testInitUrlSameAsBaseDirectory()
@ -279,4 +279,54 @@ class RequestTest extends TestCase
$request = new Request(); $request = new Request();
$this->assertEquals('https://localhost:8000', $request->getBaseUrl()); $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());
}
} }

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace tests;
use Exception;
use flight\net\UploadedFile;
use PHPUnit\Framework\TestCase;
class UploadedFileTest extends TestCase
{
public function tearDown(): void
{
if (file_exists('file.txt')) {
unlink('file.txt');
}
if (file_exists('tmp_name')) {
unlink('tmp_name');
}
}
public function testMoveToSuccess()
{
file_put_contents('tmp_name', 'test');
$uploadedFile = new UploadedFile('file.txt', 'text/plain', 4, 'tmp_name', UPLOAD_ERR_OK);
$uploadedFile->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');
}
}

@ -177,7 +177,7 @@ Flight::route('/json-halt', function () {
// Download a file // Download a file
Flight::route('/download', function () { Flight::route('/download', function () {
Flight::download('test_file.txt'); Flight::download('test_file.txt');
}); });
Flight::map('error', function (Throwable $e) { Flight::map('error', function (Throwable $e) {

Loading…
Cancel
Save