Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
12 changes: 12 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions lib/private/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
127 changes: 127 additions & 0 deletions lib/private/Validator/AttributeLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OC\Validator;

use OCP\Validator\Constraints\Choice;
use OCP\Validator\Constraints\Count;
use OCP\Validator\Constraints\Email;
use OCP\Validator\Constraints\Length;
use OCP\Validator\Constraints\NotBlank;
use OCP\Validator\Constraints\NotNull;
use OCP\Validator\Constraints\Range;
use OCP\Validator\Constraints\Regex;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Mapping\Loader\LoaderInterface;

/**
* Feeds a Symfony ClassMetadata from the OCP\Validator\Constraints attributes
*
* This mirrors what Symfony's own Symfony\Component\Validator\Mapping\Loader\AttributeLoader
* does for its own attributes, but reads OCP\Validator\Constraints\* instead, translating each
* one into the equivalent Symfony constraint, so OCP does not have to expose Symfony's
* constraint classes.
*
* Only properties are supported, accessor methods are not.
*/
class AttributeLoader implements LoaderInterface {
#[\Override]
public function loadClassMetadata(ClassMetadata $metadata): bool {
$reflectionClass = $metadata->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,
};
}
}
40 changes: 40 additions & 0 deletions lib/private/Validator/ConstraintValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OC\Validator;

use OCP\Validator\IConstraintValidator;
use OCP\Validator\Violation;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\ValidatorBuilder;

class ConstraintValidator implements IConstraintValidator {
private ValidatorInterface $validator;

public function __construct() {
$this->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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s sad to flatten the iterator. Maybe foreach+yield instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at the same time, returning a generator in a validate method is not great as in most case, we want to know if the returned list is empty or not

);
}
}
53 changes: 53 additions & 0 deletions lib/public/Validator/Constraints/Choice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCP\Validator\Constraints;

/**
* Validates that a value is one of a given set of choices
*
* ```
* class Person {
* #[Choice(choices: ['admin', 'member', 'guest'])]
* public string $role;
* }
* ```
*
* @since 36.0.0
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
final class Choice {
/**
* @param list<mixed> $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.');
}
}
}
49 changes: 49 additions & 0 deletions lib/public/Validator/Constraints/Count.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCP\Validator\Constraints;

/**
* Validates a collection's element count
*
* ```
* class Person {
* #[Count(min: 1, max: 5)]
* public array $nicknames; // an array of strings
* }
* ```
*
* @since 36.0.0
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
final class Count {
/**
* @param int<0, max>|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 . '.');
}
}
}
36 changes: 36 additions & 0 deletions lib/public/Validator/Constraints/Email.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCP\Validator\Constraints;

/**
* Validates that a value is a valid email address
*
* ```
* class Person {
* #[Email]
* public string $email;
* }
* ```
*
* @since 36.0.0
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
final class Email {
/**
* @param string|null $message the error message, 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 ?string $message = null,
public readonly ?array $groups = null,
) {
}
}
Loading
Loading