diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 73624d14..1412b732 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: phpunit: uses: yiisoft/actions/.github/workflows/phpunit.yml@master with: - extensions: intl + extensions: intl,fileinfo coverage: xdebug os: >- ['ubuntu-latest', 'windows-latest'] diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae77f8c..9275ff29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - New #665: Add methods `addErrorWithFormatOnly()` and `addErrorWithoutPostProcessing()` to `Result` object (@vjik) - Enh #668: Clarify psalm types in `Result` (@vjik) +- New #670: Add `Image` validation rule (@vjik, @arogachev) ## 1.2.0 February 21, 2024 diff --git a/composer-require-checker.json b/composer-require-checker.json index cfd850c6..50fda2dd 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -15,6 +15,7 @@ "Reflection", "SPL", "standard", + "fileinfo", "intl" ], "scan-files": [] diff --git a/composer.json b/composer.json index 3f5dfbae..af44699b 100644 --- a/composer.json +++ b/composer.json @@ -30,11 +30,12 @@ "php": "^8.0", "ext-mbstring": "*", "psr/container": "^1.0|^2.0", + "psr/http-message": "^1.0|^2.0", "yiisoft/arrays": "^2.1|^3.0", - "yiisoft/translator": "^2.1|^3.0", "yiisoft/friendly-exception": "^1.0", "yiisoft/network-utilities": "^1.0", - "yiisoft/strings": "^2.1" + "yiisoft/strings": "^2.1", + "yiisoft/translator": "^2.1|^3.0" }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.0", @@ -52,6 +53,7 @@ }, "suggest": { "ext-intl": "Allows using IDN validation for emails", + "ext-fileinfo": "To use image rule", "yiisoft/di": "To create rule handlers via Yii DI" }, "autoload": { diff --git a/docs/guide/en/built-in-rules.md b/docs/guide/en/built-in-rules.md index c67944b4..59779e55 100644 --- a/docs/guide/en/built-in-rules.md +++ b/docs/guide/en/built-in-rules.md @@ -45,6 +45,10 @@ Here is a list of all available built-in rules, divided by category. - [Count](../../../src/Rule/Count.php) - [OneOf](../../../src/Rule/OneOf.php) +### File rules + +- [Image](../../../src/Rule/Image/Image.php) + ### General purpose rules - [Callback](../../../src/Rule/Callback.php) diff --git a/src/Rule/Image/CompositeImageInfoProvider.php b/src/Rule/Image/CompositeImageInfoProvider.php new file mode 100644 index 00000000..ae6479a4 --- /dev/null +++ b/src/Rule/Image/CompositeImageInfoProvider.php @@ -0,0 +1,30 @@ +providers = $providers; + } + + public function get(string $path): ?ImageInfo + { + foreach ($this->providers as $provider) { + $info = $provider->get($path); + if ($info !== null) { + return $info; + } + } + return null; + } +} diff --git a/src/Rule/Image/Image.php b/src/Rule/Image/Image.php new file mode 100644 index 00000000..bf191845 --- /dev/null +++ b/src/Rule/Image/Image.php @@ -0,0 +1,243 @@ +width; + } + + public function getHeight(): ?int + { + return $this->height; + } + + public function getMinWidth(): ?int + { + return $this->minWidth; + } + + public function getMinHeight(): ?int + { + return $this->minHeight; + } + + public function getMaxWidth(): ?int + { + return $this->maxWidth; + } + + public function getMaxHeight(): ?int + { + return $this->maxHeight; + } + + public function getNotImageMessage(): string + { + return $this->notImageMessage; + } + + public function getNotExactWidthMessage(): string + { + return $this->notExactWidthMessage; + } + + public function getNotExactHeightMessage(): string + { + return $this->notExactHeightMessage; + } + + public function getTooSmallWidthMessage(): string + { + return $this->tooSmallWidthMessage; + } + + public function getTooSmallHeightMessage(): string + { + return $this->tooSmallHeightMessage; + } + + public function getTooLargeWidthMessage(): string + { + return $this->tooLargeWidthMessage; + } + + public function getTooLargeHeightMessage(): string + { + return $this->tooLargeHeightMessage; + } + + public function getName(): string + { + return 'image'; + } + + public function getHandler(): string + { + return ImageHandler::class; + } + + public function getOptions(): array + { + return [ + 'notExactWidthMessage' => [ + 'template' => $this->notExactWidthMessage, + 'parameters' => [ + 'exactly' => $this->width, + ], + ], + 'notExactHeightMessage' => [ + 'template' => $this->notExactHeightMessage, + 'parameters' => [ + 'exactly' => $this->height, + ], + ], + 'tooSmallWidthMessage' => [ + 'template' => $this->tooSmallWidthMessage, + 'parameters' => [ + 'limit' => $this->minWidth, + ], + ], + 'tooSmallHeightMessage' => [ + 'template' => $this->tooSmallHeightMessage, + 'parameters' => [ + 'limit' => $this->minHeight, + ], + ], + 'tooLargeWidthMessage' => [ + 'template' => $this->tooLargeWidthMessage, + 'parameters' => [ + 'limit' => $this->maxWidth, + ], + ], + 'tooLargeHeightMessage' => [ + 'template' => $this->tooLargeHeightMessage, + 'parameters' => [ + 'limit' => $this->maxHeight, + ], + ], + 'notImageMessage' => [ + 'template' => $this->notImageMessage, + 'parameters' => [], + ], + 'skipOnEmpty' => $this->getSkipOnEmptyOption(), + 'skipOnError' => $this->skipOnError, + ]; + } +} diff --git a/src/Rule/Image/ImageHandler.php b/src/Rule/Image/ImageHandler.php new file mode 100644 index 00000000..759d6c98 --- /dev/null +++ b/src/Rule/Image/ImageHandler.php @@ -0,0 +1,141 @@ +imageInfoProvider = $imageInfoProvider ?? new NativeImageInfoProvider(); + } + + public function validate(mixed $value, object $rule, ValidationContext $context): Result + { + if (!$rule instanceof Image) { + throw new UnexpectedRuleException(Image::class, $rule); + } + + $result = new Result(); + + $imageFilePath = $this->getImageFilePath($value); + if (empty($imageFilePath)) { + $result->addError($rule->getNotImageMessage(), ['attribute' => $context->getTranslatedAttribute()]); + return $result; + } + + if (!$this->shouldValidateDimensions($rule)) { + return $result; + } + + $info = $this->imageInfoProvider->get($imageFilePath); + if (empty($info)) { + $result->addError($rule->getNotImageMessage(), ['attribute' => $context->getTranslatedAttribute()]); + return $result; + } + + $width = $info->getWidth(); + $height = $info->getHeight(); + + if ($rule->getWidth() !== null && $width !== $rule->getWidth()) { + $result->addError($rule->getNotExactWidthMessage(), [ + 'attribute' => $context->getTranslatedAttribute(), + 'exactly' => $rule->getWidth(), + ]); + } + if ($rule->getHeight() !== null && $height !== $rule->getHeight()) { + $result->addError($rule->getNotExactHeightMessage(), [ + 'attribute' => $context->getTranslatedAttribute(), + 'exactly' => $rule->getHeight(), + ]); + } + if ($rule->getMinWidth() !== null && $width < $rule->getMinWidth()) { + $result->addError($rule->getTooSmallWidthMessage(), [ + 'attribute' => $context->getTranslatedAttribute(), + 'limit' => $rule->getMinWidth(), + ]); + } + if ($rule->getMinHeight() !== null && $height < $rule->getMinHeight()) { + $result->addError($rule->getTooSmallHeightMessage(), [ + 'attribute' => $context->getTranslatedAttribute(), + 'limit' => $rule->getMinHeight(), + ]); + } + if ($rule->getMaxWidth() !== null && $width > $rule->getMaxWidth()) { + $result->addError($rule->getTooLargeWidthMessage(), [ + 'attribute' => $context->getTranslatedAttribute(), + 'limit' => $rule->getMaxWidth(), + ]); + } + if ($rule->getMaxHeight() !== null && $height > $rule->getMaxHeight()) { + $result->addError($rule->getTooLargeHeightMessage(), [ + 'attribute' => $context->getTranslatedAttribute(), + 'limit' => $rule->getMaxHeight(), + ]); + } + + return $result; + } + + private function shouldValidateDimensions(Image $rule): bool + { + return $rule->getWidth() !== null + || $rule->getHeight() !== null + || $rule->getMinHeight() !== null + || $rule->getMinWidth() !== null + || $rule->getMaxHeight() !== null + || $rule->getMaxWidth() !== null; + } + + private function getImageFilePath(mixed $value): ?string + { + $filePath = $this->getFilePath($value); + if (empty($filePath)) { + return null; + } + + if (!$this->isImageFile($filePath)) { + return null; + } + + return $filePath; + } + + /** + * From PHP documentation: do not use `getimagesize()` to check that a given file is a valid image. Use + * a purpose-built solution such as the `Fileinfo` extension instead. + * + * @link https://www.php.net/manual/function.getimagesize.php + * @link https://www.php.net/manual/function.mime-content-type.php + */ + private function isImageFile(string $filePath): bool + { + $mimeType = @mime_content_type($filePath); + return $mimeType !== false && str_starts_with($mimeType, 'image/'); + } + + private function getFilePath(mixed $value): ?string + { + if ($value instanceof UploadedFileInterface) { + $value = $value->getError() === UPLOAD_ERR_OK ? $value->getStream()->getMetadata('uri') : null; + } + return is_string($value) ? $value : null; + } +} diff --git a/src/Rule/Image/ImageInfo.php b/src/Rule/Image/ImageInfo.php new file mode 100644 index 00000000..36a97b89 --- /dev/null +++ b/src/Rule/Image/ImageInfo.php @@ -0,0 +1,24 @@ +width; + } + + public function getHeight(): int + { + return $this->height; + } +} diff --git a/src/Rule/Image/ImageInfoProviderInterface.php b/src/Rule/Image/ImageInfoProviderInterface.php new file mode 100644 index 00000000..c720d3ad --- /dev/null +++ b/src/Rule/Image/ImageInfoProviderInterface.php @@ -0,0 +1,10 @@ + PHP native function `getimagesize()` don't support HEIF / HEIC formats. + */ +final class NativeImageInfoProvider implements ImageInfoProviderInterface +{ + public function get(string $path): ?ImageInfo + { + /** + * @psalm-var (array{0:int,1:int}&array)|false $data Need for PHP 8.0 only + */ + $data = @getimagesize($path); + if ($data === false) { + return null; + } + + return new ImageInfo($data[0], $data[1]); + } +} diff --git a/tests/Rule/Base/RuleTestCase.php b/tests/Rule/Base/RuleTestCase.php index 178efe6c..da795364 100644 --- a/tests/Rule/Base/RuleTestCase.php +++ b/tests/Rule/Base/RuleTestCase.php @@ -15,7 +15,7 @@ abstract public function dataValidationPassed(): array; /** * @dataProvider dataValidationPassed */ - public function testValidationPassed(mixed $data, ?array $rules = null): void + public function testValidationPassed(mixed $data, array|RuleInterface|null $rules = null): void { $result = (new Validator())->validate($data, $rules); diff --git a/tests/Rule/CompareTest.php b/tests/Rule/CompareTest.php index d20b304e..24bc942e 100644 --- a/tests/Rule/CompareTest.php +++ b/tests/Rule/CompareTest.php @@ -543,7 +543,7 @@ public function hasAttribute(string $attribute): bool * @dataProvider dataValidationPassed * @dataProvider dataValidationPassedWithDifferentTypes */ - public function testValidationPassed(mixed $data, ?array $rules = null): void + public function testValidationPassed(mixed $data, array|RuleInterface|null $rules = null): void { parent::testValidationPassed($data, $rules); } diff --git a/tests/Rule/Image/16x18.jpg b/tests/Rule/Image/16x18.jpg new file mode 100644 index 00000000..2ba693ae Binary files /dev/null and b/tests/Rule/Image/16x18.jpg differ diff --git a/tests/Rule/Image/16x18.png b/tests/Rule/Image/16x18.png new file mode 100644 index 00000000..fb0b5ee1 Binary files /dev/null and b/tests/Rule/Image/16x18.png differ diff --git a/tests/Rule/Image/797x808.HEIC b/tests/Rule/Image/797x808.HEIC new file mode 100644 index 00000000..f191ad7e Binary files /dev/null and b/tests/Rule/Image/797x808.HEIC differ diff --git a/tests/Rule/Image/CompositeImageInfoProviderTest.php b/tests/Rule/Image/CompositeImageInfoProviderTest.php new file mode 100644 index 00000000..72523f40 --- /dev/null +++ b/tests/Rule/Image/CompositeImageInfoProviderTest.php @@ -0,0 +1,73 @@ +get(__DIR__ . '/16x18.jpg'); + + $this->assertNull($result); + } + + public function testWithOneProvider(): void + { + $provider = new CompositeImageInfoProvider(new NativeImageInfoProvider()); + + $result = $provider->get(__DIR__ . '/16x18.jpg'); + + $this->assertInstanceOf(ImageInfo::class, $result); + $this->assertSame(16, $result->getWidth()); + $this->assertSame(18, $result->getHeight()); + } + + public function testWithTwoProviders(): void + { + $provider = new CompositeImageInfoProvider( + new StubImageInfoProvider(), + new NativeImageInfoProvider(), + ); + + $result = $provider->get(__DIR__ . '/16x18.jpg'); + + $this->assertInstanceOf(ImageInfo::class, $result); + $this->assertSame(16, $result->getWidth()); + $this->assertSame(18, $result->getHeight()); + } + + public function testWithTwoProviders2(): void + { + $provider = new CompositeImageInfoProvider( + new StubImageInfoProvider(new ImageInfo(10, 15)), + new NativeImageInfoProvider(), + ); + + $result = $provider->get(__DIR__ . '/16x18.jpg'); + + $this->assertInstanceOf(ImageInfo::class, $result); + $this->assertSame(10, $result->getWidth()); + $this->assertSame(15, $result->getHeight()); + } + + public function testWithTwoProviders3(): void + { + $provider = new CompositeImageInfoProvider( + new StubImageInfoProvider(), + new StubImageInfoProvider(), + ); + + $result = $provider->get(__DIR__ . '/16x18.jpg'); + + $this->assertNull($result); + } +} diff --git a/tests/Rule/Image/ImageTest.php b/tests/Rule/Image/ImageTest.php new file mode 100644 index 00000000..44a954b2 --- /dev/null +++ b/tests/Rule/Image/ImageTest.php @@ -0,0 +1,182 @@ +assertSame('image', $rule->getName()); + } + + public function dataValidationPassed(): array + { + return [ + 'png' => [__DIR__ . '/16x18.png', new Image()], + 'jpg' => [__DIR__ . '/16x18.jpg', new Image()], + 'heic' => [__DIR__ . '/797x808.HEIC', new Image()], + 'uploaded-file' => [new UploadedFile(__DIR__ . '/16x18.jpg', 0, UPLOAD_ERR_OK), new Image()], + 'exactly' => [__DIR__ . '/16x18.jpg', new Image(width: 16, height: 18)], + 'min-width' => [__DIR__ . '/16x18.jpg', new Image(minWidth: 12)], + 'min-width-boundary' => [__DIR__ . '/16x18.jpg', new Image(minWidth: 16)], + 'min-height' => [__DIR__ . '/16x18.jpg', new Image(minHeight: 17)], + 'min-height-boundary' => [__DIR__ . '/16x18.jpg', new Image(minHeight: 18)], + 'max-width' => [__DIR__ . '/16x18.jpg', new Image(maxWidth: 17)], + 'max-width-boundary' => [__DIR__ . '/16x18.jpg', new Image(maxWidth: 16)], + 'max-height' => [__DIR__ . '/16x18.jpg', new Image(maxHeight: 19)], + 'max-height-boundary' => [__DIR__ . '/16x18.jpg', new Image(maxHeight: 18)], + ]; + } + + public function dataValidationFailed(): array + { + $notImageResult = ['' => ['The value must be an image.']]; + + return [ + 'heic-with-width' => [__DIR__ . '/797x808.HEIC', new Image(width: 10), $notImageResult], + 'heic-with-height' => [__DIR__ . '/797x808.HEIC', new Image(height: 10), $notImageResult], + 'heic-with-min-width' => [__DIR__ . '/797x808.HEIC', new Image(minWidth: 10), $notImageResult], + 'heic-with-max-height' => [__DIR__ . '/797x808.HEIC', new Image(minHeight: 10), $notImageResult], + 'heic-with-max-width' => [__DIR__ . '/797x808.HEIC', new Image(maxWidth: 10), $notImageResult], + 'heic-with-min-height' => [__DIR__ . '/797x808.HEIC', new Image(maxHeight: 10), $notImageResult], + 'heic-with-size-and-custom-message' => [ + ['a' => __DIR__ . '/797x808.HEIC'], + ['a' => new Image(minWidth: 10, notImageMessage: 'The value of "{attribute}" must be an image.')], + ['a' => ['The value of "a" must be an image.']], + ], + 'empty-string' => ['', new Image(), $notImageResult], + 'not-file-path' => ['test', new Image(), $notImageResult], + 'not-image' => [__DIR__ . '/ImageTest.php', new Image(), $notImageResult], + 'not-image-with-custom-message' => [ + ['a' => __DIR__ . '/ImageTest.php'], + ['a' => new Image(notImageMessage: 'The value of "{attribute}" must be an image.')], + ['a' => ['The value of "a" must be an image.']], + ], + 'not-uploaded-file' => [ + new UploadedFile(__DIR__ . '/16x18.jpg', 0, UPLOAD_ERR_NO_FILE), + new Image(), + $notImageResult, + ], + 'not-exactly' => [ + __DIR__ . '/16x18.jpg', + new Image(width: 24, height: 32), + [ + '' => [ + 'The width of image "" must be exactly 24 pixels.', + 'The height of image "" must be exactly 32 pixels.', + ], + ], + ], + 'too-small-width' => [ + __DIR__ . '/16x18.jpg', + new Image(minWidth: 17), + ['' => ['The width of image "" cannot be smaller than 17 pixels.']], + ], + 'too-small-height' => [ + __DIR__ . '/16x18.jpg', + new Image(minHeight: 19), + ['' => ['The height of image "" cannot be smaller than 19 pixels.']], + ], + 'too-large-width' => [ + __DIR__ . '/16x18.jpg', + new Image(maxWidth: 15), + ['' => ['The width of image "" cannot be larger than 15 pixels.']], + ], + 'too-large-height' => [ + __DIR__ . '/16x18.jpg', + new Image(maxHeight: 17), + ['' => ['The height of image "" cannot be larger than 17 pixels.']], + ], + ]; + } + + protected function getDifferentRuleInHandlerItems(): array + { + return [Image::class, ImageHandler::class]; + } + + public function dataOptions(): array + { + return [ + [ + new Image(), + [ + 'notExactWidthMessage' => [ + 'template' => 'The width of image "{attribute}" must be exactly {exactly, number} {exactly, plural, one{pixel} other{pixels}}.', + 'parameters' => [ + 'exactly' => null, + ], + ], + 'notExactHeightMessage' => [ + 'template' => 'The height of image "{attribute}" must be exactly {exactly, number} {exactly, plural, one{pixel} other{pixels}}.', + 'parameters' => [ + 'exactly' => null, + ], + ], + 'tooSmallWidthMessage' => [ + 'template' => 'The width of image "{attribute}" cannot be smaller than {limit, number} {limit, plural, one{pixel} other{pixels}}.', + 'parameters' => [ + 'limit' => null, + ], + ], + 'tooSmallHeightMessage' => [ + 'template' => 'The height of image "{attribute}" cannot be smaller than {limit, number} {limit, plural, one{pixel} other{pixels}}.', + 'parameters' => [ + 'limit' => null, + ], + ], + 'tooLargeWidthMessage' => [ + 'template' => 'The width of image "{attribute}" cannot be larger than {limit, number} {limit, plural, one{pixel} other{pixels}}.', + 'parameters' => [ + 'limit' => null, + ], + ], + 'tooLargeHeightMessage' => [ + 'template' => 'The height of image "{attribute}" cannot be larger than {limit, number} {limit, plural, one{pixel} other{pixels}}.', + 'parameters' => [ + 'limit' => null, + ], + ], + 'notImageMessage' => [ + 'template' => 'The value must be an image.', + 'parameters' => [], + ], + 'skipOnEmpty' => false, + 'skipOnError' => false, + ], + ], + ]; + } + + public function testSkipOnError(): void + { + $this->testSkipOnErrorInternal(new Image(), new Image(skipOnError: true)); + } + + public function testWhen(): void + { + $this->testWhenInternal( + new Image(), + new Image( + when: static fn(mixed $value): bool => $value !== null + ) + ); + } +} diff --git a/tests/Rule/Image/StubImageInfoProvider.php b/tests/Rule/Image/StubImageInfoProvider.php new file mode 100644 index 00000000..e6493d37 --- /dev/null +++ b/tests/Rule/Image/StubImageInfoProvider.php @@ -0,0 +1,21 @@ +info; + } +}