From 9b6bd8abd21ae9025fa97c2bc294402d6315e815 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 26 Oct 2022 16:57:09 +0200 Subject: [PATCH 1/4] Introduce class `FileElement` Populate uploaded file values in Form::handleRequest() --- src/Form.php | 1 + src/FormElement/FileElement.php | 56 +++++++++++++++++++++++ tests/FormElement/FileElementTest.php | 64 +++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 src/FormElement/FileElement.php create mode 100644 tests/FormElement/FileElementTest.php diff --git a/src/Form.php b/src/Form.php index d97aa161..93bb6d98 100644 --- a/src/Form.php +++ b/src/Form.php @@ -210,6 +210,7 @@ public function handleRequest(ServerRequestInterface $request) $params = []; } + $params = array_merge_recursive($params, $request->getUploadedFiles()); $this->populate($params); // Assemble after populate in order to conditionally provide form elements diff --git a/src/FormElement/FileElement.php b/src/FormElement/FileElement.php new file mode 100644 index 00000000..d5a7a9f5 --- /dev/null +++ b/src/FormElement/FileElement.php @@ -0,0 +1,56 @@ +isMultiple() ? ($name . '[]') : $name; + } + + public function hasValue() + { + if ($this->value === null) { + return false; + } + + $file = $this->value; + + if ($this->isMultiple()) { + return $file[0]->getError() !== UPLOAD_ERR_NO_FILE; + } + + return $file->getError() !== UPLOAD_ERR_NO_FILE; + } + + public function getValue() + { + return $this->hasValue() ? $this->value : null; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + $this->registerMultipleAttributeCallback($attributes); + } +} diff --git a/tests/FormElement/FileElementTest.php b/tests/FormElement/FileElementTest.php new file mode 100644 index 00000000..5d6b7c54 --- /dev/null +++ b/tests/FormElement/FileElementTest.php @@ -0,0 +1,64 @@ +addElement('file', 'test_file'); + + $this->assertInstanceOf(FileElement::class, $form->getElement('test_file')); + } + + public function testRendering() + { + $file = new FileElement('test_file'); + + $this->assertHtml('', $file); + } + + public function testUploadedFiles() + { + $fileToUpload = new UploadedFile( + 'test/test.pdf', + 500, + 0, + 'test.pdf', + 'application/pdf' + ); + + $req = (new ServerRequest('POST', ServerRequest::getUriFromGlobals())) + ->withUploadedFiles(['test_file' => $fileToUpload]) + ->withParsedBody([]); + + $form = (new Form()) + ->addElement('file', 'test_file') + ->handleRequest($req); + + $this->assertSame($form->getValue('test_file'), $fileToUpload); + } + + public function testMutipleAttributeAlsoChangesNameAttribute() + { + $file = new FileElement('test_file', ['multiple' => true]); + + $this->assertHtml('', $file); + $this->assertSame($file->getName(), 'test_file'); + } + + public function testValueAttributeIsNotRendered() + { + $file = new FileElement('test_file'); + + $file->setValue('test'); + $this->assertHtml('', $file); + } +} From 33e017be72d6893c9ca9dedccba7d8d03ecef944 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 27 Oct 2022 11:15:09 +0200 Subject: [PATCH 2/4] composer.json: Add `guzzle/psr7` for unit tests --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index 4fbfc659..2cf61f4d 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,9 @@ "ipl/validator": "dev-master", "psr/http-message": "~1.0" }, + "require-dev": { + "guzzlehttp/psr7": "^1" + }, "autoload": { "psr-4": { "ipl\\Html\\": "src" From 4423b2dbddaf2a125f514109fb1ecae36fe88e33 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 24 Jan 2023 13:54:14 +0100 Subject: [PATCH 3/4] FileElement: Use correct separator for the `accept` attribute --- src/FormElement/FileElement.php | 7 +++++++ tests/FormElement/FileElementTest.php | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/FormElement/FileElement.php b/src/FormElement/FileElement.php index d5a7a9f5..d1b4925c 100644 --- a/src/FormElement/FileElement.php +++ b/src/FormElement/FileElement.php @@ -15,6 +15,13 @@ class FileElement extends InputElement /** @var UploadedFileInterface|UploadedFileInterface[] */ protected $value; + public function __construct($name, $attributes = null) + { + $this->getAttributes()->get('accept')->setSeparator(', '); + + parent::__construct($name, $attributes); + } + public function getValueAttribute() { // Value attributes of file inputs are set only client-side. diff --git a/tests/FormElement/FileElementTest.php b/tests/FormElement/FileElementTest.php index 5d6b7c54..d6e3927c 100644 --- a/tests/FormElement/FileElementTest.php +++ b/tests/FormElement/FileElementTest.php @@ -20,9 +20,9 @@ public function testElementLoading(): void public function testRendering() { - $file = new FileElement('test_file'); + $file = new FileElement('test_file', ['accept' => ['image/png', 'image/jpeg']]); - $this->assertHtml('', $file); + $this->assertHtml('', $file); } public function testUploadedFiles() From f92343aab23596b38fdbafafe2ac8e162c59dc76 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 24 Jan 2023 13:55:43 +0100 Subject: [PATCH 4/4] FileElement: Register the `FileValidator` by default --- src/FormElement/FileElement.php | 99 +++++++++++++++++++ tests/FormElement/FileElementTest.php | 84 ++++++++++++++++ tests/Lib/FileElementWithAdjustableConfig.php | 24 +++++ 3 files changed, 207 insertions(+) create mode 100644 tests/Lib/FileElementWithAdjustableConfig.php diff --git a/src/FormElement/FileElement.php b/src/FormElement/FileElement.php index d1b4925c..aa736a4d 100644 --- a/src/FormElement/FileElement.php +++ b/src/FormElement/FileElement.php @@ -3,6 +3,8 @@ namespace ipl\Html\FormElement; use ipl\Html\Attributes; +use ipl\Validator\FileValidator; +use ipl\Validator\ValidatorChain; use Psr\Http\Message\UploadedFileInterface; use ipl\Html\Common\MultipleAttribute; @@ -15,6 +17,9 @@ class FileElement extends InputElement /** @var UploadedFileInterface|UploadedFileInterface[] */ protected $value; + /** @var int The default maximum file size */ + protected static $defaultMaxFileSize; + public function __construct($name, $attributes = null) { $this->getAttributes()->get('accept')->setSeparator(', '); @@ -55,9 +60,103 @@ public function getValue() return $this->hasValue() ? $this->value : null; } + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add(new FileValidator([ + 'maxSize' => $this->getDefaultMaxFileSize(), + 'mimeType' => array_filter( + (array) $this->getAttributes()->get('accept')->getValue(), + function ($type) { + // file inputs also allow file extensions in the accept attribute. These + // must not be passed as they don't resemble valid mime type definitions. + return is_string($type) && ltrim($type)[0] !== '.'; + } + ) + ])); + } + protected function registerAttributeCallbacks(Attributes $attributes) { parent::registerAttributeCallbacks($attributes); $this->registerMultipleAttributeCallback($attributes); } + + /** + * Get the system's default maximum file upload size + * + * @return int + */ + public function getDefaultMaxFileSize(): int + { + if (static::$defaultMaxFileSize === null) { + $ini = $this->convertIniToInteger(trim(static::getPostMaxSize())); + $max = $this->convertIniToInteger(trim(static::getUploadMaxFilesize())); + $min = max($ini, $max); + if ($ini > 0) { + $min = min($min, $ini); + } + + if ($max > 0) { + $min = min($min, $max); + } + + static::$defaultMaxFileSize = $min; + } + + return static::$defaultMaxFileSize; + } + + /** + * Converts a ini setting to a integer value + * + * @param string $setting + * + * @return int + */ + private function convertIniToInteger(string $setting): int + { + if (! is_numeric($setting)) { + $type = strtoupper(substr($setting, -1)); + $setting = (int) substr($setting, 0, -1); + + switch ($type) { + case 'K': + $setting *= 1024; + break; + + case 'M': + $setting *= 1024 * 1024; + break; + + case 'G': + $setting *= 1024 * 1024 * 1024; + break; + + default: + break; + } + } + + return (int) $setting; + } + + /** + * Get the `post_max_size` INI setting + * + * @return string + */ + protected static function getPostMaxSize(): string + { + return ini_get('post_max_size') ?: '8M'; + } + + /** + * Get the `upload_max_filesize` INI setting + * + * @return string + */ + protected static function getUploadMaxFilesize(): string + { + return ini_get('upload_max_filesize') ?: '2M'; + } } diff --git a/tests/FormElement/FileElementTest.php b/tests/FormElement/FileElementTest.php index d6e3927c..f90df45a 100644 --- a/tests/FormElement/FileElementTest.php +++ b/tests/FormElement/FileElementTest.php @@ -6,10 +6,18 @@ use GuzzleHttp\Psr7\UploadedFile; use ipl\Html\Form; use ipl\Html\FormElement\FileElement; +use ipl\I18n\NoopTranslator; +use ipl\I18n\StaticTranslator; +use ipl\Tests\Html\Lib\FileElementWithAdjustableConfig; use ipl\Tests\Html\TestCase; class FileElementTest extends TestCase { + protected function setUp(): void + { + StaticTranslator::$instance = new NoopTranslator(); + } + public function testElementLoading(): void { $form = (new Form()) @@ -61,4 +69,80 @@ public function testValueAttributeIsNotRendered() $file->setValue('test'); $this->assertHtml('', $file); } + + public function testDefaultMaxFileSizeAsBytesIsParsedCorrectly() + { + $element = new FileElementWithAdjustableConfig('test'); + $element->setValue(new UploadedFile('test', 500, 0)); + + $element::$defaultMaxFileSize = null; + $element::$uploadMaxFilesize = '500'; + $element::$postMaxSize = '1000'; // Just needs to be bigger than 500 + + $this->assertTrue($element->isValid(), implode("\n", $element->getMessages())); + } + + public function testDefaultMaxFileSizeAsKiloBytesIsParsedCorrectly() + { + $element = new FileElementWithAdjustableConfig('test'); + $element->setValue(new UploadedFile('test', 1024, 0)); + + $element::$defaultMaxFileSize = null; + $element::$uploadMaxFilesize = '1K'; + $element::$postMaxSize = '2K'; // Just needs to be bigger than 1K + + $this->assertTrue($element->isValid(), implode("\n", $element->getMessages())); + } + + public function testDefaultMaxFileSizeAsMegaBytesIsParsedCorrectly() + { + $element = new FileElementWithAdjustableConfig('test'); + $element->setValue(new UploadedFile('test', 102400, 0)); + + $element::$defaultMaxFileSize = null; + $element::$uploadMaxFilesize = '1M'; + $element::$postMaxSize = '2M'; // Just needs to be bigger than 1M + + $this->assertTrue($element->isValid(), implode("\n", $element->getMessages())); + } + + public function testDefaultMaxFileSizeAsGigaBytesIsParsedCorrectly() + { + $element = new FileElementWithAdjustableConfig('test'); + $element->setValue(new UploadedFile('test', 1073741824, 0)); + + $element::$defaultMaxFileSize = null; + $element::$uploadMaxFilesize = '1G'; + $element::$postMaxSize = '2G'; // Just needs to be bigger than 1G + + $this->assertTrue($element->isValid(), implode("\n", $element->getMessages())); + } + + /** + * @depends testDefaultMaxFileSizeAsKiloBytesIsParsedCorrectly + */ + public function testUploadMaxFilesizeOverrulesPostMaxSize() + { + $element = new FileElementWithAdjustableConfig('test'); + $element->setValue(new UploadedFile('test', 1024, 0)); + + $element::$defaultMaxFileSize = null; + $element::$uploadMaxFilesize = '1K'; + $element::$postMaxSize = '2K'; // Just needs to be bigger than 1K + + $this->assertTrue($element->isValid(), implode("\n", $element->getMessages())); + + // ...if possible + + $element::$defaultMaxFileSize = null; + $element::$uploadMaxFilesize = '2K'; + $element::$postMaxSize = '1K'; + + $this->assertTrue($element->isValid(), implode("\n", $element->getMessages())); + + $element->setValue(new UploadedFile('test', 2048, 0)); + $element::$defaultMaxFileSize = null; + + $this->assertFalse($element->isValid()); + } } diff --git a/tests/Lib/FileElementWithAdjustableConfig.php b/tests/Lib/FileElementWithAdjustableConfig.php new file mode 100644 index 00000000..b0bc5070 --- /dev/null +++ b/tests/Lib/FileElementWithAdjustableConfig.php @@ -0,0 +1,24 @@ +