From c4ad092dffb291264ee1dd58de17bee0dc08f8a7 Mon Sep 17 00:00:00 2001 From: Maxime COLIN Date: Thu, 19 Dec 2024 11:01:08 +0100 Subject: [PATCH] [Validator] Validate SVG ratio in Image validator --- src/Symfony/Component/Validator/CHANGELOG.md | 5 + .../Validator/Constraints/ImageValidator.php | 70 ++++++++- .../Constraints/Fixtures/test_landscape.svg | 2 + .../Fixtures/test_landscape_height.svg | 2 + .../Fixtures/test_landscape_width.svg | 2 + .../Fixtures/test_landscape_width_height.svg | 2 + .../Constraints/Fixtures/test_no_size.svg | 2 + .../Constraints/Fixtures/test_portrait.svg | 2 + .../Constraints/Fixtures/test_square.svg | 2 + .../Tests/Constraints/ImageValidatorTest.php | 136 ++++++++++++++++++ 10 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape.svg create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_height.svg create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_width.svg create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_width_height.svg create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_no_size.svg create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_portrait.svg create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_square.svg diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 6b5be184c0101..5e480139e8690 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + + * Add support for ratio checks for SVG files to the `Image` constraint + 7.2 --- diff --git a/src/Symfony/Component/Validator/Constraints/ImageValidator.php b/src/Symfony/Component/Validator/Constraints/ImageValidator.php index a715471d9375b..219ad620c3454 100644 --- a/src/Symfony/Component/Validator/Constraints/ImageValidator.php +++ b/src/Symfony/Component/Validator/Constraints/ImageValidator.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Validator\Constraints; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Exception\LogicException; @@ -50,7 +52,13 @@ public function validate(mixed $value, Constraint $constraint): void return; } - $size = @getimagesize($value); + $isSvg = $this->isSvg($value); + + if ($isSvg) { + $size = $this->getSvgSize($value); + } else { + $size = @getimagesize($value); + } if (!$size || (0 === $size[0]) || (0 === $size[1])) { $this->context->buildViolation($constraint->sizeNotDetectedMessage) @@ -63,7 +71,7 @@ public function validate(mixed $value, Constraint $constraint): void $width = $size[0]; $height = $size[1]; - if ($constraint->minWidth) { + if (!$isSvg && $constraint->minWidth) { if (!ctype_digit((string) $constraint->minWidth)) { throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid minimum width.', $constraint->minWidth)); } @@ -79,7 +87,7 @@ public function validate(mixed $value, Constraint $constraint): void } } - if ($constraint->maxWidth) { + if (!$isSvg && $constraint->maxWidth) { if (!ctype_digit((string) $constraint->maxWidth)) { throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid maximum width.', $constraint->maxWidth)); } @@ -95,7 +103,7 @@ public function validate(mixed $value, Constraint $constraint): void } } - if ($constraint->minHeight) { + if (!$isSvg && $constraint->minHeight) { if (!ctype_digit((string) $constraint->minHeight)) { throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid minimum height.', $constraint->minHeight)); } @@ -111,7 +119,7 @@ public function validate(mixed $value, Constraint $constraint): void } } - if ($constraint->maxHeight) { + if (!$isSvg && $constraint->maxHeight) { if (!ctype_digit((string) $constraint->maxHeight)) { throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid maximum height.', $constraint->maxHeight)); } @@ -127,7 +135,7 @@ public function validate(mixed $value, Constraint $constraint): void $pixels = $width * $height; - if (null !== $constraint->minPixels) { + if (!$isSvg && null !== $constraint->minPixels) { if (!ctype_digit((string) $constraint->minPixels)) { throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid minimum amount of pixels.', $constraint->minPixels)); } @@ -143,7 +151,7 @@ public function validate(mixed $value, Constraint $constraint): void } } - if (null !== $constraint->maxPixels) { + if (!$isSvg && null !== $constraint->maxPixels) { if (!ctype_digit((string) $constraint->maxPixels)) { throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid maximum amount of pixels.', $constraint->maxPixels)); } @@ -231,4 +239,52 @@ public function validate(mixed $value, Constraint $constraint): void imagedestroy($resource); } } + + private function isSvg(mixed $value): bool + { + if ($value instanceof File) { + $mime = $value->getMimeType(); + } elseif (class_exists(MimeTypes::class)) { + $mime = MimeTypes::getDefault()->guessMimeType($value); + } elseif (!class_exists(File::class)) { + return false; + } else { + $mime = (new File($value))->getMimeType(); + } + + return 'image/svg+xml' === $mime; + } + + /** + * @return array{int, int}|null index 0 and 1 contains respectively the width and the height of the image, null if size can't be found + */ + private function getSvgSize(mixed $value): ?array + { + if ($value instanceof File) { + $content = $value->getContent(); + } elseif (!class_exists(File::class)) { + return null; + } else { + $content = (new File($value))->getContent(); + } + + if (1 === preg_match('/]+width="(?[0-9]+)"[^<>]*>/', $content, $widthMatches)) { + $width = (int) $widthMatches['width']; + } + + if (1 === preg_match('/]+height="(?[0-9]+)"[^<>]*>/', $content, $heightMatches)) { + $height = (int) $heightMatches['height']; + } + + if (1 === preg_match('/]+viewBox="-?[0-9]+ -?[0-9]+ (?-?[0-9]+) (?-?[0-9]+)"[^<>]*>/', $content, $viewBoxMatches)) { + $width ??= (int) $viewBoxMatches['width']; + $height ??= (int) $viewBoxMatches['height']; + } + + if (isset($width) && isset($height)) { + return [$width, $height]; + } + + return null; + } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape.svg b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape.svg new file mode 100644 index 0000000000000..e1212da08364e --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape.svg @@ -0,0 +1,2 @@ + + diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_height.svg b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_height.svg new file mode 100644 index 0000000000000..7a54631f152c9 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_height.svg @@ -0,0 +1,2 @@ + + diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_width.svg b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_width.svg new file mode 100644 index 0000000000000..a64c0b1e061db --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_width.svg @@ -0,0 +1,2 @@ + + diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_width_height.svg b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_width_height.svg new file mode 100644 index 0000000000000..ec7b52445546a --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape_width_height.svg @@ -0,0 +1,2 @@ + + diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_no_size.svg b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_no_size.svg new file mode 100644 index 0000000000000..e0af766e8ff5d --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_no_size.svg @@ -0,0 +1,2 @@ + + diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_portrait.svg b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_portrait.svg new file mode 100644 index 0000000000000..d17c991bee42b --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_portrait.svg @@ -0,0 +1,2 @@ + + diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_square.svg b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_square.svg new file mode 100644 index 0000000000000..ffac7f14ac732 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_square.svg @@ -0,0 +1,2 @@ + + diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ImageValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ImageValidatorTest.php index 908517081d8a3..d18d81eea3ad0 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ImageValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ImageValidatorTest.php @@ -498,4 +498,140 @@ public static function provideInvalidMimeTypeWithNarrowedSet() ]), ]; } + + /** @dataProvider provideSvgWithViolation */ + public function testSvgWithViolation(string $image, Image $constraint, string $violation, array $parameters = []) + { + $this->validator->validate($image, $constraint); + + $this->buildViolation('myMessage') + ->setCode($violation) + ->setParameters($parameters) + ->assertRaised(); + } + + public static function provideSvgWithViolation(): iterable + { + yield 'No size svg' => [ + __DIR__.'/Fixtures/test_no_size.svg', + new Image(allowLandscape: false, sizeNotDetectedMessage: 'myMessage'), + Image::SIZE_NOT_DETECTED_ERROR, + ]; + + yield 'Landscape SVG not allowed' => [ + __DIR__.'/Fixtures/test_landscape.svg', + new Image(allowLandscape: false, allowLandscapeMessage: 'myMessage'), + Image::LANDSCAPE_NOT_ALLOWED_ERROR, + [ + '{{ width }}' => 500, + '{{ height }}' => 200, + ], + ]; + + yield 'Portrait SVG not allowed' => [ + __DIR__.'/Fixtures/test_portrait.svg', + new Image(allowPortrait: false, allowPortraitMessage: 'myMessage'), + Image::PORTRAIT_NOT_ALLOWED_ERROR, + [ + '{{ width }}' => 200, + '{{ height }}' => 500, + ], + ]; + + yield 'Square SVG not allowed' => [ + __DIR__.'/Fixtures/test_square.svg', + new Image(allowSquare: false, allowSquareMessage: 'myMessage'), + Image::SQUARE_NOT_ALLOWED_ERROR, + [ + '{{ width }}' => 500, + '{{ height }}' => 500, + ], + ]; + + yield 'Landscape with width attribute SVG allowed' => [ + __DIR__.'/Fixtures/test_landscape_width.svg', + new Image(allowLandscape: false, allowLandscapeMessage: 'myMessage'), + Image::LANDSCAPE_NOT_ALLOWED_ERROR, + [ + '{{ width }}' => 600, + '{{ height }}' => 200, + ], + ]; + + yield 'Landscape with height attribute SVG not allowed' => [ + __DIR__.'/Fixtures/test_landscape_height.svg', + new Image(allowLandscape: false, allowLandscapeMessage: 'myMessage'), + Image::LANDSCAPE_NOT_ALLOWED_ERROR, + [ + '{{ width }}' => 500, + '{{ height }}' => 300, + ], + ]; + + yield 'Landscape with width and height attribute SVG not allowed' => [ + __DIR__.'/Fixtures/test_landscape_width_height.svg', + new Image(allowLandscape: false, allowLandscapeMessage: 'myMessage'), + Image::LANDSCAPE_NOT_ALLOWED_ERROR, + [ + '{{ width }}' => 600, + '{{ height }}' => 300, + ], + ]; + + yield 'SVG Min ratio 2' => [ + __DIR__.'/Fixtures/test_square.svg', + new Image(minRatio: 2, minRatioMessage: 'myMessage'), + Image::RATIO_TOO_SMALL_ERROR, + [ + '{{ ratio }}' => '1', + '{{ min_ratio }}' => '2', + ], + ]; + + yield 'SVG Min ratio 0.5' => [ + __DIR__.'/Fixtures/test_square.svg', + new Image(maxRatio: 0.5, maxRatioMessage: 'myMessage'), + Image::RATIO_TOO_BIG_ERROR, + [ + '{{ ratio }}' => '1', + '{{ max_ratio }}' => '0.5', + ], + ]; + } + + /** @dataProvider provideSvgWithoutViolation */ + public function testSvgWithoutViolation(string $image, Image $constraint) + { + $this->validator->validate($image, $constraint); + + $this->assertNoViolation(); + } + + public static function provideSvgWithoutViolation(): iterable + { + yield 'Landscape SVG allowed' => [ + __DIR__.'/Fixtures/test_landscape.svg', + new Image(allowLandscape: true, allowLandscapeMessage: 'myMessage'), + ]; + + yield 'Portrait SVG allowed' => [ + __DIR__.'/Fixtures/test_portrait.svg', + new Image(allowPortrait: true, allowPortraitMessage: 'myMessage'), + ]; + + yield 'Square SVG allowed' => [ + __DIR__.'/Fixtures/test_square.svg', + new Image(allowSquare: true, allowSquareMessage: 'myMessage'), + ]; + + yield 'SVG Min ratio 1' => [ + __DIR__.'/Fixtures/test_square.svg', + new Image(minRatio: 1, minRatioMessage: 'myMessage'), + ]; + + yield 'SVG Max ratio 1' => [ + __DIR__.'/Fixtures/test_square.svg', + new Image(maxRatio: 1, maxRatioMessage: 'myMessage'), + ]; + } }