diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index ee192ad0236cd..8d33e5aaaec3e 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1163,6 +1163,16 @@ 'OCP\\User\\IAvailabilityCoordinator' => $baseDir . '/lib/public/User/IAvailabilityCoordinator.php', 'OCP\\User\\IOutOfOfficeData' => $baseDir . '/lib/public/User/IOutOfOfficeData.php', 'OCP\\Util' => $baseDir . '/lib/public/Util.php', + 'OCP\\Validator\\Constraints\\Choice' => $baseDir . '/lib/public/Validator/Constraints/Choice.php', + 'OCP\\Validator\\Constraints\\Count' => $baseDir . '/lib/public/Validator/Constraints/Count.php', + 'OCP\\Validator\\Constraints\\Email' => $baseDir . '/lib/public/Validator/Constraints/Email.php', + 'OCP\\Validator\\Constraints\\Length' => $baseDir . '/lib/public/Validator/Constraints/Length.php', + 'OCP\\Validator\\Constraints\\NotBlank' => $baseDir . '/lib/public/Validator/Constraints/NotBlank.php', + 'OCP\\Validator\\Constraints\\NotNull' => $baseDir . '/lib/public/Validator/Constraints/NotNull.php', + 'OCP\\Validator\\Constraints\\Range' => $baseDir . '/lib/public/Validator/Constraints/Range.php', + 'OCP\\Validator\\Constraints\\Regex' => $baseDir . '/lib/public/Validator/Constraints/Regex.php', + 'OCP\\Validator\\IConstraintValidator' => $baseDir . '/lib/public/Validator/IConstraintValidator.php', + 'OCP\\Validator\\Violation' => $baseDir . '/lib/public/Validator/Violation.php', 'OCP\\WorkflowEngine\\EntityContext\\IContextPortation' => $baseDir . '/lib/public/WorkflowEngine/EntityContext/IContextPortation.php', 'OCP\\WorkflowEngine\\EntityContext\\IDisplayName' => $baseDir . '/lib/public/WorkflowEngine/EntityContext/IDisplayName.php', 'OCP\\WorkflowEngine\\EntityContext\\IDisplayText' => $baseDir . '/lib/public/WorkflowEngine/EntityContext/IDisplayText.php', @@ -2445,6 +2455,8 @@ 'OC\\User\\PartiallyDeletedUsersBackend' => $baseDir . '/lib/private/User/PartiallyDeletedUsersBackend.php', 'OC\\User\\Session' => $baseDir . '/lib/private/User/Session.php', 'OC\\User\\User' => $baseDir . '/lib/private/User/User.php', + 'OC\\Validator\\AttributeLoader' => $baseDir . '/lib/private/Validator/AttributeLoader.php', + 'OC\\Validator\\ConstraintValidator' => $baseDir . '/lib/private/Validator/ConstraintValidator.php', 'OC_App' => $baseDir . '/lib/private/legacy/OC_App.php', 'OC_Defaults' => $baseDir . '/lib/private/legacy/OC_Defaults.php', 'OC_Helper' => $baseDir . '/lib/private/legacy/OC_Helper.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 8722fc67071bd..103a3d31d0976 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1204,6 +1204,16 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\User\\IAvailabilityCoordinator' => __DIR__ . '/../../..' . '/lib/public/User/IAvailabilityCoordinator.php', 'OCP\\User\\IOutOfOfficeData' => __DIR__ . '/../../..' . '/lib/public/User/IOutOfOfficeData.php', 'OCP\\Util' => __DIR__ . '/../../..' . '/lib/public/Util.php', + 'OCP\\Validator\\Constraints\\Choice' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Choice.php', + 'OCP\\Validator\\Constraints\\Count' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Count.php', + 'OCP\\Validator\\Constraints\\Email' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Email.php', + 'OCP\\Validator\\Constraints\\Length' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Length.php', + 'OCP\\Validator\\Constraints\\NotBlank' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/NotBlank.php', + 'OCP\\Validator\\Constraints\\NotNull' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/NotNull.php', + 'OCP\\Validator\\Constraints\\Range' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Range.php', + 'OCP\\Validator\\Constraints\\Regex' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Regex.php', + 'OCP\\Validator\\IConstraintValidator' => __DIR__ . '/../../..' . '/lib/public/Validator/IConstraintValidator.php', + 'OCP\\Validator\\Violation' => __DIR__ . '/../../..' . '/lib/public/Validator/Violation.php', 'OCP\\WorkflowEngine\\EntityContext\\IContextPortation' => __DIR__ . '/../../..' . '/lib/public/WorkflowEngine/EntityContext/IContextPortation.php', 'OCP\\WorkflowEngine\\EntityContext\\IDisplayName' => __DIR__ . '/../../..' . '/lib/public/WorkflowEngine/EntityContext/IDisplayName.php', 'OCP\\WorkflowEngine\\EntityContext\\IDisplayText' => __DIR__ . '/../../..' . '/lib/public/WorkflowEngine/EntityContext/IDisplayText.php', @@ -2486,6 +2496,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\User\\PartiallyDeletedUsersBackend' => __DIR__ . '/../../..' . '/lib/private/User/PartiallyDeletedUsersBackend.php', 'OC\\User\\Session' => __DIR__ . '/../../..' . '/lib/private/User/Session.php', 'OC\\User\\User' => __DIR__ . '/../../..' . '/lib/private/User/User.php', + 'OC\\Validator\\AttributeLoader' => __DIR__ . '/../../..' . '/lib/private/Validator/AttributeLoader.php', + 'OC\\Validator\\ConstraintValidator' => __DIR__ . '/../../..' . '/lib/private/Validator/ConstraintValidator.php', 'OC_App' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_App.php', 'OC_Defaults' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Defaults.php', 'OC_Helper' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Helper.php', diff --git a/lib/private/Server.php b/lib/private/Server.php index 40dcf4794a4ce..67aa72bb7b4a3 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -157,6 +157,7 @@ use OC\User\Listeners\UserChangedListener; use OC\User\Session; use OC\User\User; +use OC\Validator\ConstraintValidator; use OCA\Theming\ImageManager; use OCA\Theming\Service\BackgroundService; use OCA\Theming\ThemingDefaults; @@ -297,6 +298,7 @@ use OCP\User\Events\UserLoggedInWithCookieEvent; use OCP\User\Events\UserLoggedOutEvent; use OCP\User\IAvailabilityCoordinator; +use OCP\Validator\IConstraintValidator; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -1006,6 +1008,7 @@ public function __construct( }); $this->registerAlias(IEventDispatcher::class, EventDispatcher::class); $this->registerAlias(ISerializer::class, Serializer::class); + $this->registerAlias(IConstraintValidator::class, ConstraintValidator::class); /** @deprecated 35.0.0 */ $this->registerDeprecatedAlias('CryptoWrapper', CryptoWrapper::class); diff --git a/lib/private/Validator/AttributeLoader.php b/lib/private/Validator/AttributeLoader.php new file mode 100644 index 0000000000000..51165f631f10f --- /dev/null +++ b/lib/private/Validator/AttributeLoader.php @@ -0,0 +1,127 @@ +getReflectionClass(); + $className = $reflectionClass->name; + $loaded = false; + + foreach ($reflectionClass->getProperties() as $property) { + if ($property->getDeclaringClass()->name !== $className) { + continue; + } + + foreach ($property->getAttributes() as $reflectionAttribute) { + $constraint = $this->buildConstraint($reflectionAttribute); + if ($constraint === null) { + continue; + } + + $metadata->addPropertyConstraint($property->name, $constraint); + $loaded = true; + } + } + + return $loaded; + } + + private function buildConstraint(\ReflectionAttribute $reflectionAttribute): ?SymfonyConstraint { + $attribute = match ($reflectionAttribute->getName()) { + NotBlank::class, NotNull::class, Length::class, Email::class, Range::class, Choice::class, Regex::class, Count::class => $reflectionAttribute->newInstance(), + default => null, + }; + + return match (true) { + $attribute instanceof NotBlank => new Assert\NotBlank( + message: $attribute->message, + allowNull: $attribute->allowNull, + groups: $attribute->groups, + ), + $attribute instanceof NotNull => new Assert\NotNull( + message: $attribute->message, + groups: $attribute->groups, + ), + $attribute instanceof Length => new Assert\Length( + exactly: $attribute->exactly, + min: $attribute->min, + max: $attribute->max, + exactMessage: $attribute->exactMessage, + minMessage: $attribute->minMessage, + maxMessage: $attribute->maxMessage, + groups: $attribute->groups, + ), + $attribute instanceof Email => new Assert\Email( + message: $attribute->message, + groups: $attribute->groups, + ), + $attribute instanceof Range => new Assert\Range( + min: $attribute->min, + max: $attribute->max, + notInRangeMessage: $attribute->notInRangeMessage, + minMessage: $attribute->minMessage, + maxMessage: $attribute->maxMessage, + groups: $attribute->groups, + ), + $attribute instanceof Choice => new Assert\Choice( + choices: $attribute->choices, + multiple: $attribute->multiple, + min: $attribute->min, + max: $attribute->max, + message: $attribute->message, + multipleMessage: $attribute->multipleMessage, + minMessage: $attribute->minMessage, + maxMessage: $attribute->maxMessage, + groups: $attribute->groups, + ), + $attribute instanceof Regex => new Assert\Regex( + pattern: $attribute->pattern, + match: $attribute->match, + message: $attribute->message, + groups: $attribute->groups, + ), + $attribute instanceof Count => new Assert\Count( + exactly: $attribute->exactly, + min: $attribute->min, + max: $attribute->max, + exactMessage: $attribute->exactMessage, + minMessage: $attribute->minMessage, + maxMessage: $attribute->maxMessage, + groups: $attribute->groups, + ), + default => null, + }; + } +} diff --git a/lib/private/Validator/ConstraintValidator.php b/lib/private/Validator/ConstraintValidator.php new file mode 100644 index 0000000000000..97de87585ccae --- /dev/null +++ b/lib/private/Validator/ConstraintValidator.php @@ -0,0 +1,40 @@ +validator = (new ValidatorBuilder()) + ->addLoader(new AttributeLoader()) + ->getValidator(); + } + + #[\Override] + public function validate(mixed $data, string|array|null $groups = null): array { + $violations = $this->validator->validate($data, null, $groups); + + return array_map( + static fn (ConstraintViolationInterface $violation): Violation => new Violation( + propertyPath: $violation->getPropertyPath(), + message: (string)$violation->getMessage(), + invalidValue: $violation->getInvalidValue(), + ), + iterator_to_array($violations), + ); + } +} diff --git a/lib/public/Validator/Constraints/Choice.php b/lib/public/Validator/Constraints/Choice.php new file mode 100644 index 0000000000000..ea35ccba57e64 --- /dev/null +++ b/lib/public/Validator/Constraints/Choice.php @@ -0,0 +1,53 @@ + $choices the valid choices, must not be empty + * @param bool $multiple whether the value is an array of valid choices instead of a single one (defaults to false) + * @param int<0, max>|null $min the minimum number of valid choices, only used when `$multiple` is true + * @param positive-int|null $max the maximum number of valid choices, only used when `$multiple` is true + * @param string|null $message the error message for an invalid single choice, or null to use the built-in default + * @param string|null $multipleMessage the error message for an invalid choice in a multiple selection, or null to use the built-in default + * @param string|null $minMessage the error message when fewer than `$min` choices are given, or null to use the built-in default + * @param string|null $maxMessage the error message when more than `$max` choices are given, or null to use the built-in default + * @param string[]|null $groups the validation groups this constraint belongs to + * @since 36.0.0 + */ + public function __construct( + public readonly array $choices, + public readonly bool $multiple = false, + public readonly ?int $min = null, + public readonly ?int $max = null, + public readonly ?string $message = null, + public readonly ?string $multipleMessage = null, + public readonly ?string $minMessage = null, + public readonly ?string $maxMessage = null, + public readonly ?array $groups = null, + ) { + if (!$choices) { + throw new \InvalidArgumentException('The choices given to ' . self::class . ' cannot be empty.'); + } + } +} diff --git a/lib/public/Validator/Constraints/Count.php b/lib/public/Validator/Constraints/Count.php new file mode 100644 index 0000000000000..e27edef51b91b --- /dev/null +++ b/lib/public/Validator/Constraints/Count.php @@ -0,0 +1,49 @@ +|null $min the minimum expected number of elements + * @param int<0, max>|null $max the maximum expected number of elements + * @param int<0, max>|null $exactly the exact expected number of elements, equivalent to setting `$min` and `$max` to the same value + * @param string|null $minMessage the error message when there are too few elements, or null to use the built-in default + * @param string|null $maxMessage the error message when there are too many elements, or null to use the built-in default + * @param string|null $exactMessage the error message when `$exactly` is set and the count differs, or null to use the built-in default + * @param string[]|null $groups the validation groups this constraint belongs to + * @since 36.0.0 + */ + public function __construct( + public readonly ?int $min = null, + public readonly ?int $max = null, + public readonly ?int $exactly = null, + public readonly ?string $minMessage = null, + public readonly ?string $maxMessage = null, + public readonly ?string $exactMessage = null, + public readonly ?array $groups = null, + ) { + if ($min === null && $max === null && $exactly === null) { + throw new \InvalidArgumentException('At least one of "min", "max" or "exactly" must be set on ' . self::class . '.'); + } + } +} diff --git a/lib/public/Validator/Constraints/Email.php b/lib/public/Validator/Constraints/Email.php new file mode 100644 index 0000000000000..030b48bcc7858 --- /dev/null +++ b/lib/public/Validator/Constraints/Email.php @@ -0,0 +1,36 @@ +|null $min the minimum expected length + * @param positive-int|null $max the maximum expected length + * @param positive-int|null $exactly the exact expected length, equivalent to setting `$min` and `$max` to the same value + * @param string|null $minMessage the error message when the value is too short, or null to use the built-in default + * @param string|null $maxMessage the error message when the value is too long, or null to use the built-in default + * @param string|null $exactMessage the error message when `$exactly` is set and the length differs, or null to use the built-in default + * @param string[]|null $groups the validation groups this constraint belongs to + * @since 36.0.0 + */ + public function __construct( + public readonly ?int $min = null, + public readonly ?int $max = null, + public readonly ?int $exactly = null, + public readonly ?string $minMessage = null, + public readonly ?string $maxMessage = null, + public readonly ?string $exactMessage = null, + public readonly ?array $groups = null, + ) { + if ($min === null && $max === null && $exactly === null) { + throw new \InvalidArgumentException('At least one of "min", "max" or "exactly" must be set on ' . self::class . '.'); + } + } +} diff --git a/lib/public/Validator/Constraints/NotBlank.php b/lib/public/Validator/Constraints/NotBlank.php new file mode 100644 index 0000000000000..8b9d160202134 --- /dev/null +++ b/lib/public/Validator/Constraints/NotBlank.php @@ -0,0 +1,39 @@ +validator = new ConstraintValidator(); + } + + private function validDto(): ValidatorTestDto { + return new ValidatorTestDto( + name: 'Jane', + email: 'jane@example.com', + age: 30, + role: 'admin', + username: 'jane_doe', + tags: ['a'], + ); + } + + public function testServiceIsRegistered(): void { + $this->assertInstanceOf(ConstraintValidator::class, Server::get(IConstraintValidator::class)); + } + + public function testValidDataProducesNoViolations(): void { + $this->assertSame([], $this->validator->validate($this->validDto())); + } + + public function testNotBlankConstraint(): void { + // An empty string also violates the Length(min: 2) constraint on the same property, + // so both are expected to fire. + $dto = new ValidatorTestDto(name: '', email: 'jane@example.com', age: 30, role: 'admin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(2, $violations); + $this->assertSame('name', $violations[0]->propertyPath); + $this->assertSame('name', $violations[1]->propertyPath); + } + + public function testLengthConstraint(): void { + $dto = new ValidatorTestDto(name: 'J', email: 'jane@example.com', age: 30, role: 'admin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('name', $violations[0]->propertyPath); + $this->assertSame('J', $violations[0]->invalidValue); + } + + public function testEmailConstraintChecksWithMatchingGroup(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'not-an-email', age: 30, role: 'admin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto, groups: ['detailed']); + + $this->assertCount(1, $violations); + $this->assertSame('email', $violations[0]->propertyPath); + $this->assertSame('not-an-email', $violations[0]->invalidValue); + } + + public function testEmailConstraintIsSkippedWithoutMatchingGroup(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'not-an-email', age: 30, role: 'admin', username: 'jane_doe', tags: ['a']); + + // The Email constraint only belongs to the "detailed" group, so validating without + // groups (the implicit "Default" group) does not check it. + $this->assertSame([], $this->validator->validate($dto)); + } + + public function testRangeConstraint(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'jane@example.com', age: 200, role: 'admin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('age', $violations[0]->propertyPath); + } + + public function testChoiceConstraint(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'jane@example.com', age: 30, role: 'superadmin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('role', $violations[0]->propertyPath); + } + + public function testRegexConstraint(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'jane@example.com', age: 30, role: 'admin', username: 'Jane Doe!', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('username', $violations[0]->propertyPath); + } + + public function testCountConstraint(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'jane@example.com', age: 30, role: 'admin', username: 'jane_doe', tags: []); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('tags', $violations[0]->propertyPath); + } + + public function testNotNullConstraint(): void { + $violations = $this->validator->validate(new ValidatorNotNullTestDto(value: null)); + + $this->assertCount(1, $violations); + $this->assertSame('value', $violations[0]->propertyPath); + } + + public function testNotNullConstraintPassesForNonNullValue(): void { + $this->assertSame([], $this->validator->validate(new ValidatorNotNullTestDto(value: 'something'))); + } +} diff --git a/tests/lib/Validator/ValidatorTestDto.php b/tests/lib/Validator/ValidatorTestDto.php new file mode 100644 index 0000000000000..919f9de91f719 --- /dev/null +++ b/tests/lib/Validator/ValidatorTestDto.php @@ -0,0 +1,38 @@ +