diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d8d36c4e7e03..4a4528d757bd 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -63,6 +63,10 @@ jobs: sudo apt-get update sudo apt-get install zopfli + - name: Install system dependencies + run: | + sudo apt-get install ffmpeg + - name: Configure environment run: | git config --global user.email "" diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 62ab3e5e6a3a..653187db4fb8 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -96,9 +96,9 @@ jobs: php phpunit install - - name: Install memurai-developer + - name: Install memurai-developer, ffmpeg run: | - choco install --no-progress memurai-developer + choco install --no-progress memurai-developer ffmpeg - name: Run tests (minimal extensions) if: always() && steps.setup.outcome == 'success' diff --git a/composer.json b/composer.json index b6099e895494..ec46a75bb885 100644 --- a/composer.json +++ b/composer.json @@ -160,7 +160,8 @@ "twig/cssinliner-extra": "^2.12|^3", "twig/inky-extra": "^2.12|^3", "twig/markdown-extra": "^2.12|^3", - "web-token/jwt-library": "^3.3.2|^4.0" + "web-token/jwt-library": "^3.3.2|^4.0", + "php-ffmpeg/php-ffmpeg": "^1.3" }, "conflict": { "ext-psr": "<1.1|>=2", diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 70468d4d3fdb..f9fbfc901740 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add support for ratio checks for SVG files to the `Image` constraint * Add the `Slug` constraint + * Add the `Video` constraint for validating video files 7.2 --- diff --git a/src/Symfony/Component/Validator/Constraints/Video.php b/src/Symfony/Component/Validator/Constraints/Video.php new file mode 100644 index 000000000000..a1a513574629 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Video.php @@ -0,0 +1,221 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Attribute\HasNamedArguments; + +/** + * @author Kev + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class Video extends File +{ + public const SIZE_NOT_DETECTED_ERROR = '5dab98df-43c8-481b-94f9-46a3c958285c'; + public const TOO_WIDE_ERROR = '9e18d6a4-aeda-4644-be8e-9e29dbfd6c4a'; + public const TOO_NARROW_ERROR = 'b267f54b-d994-46d4-9ca6-338fc4f7962f'; + public const TOO_HIGH_ERROR = '44f4c411-0199-48c2-b597-df1f5944ccde'; + public const TOO_LOW_ERROR = '0b6bc3ce-df90-40f9-90aa-5bbb840cb481'; + public const TOO_FEW_PIXEL_ERROR = '510ddf98-2eda-436e-be7e-b6f107bc0e22'; + public const TOO_MANY_PIXEL_ERROR = 'ff0a8ee8-951d-4c97-afe2-03c0d61a2a02'; + public const RATIO_TOO_BIG_ERROR = '5e6b9c21-d4d8-444d-9f4c-e3ff1e25a9a6'; + public const RATIO_TOO_SMALL_ERROR = '26985857-7447-49dc-b271-1477a76cc63c'; + public const SQUARE_NOT_ALLOWED_ERROR = '18500335-b868-4056-b2a2-aa2aeeb0cbdf'; + public const LANDSCAPE_NOT_ALLOWED_ERROR = 'cbf38fbc-04c0-457a-8c29-a6f3080e415a'; + public const PORTRAIT_NOT_ALLOWED_ERROR = '6c3e34a8-94d5-4434-9f20-fb9c0f3ab531'; + public const CORRUPTED_VIDEO_ERROR = '591b9c4d-d357-425f-8672-6b187816550e'; + + // Include the mapping from the base class + + protected const ERROR_NAMES = [ + self::NOT_FOUND_ERROR => 'NOT_FOUND_ERROR', + self::NOT_READABLE_ERROR => 'NOT_READABLE_ERROR', + self::EMPTY_ERROR => 'EMPTY_ERROR', + self::TOO_LARGE_ERROR => 'TOO_LARGE_ERROR', + self::INVALID_MIME_TYPE_ERROR => 'INVALID_MIME_TYPE_ERROR', + self::FILENAME_TOO_LONG => 'FILENAME_TOO_LONG', + self::SIZE_NOT_DETECTED_ERROR => 'SIZE_NOT_DETECTED_ERROR', + self::TOO_WIDE_ERROR => 'TOO_WIDE_ERROR', + self::TOO_NARROW_ERROR => 'TOO_NARROW_ERROR', + self::TOO_HIGH_ERROR => 'TOO_HIGH_ERROR', + self::TOO_LOW_ERROR => 'TOO_LOW_ERROR', + self::TOO_FEW_PIXEL_ERROR => 'TOO_FEW_PIXEL_ERROR', + self::TOO_MANY_PIXEL_ERROR => 'TOO_MANY_PIXEL_ERROR', + self::RATIO_TOO_BIG_ERROR => 'RATIO_TOO_BIG_ERROR', + self::RATIO_TOO_SMALL_ERROR => 'RATIO_TOO_SMALL_ERROR', + self::SQUARE_NOT_ALLOWED_ERROR => 'SQUARE_NOT_ALLOWED_ERROR', + self::LANDSCAPE_NOT_ALLOWED_ERROR => 'LANDSCAPE_NOT_ALLOWED_ERROR', + self::PORTRAIT_NOT_ALLOWED_ERROR => 'PORTRAIT_NOT_ALLOWED_ERROR', + self::CORRUPTED_VIDEO_ERROR => 'CORRUPTED_VIDEO_ERROR', + ]; + + public array|string $mimeTypes = 'video/*'; + public ?int $minWidth = null; + public ?int $maxWidth = null; + public ?int $maxHeight = null; + public ?int $minHeight = null; + public int|float|null $maxRatio = null; + public int|float|null $minRatio = null; + public int|float|null $minPixels = null; + public int|float|null $maxPixels = null; + public ?bool $allowSquare = true; + public ?bool $allowLandscape = true; + public ?bool $allowPortrait = true; + + // The constant for a wrong MIME type is taken from the parent class. + public string $mimeTypesMessage = 'This file is not a valid video.'; + public string $sizeNotDetectedMessage = 'The size of the video could not be detected.'; + public string $maxWidthMessage = 'The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px.'; + public string $minWidthMessage = 'The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px.'; + public string $maxHeightMessage = 'The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px.'; + public string $minHeightMessage = 'The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px.'; + public string $minPixelsMessage = 'The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels.'; + public string $maxPixelsMessage = 'The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels.'; + public string $maxRatioMessage = 'The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}.'; + public string $minRatioMessage = 'The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}.'; + public string $allowSquareMessage = 'The video is square ({{ width }}x{{ height }}px). Square videos are not allowed.'; + public string $allowLandscapeMessage = 'The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed.'; + public string $allowPortraitMessage = 'The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed.'; + public string $corruptedMessage = 'The video file is corrupted.'; + + /** + * @param positive-int|string|null $maxSize The max size of the underlying file + * @param bool|null $binaryFormat Pass true to use binary-prefixed units (KiB, MiB, etc.) or false to use SI-prefixed units (kB, MB) in displayed messages. Pass null to guess the format from the maxSize option. (defaults to null) + * @param non-empty-string[]|null $mimeTypes Acceptable media types + * @param positive-int|null $filenameMaxLength Maximum length of the file name + * @param string|null $disallowEmptyMessage Enable empty upload validation with this message in case of error + * @param string|null $uploadIniSizeErrorMessage Message if the file size exceeds the max size configured in php.ini + * @param string|null $uploadFormSizeErrorMessage Message if the file size exceeds the max size configured in the HTML input field + * @param string|null $uploadPartialErrorMessage Message if the file is only partially uploaded + * @param string|null $uploadNoTmpDirErrorMessage Message if there is no upload_tmp_dir in php.ini + * @param string|null $uploadCantWriteErrorMessage Message if the uploaded file can not be stored in the temporary directory + * @param string|null $uploadErrorMessage Message if an unknown error occurred on upload + * @param string[]|null $groups + * @param int<0, int>|null $minWidth Minimum video width + * @param positive-int|null $maxWidth Maximum video width + * @param positive-int|null $maxHeight Maximum video height + * @param int<0, int>|null $minHeight Minimum video weight + * @param positive-int|float|null $maxRatio Maximum video ratio + * @param int<0, max>|float|null $minRatio Minimum video ratio + * @param int<0, max>|float|null $minPixels Minimum amount of pixels + * @param positive-int|float|null $maxPixels Maximum amount of pixels + * @param bool|null $allowSquare Whether to allow a square video (defaults to true) + * @param bool|null $allowLandscape Whether to allow a landscape video (defaults to true) + * @param bool|null $allowPortrait Whether to allow a portrait video (defaults to true) + * @param string|null $sizeNotDetectedMessage Message if the system can not determine video size and there is a size constraint to validate + * + * @see https://www.iana.org/assignments/media-types/media-types.xhtml Existing media types + */ + #[HasNamedArguments] + public function __construct( + int|string|null $maxSize = null, + ?bool $binaryFormat = null, + array|string|null $mimeTypes = null, + ?int $filenameMaxLength = null, + ?int $minWidth = null, + ?int $maxWidth = null, + ?int $maxHeight = null, + ?int $minHeight = null, + int|float|null $maxRatio = null, + int|float|null $minRatio = null, + int|float|null $minPixels = null, + int|float|null $maxPixels = null, + ?bool $allowSquare = null, + ?bool $allowLandscape = null, + ?bool $allowPortrait = null, + ?string $notFoundMessage = null, + ?string $notReadableMessage = null, + ?string $maxSizeMessage = null, + ?string $mimeTypesMessage = null, + ?string $disallowEmptyMessage = null, + ?string $filenameTooLongMessage = null, + ?string $uploadIniSizeErrorMessage = null, + ?string $uploadFormSizeErrorMessage = null, + ?string $uploadPartialErrorMessage = null, + ?string $uploadNoFileErrorMessage = null, + ?string $uploadNoTmpDirErrorMessage = null, + ?string $uploadCantWriteErrorMessage = null, + ?string $uploadExtensionErrorMessage = null, + ?string $uploadErrorMessage = null, + ?string $sizeNotDetectedMessage = null, + ?string $maxWidthMessage = null, + ?string $minWidthMessage = null, + ?string $maxHeightMessage = null, + ?string $minHeightMessage = null, + ?string $minPixelsMessage = null, + ?string $maxPixelsMessage = null, + ?string $maxRatioMessage = null, + ?string $minRatioMessage = null, + ?string $allowSquareMessage = null, + ?string $allowLandscapeMessage = null, + ?string $allowPortraitMessage = null, + ?string $corruptedMessage = null, + ?array $groups = null, + mixed $payload = null, + ) { + if (!class_exists(\FFMpeg\FFProbe::class)) { + throw new \LogicException('The "php-ffmpeg/php-ffmpeg" library must be installed to use the Video constraint. Try running "composer require php-ffmpeg/php-ffmpeg".'); + } + + parent::__construct( + [], // File constraint do not use named arguments yet + $maxSize, + $binaryFormat, + $mimeTypes, + $filenameMaxLength, + $notFoundMessage, + $notReadableMessage, + $maxSizeMessage, + $mimeTypesMessage, + $disallowEmptyMessage, + $filenameTooLongMessage, + $uploadIniSizeErrorMessage, + $uploadFormSizeErrorMessage, + $uploadPartialErrorMessage, + $uploadNoFileErrorMessage, + $uploadNoTmpDirErrorMessage, + $uploadCantWriteErrorMessage, + $uploadExtensionErrorMessage, + $uploadErrorMessage, + $groups, + $payload + ); + + $this->minWidth = $minWidth ?? $this->minWidth; + $this->maxWidth = $maxWidth ?? $this->maxWidth; + $this->maxHeight = $maxHeight ?? $this->maxHeight; + $this->minHeight = $minHeight ?? $this->minHeight; + $this->maxRatio = $maxRatio ?? $this->maxRatio; + $this->minRatio = $minRatio ?? $this->minRatio; + $this->minPixels = $minPixels ?? $this->minPixels; + $this->maxPixels = $maxPixels ?? $this->maxPixels; + $this->allowSquare = $allowSquare ?? $this->allowSquare; + $this->allowLandscape = $allowLandscape ?? $this->allowLandscape; + $this->allowPortrait = $allowPortrait ?? $this->allowPortrait; + $this->sizeNotDetectedMessage = $sizeNotDetectedMessage ?? $this->sizeNotDetectedMessage; + $this->maxWidthMessage = $maxWidthMessage ?? $this->maxWidthMessage; + $this->minWidthMessage = $minWidthMessage ?? $this->minWidthMessage; + $this->maxHeightMessage = $maxHeightMessage ?? $this->maxHeightMessage; + $this->minHeightMessage = $minHeightMessage ?? $this->minHeightMessage; + $this->minPixelsMessage = $minPixelsMessage ?? $this->minPixelsMessage; + $this->maxPixelsMessage = $maxPixelsMessage ?? $this->maxPixelsMessage; + $this->maxRatioMessage = $maxRatioMessage ?? $this->maxRatioMessage; + $this->minRatioMessage = $minRatioMessage ?? $this->minRatioMessage; + $this->allowSquareMessage = $allowSquareMessage ?? $this->allowSquareMessage; + $this->allowLandscapeMessage = $allowLandscapeMessage ?? $this->allowLandscapeMessage; + $this->allowPortraitMessage = $allowPortraitMessage ?? $this->allowPortraitMessage; + $this->corruptedMessage = $corruptedMessage ?? $this->corruptedMessage; + + if (!\in_array('video/*', (array) $this->mimeTypes, true) && null === $mimeTypesMessage) { + $this->mimeTypesMessage = 'The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}.'; + } + } +} diff --git a/src/Symfony/Component/Validator/Constraints/VideoValidator.php b/src/Symfony/Component/Validator/Constraints/VideoValidator.php new file mode 100644 index 000000000000..e2f293773a1d --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/VideoValidator.php @@ -0,0 +1,221 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * @author Kev + */ +class VideoValidator extends FileValidator +{ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof Video) { + throw new UnexpectedTypeException($constraint, Video::class); + } + + if (!class_exists(\FFMpeg\FFProbe::class)) { + throw new \LogicException('The "php-ffmpeg/php-ffmpeg" library must be installed to use the Video constraint. Try running "composer require php-ffmpeg/php-ffmpeg".'); + } + + $violations = \count($this->context->getViolations()); + + parent::validate($value, $constraint); + + $failed = \count($this->context->getViolations()) !== $violations; + + if ($failed || null === $value || '' === $value) { + return; + } + + if (null === $constraint->minWidth && null === $constraint->maxWidth + && null === $constraint->minHeight && null === $constraint->maxHeight + && null === $constraint->minPixels && null === $constraint->maxPixels + && null === $constraint->minRatio && null === $constraint->maxRatio + && $constraint->allowSquare && $constraint->allowLandscape && $constraint->allowPortrait + ) { + return; + } + + try { + $ffprobe = \FFMpeg\FFProbe::create(); + $stream = $ffprobe->streams($value)->videos()->first(); + if (!$stream) { + throw new \Exception('Unexpected FFMpeg error.'); + } + $dimensions = $stream->getDimensions(); + } catch (\Exception $e) { + $this->context->buildViolation($constraint->corruptedMessage) + ->setParameter('{{ message }}', $e->getMessage()) + ->setCode(Video::CORRUPTED_VIDEO_ERROR) + ->addViolation(); + + return; + } + + $width = $dimensions->getWidth(); + $height = $dimensions->getHeight(); + + if ($constraint->minWidth) { + if ($constraint->minWidth < 0) { + throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid minimum width.', $constraint->minWidth)); + } + + if ($width < $constraint->minWidth) { + $this->context->buildViolation($constraint->minWidthMessage) + ->setParameter('{{ width }}', $width) + ->setParameter('{{ min_width }}', $constraint->minWidth) + ->setCode(Video::TOO_NARROW_ERROR) + ->addViolation(); + + return; + } + } + + if ($constraint->maxWidth) { + if ($constraint->maxWidth < 0) { + throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid maximum width.', $constraint->maxWidth)); + } + + if ($width > $constraint->maxWidth) { + $this->context->buildViolation($constraint->maxWidthMessage) + ->setParameter('{{ width }}', $width) + ->setParameter('{{ max_width }}', $constraint->maxWidth) + ->setCode(Video::TOO_WIDE_ERROR) + ->addViolation(); + + return; + } + } + + if ($constraint->minHeight) { + if ($constraint->minHeight < 0) { + throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid minimum height.', $constraint->minHeight)); + } + + if ($height < $constraint->minHeight) { + $this->context->buildViolation($constraint->minHeightMessage) + ->setParameter('{{ height }}', $height) + ->setParameter('{{ min_height }}', $constraint->minHeight) + ->setCode(Video::TOO_LOW_ERROR) + ->addViolation(); + + return; + } + } + + if ($constraint->maxHeight) { + if ($constraint->maxHeight < 0) { + throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid maximum height.', $constraint->maxHeight)); + } + + if ($height > $constraint->maxHeight) { + $this->context->buildViolation($constraint->maxHeightMessage) + ->setParameter('{{ height }}', $height) + ->setParameter('{{ max_height }}', $constraint->maxHeight) + ->setCode(Video::TOO_HIGH_ERROR) + ->addViolation(); + } + } + + $pixels = $width * $height; + + if (null !== $constraint->minPixels) { + if ($constraint->minPixels < 0) { + throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid minimum amount of pixels.', $constraint->minPixels)); + } + + if ($pixels < $constraint->minPixels) { + $this->context->buildViolation($constraint->minPixelsMessage) + ->setParameter('{{ pixels }}', $pixels) + ->setParameter('{{ min_pixels }}', $constraint->minPixels) + ->setParameter('{{ height }}', $height) + ->setParameter('{{ width }}', $width) + ->setCode(Video::TOO_FEW_PIXEL_ERROR) + ->addViolation(); + } + } + + if (null !== $constraint->maxPixels) { + if ($constraint->maxPixels < 0) { + throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid maximum amount of pixels.', $constraint->maxPixels)); + } + + if ($pixels > $constraint->maxPixels) { + $this->context->buildViolation($constraint->maxPixelsMessage) + ->setParameter('{{ pixels }}', $pixels) + ->setParameter('{{ max_pixels }}', $constraint->maxPixels) + ->setParameter('{{ height }}', $height) + ->setParameter('{{ width }}', $width) + ->setCode(Video::TOO_MANY_PIXEL_ERROR) + ->addViolation(); + } + } + + $ratio = round($dimensions->getRatio()->getValue(), 2); + + if (null !== $constraint->minRatio) { + if (!is_numeric((string) $constraint->minRatio)) { + throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid minimum ratio.', $constraint->minRatio)); + } + + if ($ratio < round($constraint->minRatio, 2)) { + $this->context->buildViolation($constraint->minRatioMessage) + ->setParameter('{{ ratio }}', $ratio) + ->setParameter('{{ min_ratio }}', round($constraint->minRatio, 2)) + ->setCode(Video::RATIO_TOO_SMALL_ERROR) + ->addViolation(); + } + } + + if (null !== $constraint->maxRatio) { + if (!is_numeric((string) $constraint->maxRatio)) { + throw new ConstraintDefinitionException(\sprintf('"%s" is not a valid maximum ratio.', $constraint->maxRatio)); + } + + if ($ratio > round($constraint->maxRatio, 2)) { + $this->context->buildViolation($constraint->maxRatioMessage) + ->setParameter('{{ ratio }}', $ratio) + ->setParameter('{{ max_ratio }}', round($constraint->maxRatio, 2)) + ->setCode(Video::RATIO_TOO_BIG_ERROR) + ->addViolation(); + } + } + + if (!$constraint->allowSquare && $width == $height) { + $this->context->buildViolation($constraint->allowSquareMessage) + ->setParameter('{{ width }}', $width) + ->setParameter('{{ height }}', $height) + ->setCode(Video::SQUARE_NOT_ALLOWED_ERROR) + ->addViolation(); + } + + if (!$constraint->allowLandscape && $width > $height) { + $this->context->buildViolation($constraint->allowLandscapeMessage) + ->setParameter('{{ width }}', $width) + ->setParameter('{{ height }}', $height) + ->setCode(Video::LANDSCAPE_NOT_ALLOWED_ERROR) + ->addViolation(); + } + + if (!$constraint->allowPortrait && $width < $height) { + $this->context->buildViolation($constraint->allowPortraitMessage) + ->setParameter('{{ width }}', $width) + ->setParameter('{{ height }}', $height) + ->setCode(Video::PORTRAIT_NOT_ALLOWED_ERROR) + ->addViolation(); + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test.mp4 b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test.mp4 new file mode 100644 index 000000000000..d251e5def93d Binary files /dev/null and b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test.mp4 differ diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_16by9.mp4 b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_16by9.mp4 new file mode 100644 index 000000000000..deb4587f8e63 Binary files /dev/null and b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_16by9.mp4 differ diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_4by3.mp4 b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_4by3.mp4 new file mode 100644 index 000000000000..243809ed2a35 Binary files /dev/null and b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_4by3.mp4 differ diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_corrupted.mp4 b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_corrupted.mp4 new file mode 100644 index 000000000000..a03d940035da Binary files /dev/null and b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_corrupted.mp4 differ diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape.mp4 b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape.mp4 new file mode 100644 index 000000000000..4781be7327df Binary files /dev/null and b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_landscape.mp4 differ diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_portrait.mp4 b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_portrait.mp4 new file mode 100644 index 000000000000..d4528785dea2 Binary files /dev/null and b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/test_portrait.mp4 differ diff --git a/src/Symfony/Component/Validator/Tests/Constraints/VideoTest.php b/src/Symfony/Component/Validator/Tests/Constraints/VideoTest.php new file mode 100644 index 000000000000..5c6b11ac053c --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/VideoTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraints\Video; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +/** + * @author Kev + */ +class VideoTest extends TestCase +{ + public function testAttributes() + { + $metadata = new ClassMetadata(VideoDummy::class); + $loader = new AttributeLoader(); + self::assertTrue($loader->loadClassMetadata($metadata)); + + [$aConstraint] = $metadata->properties['a']->getConstraints(); + self::assertNull($aConstraint->minWidth); + self::assertNull($aConstraint->maxWidth); + self::assertNull($aConstraint->minHeight); + self::assertNull($aConstraint->maxHeight); + + [$bConstraint] = $metadata->properties['b']->getConstraints(); + self::assertSame(50, $bConstraint->minWidth); + self::assertSame(200, $bConstraint->maxWidth); + self::assertSame(50, $bConstraint->minHeight); + self::assertSame(200, $bConstraint->maxHeight); + self::assertSame(['Default', 'VideoDummy'], $bConstraint->groups); + + [$cConstraint] = $metadata->properties['c']->getConstraints(); + self::assertSame(100000, $cConstraint->maxSize); + self::assertSame(['my_group'], $cConstraint->groups); + self::assertSame('some attached data', $cConstraint->payload); + } +} + +class VideoDummy +{ + #[Video] + private $a; + + #[Video(minWidth: 50, maxWidth: 200, minHeight: 50, maxHeight: 200)] + private $b; + + #[Video(maxSize: '100K', groups: ['my_group'], payload: 'some attached data')] + private $c; +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/VideoValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/VideoValidatorTest.php new file mode 100644 index 000000000000..aee4c56bc93a --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/VideoValidatorTest.php @@ -0,0 +1,481 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Symfony\Component\Validator\Constraints\Video; +use Symfony\Component\Validator\Constraints\VideoValidator; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +/** + * @author Kev + * + * @requires extension fileinfo + */ +class VideoValidatorTest extends ConstraintValidatorTestCase +{ + protected string $path; + protected string $video; + protected string $videoLandscape; + protected string $videoPortrait; + protected string $video4By3; + protected string $video16By9; + protected string $videoCorrupted; + protected string $notAnVideo; + + protected function createValidator(): VideoValidator + { + return new VideoValidator(); + } + + protected function setUp(): void + { + // This test should be skipped if ffmpeg is not available on the system + exec('which ffmpeg', $output, $status); + + if (0 !== $status) { + $this->markTestSkipped('FFMpeg is not available.'); + } + + parent::setUp(); + + $this->video = __DIR__.'/Fixtures/test.mp4'; + $this->videoLandscape = __DIR__.'/Fixtures/test_landscape.mp4'; + $this->videoPortrait = __DIR__.'/Fixtures/test_portrait.mp4'; + $this->video4By3 = __DIR__.'/Fixtures/test_4by3.mp4'; + $this->video16By9 = __DIR__.'/Fixtures/test_16by9.mp4'; + $this->videoCorrupted = __DIR__.'/Fixtures/test_corrupted.mp4'; + $this->notAnVideo = __DIR__.'/Fixtures/ccc.txt'; + } + + public function testNullIsValid() + { + $this->validator->validate(null, new Video()); + + $this->assertNoViolation(); + } + + public function testEmptyStringIsValid() + { + $this->validator->validate('', new Video()); + + $this->assertNoViolation(); + } + + public function testValidVideo() + { + $this->validator->validate($this->video, new Video()); + + $this->assertNoViolation(); + } + + /** + * Checks that the logic from FileValidator still works. + * + * @dataProvider provideConstraintsWithNotFoundMessage + */ + public function testFileNotFound(Video $constraint) + { + $this->validator->validate('foobar', $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ file }}', '"foobar"') + ->setCode(Video::NOT_FOUND_ERROR) + ->assertRaised(); + } + + public static function provideConstraintsWithNotFoundMessage(): iterable + { + yield 'Doctrine style' => [new Video(notFoundMessage: 'myMessage')]; + yield 'Named arguments' => [ + new Video(notFoundMessage: 'myMessage'), + ]; + } + + public function testValidSize() + { + $constraint = new Video( + minWidth: 1, + maxWidth: 2, + minHeight: 1, + maxHeight: 2, + ); + + $this->validator->validate($this->video, $constraint); + + $this->assertNoViolation(); + } + + /** + * @dataProvider provideMinWidthConstraints + */ + public function testWidthTooSmall(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ width }}', '2') + ->setParameter('{{ min_width }}', '3') + ->setCode(Video::TOO_NARROW_ERROR) + ->assertRaised(); + } + + public static function provideMinWidthConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + minWidth: 3, + minWidthMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(minWidth: 3, minWidthMessage: 'myMessage'), + ]; + } + + /** + * @dataProvider provideMaxWidthConstraints + */ + public function testWidthTooBig(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ width }}', '2') + ->setParameter('{{ max_width }}', '1') + ->setCode(Video::TOO_WIDE_ERROR) + ->assertRaised(); + } + + public static function provideMaxWidthConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + maxWidth: 1, + maxWidthMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(maxWidth: 1, maxWidthMessage: 'myMessage'), + ]; + } + + /** + * @dataProvider provideMinHeightConstraints + */ + public function testHeightTooSmall(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ height }}', '2') + ->setParameter('{{ min_height }}', '3') + ->setCode(Video::TOO_LOW_ERROR) + ->assertRaised(); + } + + public static function provideMinHeightConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + minHeight: 3, + minHeightMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(minHeight: 3, minHeightMessage: 'myMessage'), + ]; + } + + /** + * @dataProvider provideMaxHeightConstraints + */ + public function testHeightTooBig(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ height }}', '2') + ->setParameter('{{ max_height }}', '1') + ->setCode(Video::TOO_HIGH_ERROR) + ->assertRaised(); + } + + public static function provideMaxHeightConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + maxHeight: 1, + maxHeightMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(maxHeight: 1, maxHeightMessage: 'myMessage'), + ]; + } + + /** + * @dataProvider provideMinPixelsConstraints + */ + public function testPixelsTooFew(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ pixels }}', '4') + ->setParameter('{{ min_pixels }}', '5') + ->setParameter('{{ height }}', '2') + ->setParameter('{{ width }}', '2') + ->setCode(Video::TOO_FEW_PIXEL_ERROR) + ->assertRaised(); + } + + public static function provideMinPixelsConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + minPixels: 5, + minPixelsMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(minPixels: 5, minPixelsMessage: 'myMessage'), + ]; + } + + /** + * @dataProvider provideMaxPixelsConstraints + */ + public function testPixelsTooMany(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ pixels }}', '4') + ->setParameter('{{ max_pixels }}', '3') + ->setParameter('{{ height }}', '2') + ->setParameter('{{ width }}', '2') + ->setCode(Video::TOO_MANY_PIXEL_ERROR) + ->assertRaised(); + } + + public static function provideMaxPixelsConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + maxPixels: 3, + maxPixelsMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(maxPixels: 3, maxPixelsMessage: 'myMessage'), + ]; + } + + /** + * @dataProvider provideMinRatioConstraints + */ + public function testRatioTooSmall(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ ratio }}', 1) + ->setParameter('{{ min_ratio }}', 2) + ->setCode(Video::RATIO_TOO_SMALL_ERROR) + ->assertRaised(); + } + + public static function provideMinRatioConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + minRatio: 2, + minRatioMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(minRatio: 2, minRatioMessage: 'myMessage'), + ]; + } + + /** + * @dataProvider provideMaxRatioConstraints + */ + public function testRatioTooBig(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ ratio }}', 1) + ->setParameter('{{ max_ratio }}', 0.5) + ->setCode(Video::RATIO_TOO_BIG_ERROR) + ->assertRaised(); + } + + public static function provideMaxRatioConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + maxRatio: 0.5, + maxRatioMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(maxRatio: 0.5, maxRatioMessage: 'myMessage'), + ]; + } + + public function testMaxRatioUsesTwoDecimalsOnly() + { + $constraint = new Video(maxRatio: 1.33); + + $this->validator->validate($this->video4By3, $constraint); + + $this->assertNoViolation(); + } + + public function testMinRatioUsesInputMoreDecimals() + { + $constraint = new Video(minRatio: 4 / 3); + + $this->validator->validate($this->video4By3, $constraint); + + $this->assertNoViolation(); + } + + public function testMaxRatioUsesInputMoreDecimals() + { + $constraint = new Video(maxRatio: 16 / 9); + + $this->validator->validate($this->video16By9, $constraint); + + $this->assertNoViolation(); + } + + /** + * @dataProvider provideAllowSquareConstraints + */ + public function testSquareNotAllowed(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ width }}', 2) + ->setParameter('{{ height }}', 2) + ->setCode(Video::SQUARE_NOT_ALLOWED_ERROR) + ->assertRaised(); + } + + public static function provideAllowSquareConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + allowSquare: false, + allowSquareMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(allowSquare: false, allowSquareMessage: 'myMessage'), + ]; + } + + /** + * @dataProvider provideAllowLandscapeConstraints + */ + public function testLandscapeNotAllowed(Video $constraint) + { + $this->validator->validate($this->videoLandscape, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ width }}', 2) + ->setParameter('{{ height }}', 1) + ->setCode(Video::LANDSCAPE_NOT_ALLOWED_ERROR) + ->assertRaised(); + } + + public static function provideAllowLandscapeConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + allowLandscape: false, + allowLandscapeMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(allowLandscape: false, allowLandscapeMessage: 'myMessage'), + ]; + } + + /** + * @dataProvider provideAllowPortraitConstraints + */ + public function testPortraitNotAllowed(Video $constraint) + { + $this->validator->validate($this->videoPortrait, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ width }}', 1) + ->setParameter('{{ height }}', 2) + ->setCode(Video::PORTRAIT_NOT_ALLOWED_ERROR) + ->assertRaised(); + } + + public static function provideAllowPortraitConstraints(): iterable + { + yield 'Doctrine style' => [new Video( + allowPortrait: false, + allowPortraitMessage: 'myMessage', + )]; + yield 'Named arguments' => [ + new Video(allowPortrait: false, allowPortraitMessage: 'myMessage'), + ]; + } + + public function testCorrupted() + { + $constraint = new Video(maxRatio: 1); + + $this->validator->validate($this->videoCorrupted, $constraint); + + $this->buildViolation('The video file is corrupted.') + ->setParameter('{{ message }}', \sprintf('Unable to probe %s', $this->videoCorrupted)) + ->setCode(Video::CORRUPTED_VIDEO_ERROR) + ->assertRaised(); + } + + public function testInvalidMimeType() + { + $this->validator->validate($this->notAnVideo, $constraint = new Video()); + + $this->assertSame('video/*', $constraint->mimeTypes); + + $this->buildViolation('This file is not a valid video.') + ->setParameter('{{ file }}', \sprintf('"%s"', $this->notAnVideo)) + ->setParameter('{{ type }}', '"text/plain"') + ->setParameter('{{ types }}', '"video/*"') + ->setParameter('{{ name }}', '"ccc.txt"') + ->setCode(Video::INVALID_MIME_TYPE_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider provideInvalidMimeTypeWithNarrowedSet + */ + public function testInvalidMimeTypeWithNarrowedSet(Video $constraint) + { + $this->validator->validate($this->video, $constraint); + + $this->buildViolation('The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}.') + ->setParameter('{{ file }}', \sprintf('"%s"', $this->video)) + ->setParameter('{{ type }}', '"video/mp4"') + ->setParameter('{{ types }}', '"video/mkv", "video/mov"') + ->setParameter('{{ name }}', '"test.mp4"') + ->setCode(Video::INVALID_MIME_TYPE_ERROR) + ->assertRaised(); + } + + public static function provideInvalidMimeTypeWithNarrowedSet() + { + yield 'Doctrine style' => [new Video( + mimeTypes: [ + 'video/mkv', + 'video/mov', + ], + )]; + yield 'Named arguments' => [ + new Video(mimeTypes: [ + 'video/mkv', + 'video/mov', + ]), + ]; + } +} diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index 5177d37d2955..79d5d875cc37 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -40,7 +40,8 @@ "symfony/property-info": "^6.4|^7.0", "symfony/translation": "^6.4.3|^7.0.3", "symfony/type-info": "^7.1", - "egulias/email-validator": "^2.1.10|^3|^4" + "egulias/email-validator": "^2.1.10|^3|^4", + "php-ffmpeg/php-ffmpeg": "^1.3" }, "conflict": { "doctrine/lexer": "<1.1",