From 5b9d45cdc04e96ddc97b1610fe45ae56f41a60c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Dziewo=C5=84ski?= Date: Fri, 21 Feb 2025 22:07:19 +0100 Subject: editpage: Refactor user right, rate limit and block checks to use Authority Replace EditRightConstraint, UserBlockConstraint, and most of UserRateLimitConstraint with the new AuthorizationConstraint. Instead of many separate checks, everything is now handled by one authorizeWrite() call. Move 'editcontentmodel' rate limit to ContentModelChangeConstraint (by making it use authorizeWrite()). Keep 'linkpurge' rate limit in a separate check, renamed from UserRateLimitConstraint to LinkPurgeRateLimitConstraint, since the way it works in unusual and not portable to Authority without more refactoring in EditPage. AuthorizationConstraint needs some special handling to produce the idiosyncratic result codes required by EditPage, but luckily PermissionStatus gives us everything we need for that. Bug: T271975 Bug: T386346 Change-Id: Ic9f6f2fbd29efa3e349517013da540a363c263b5 --- autoload.php | 5 +- includes/ServiceWiring.php | 5 +- .../Constraint/AuthorizationConstraint.php | 88 +++++++++++ .../Constraint/ContentModelChangeConstraint.php | 35 ++--- .../editpage/Constraint/EditConstraintFactory.php | 63 +------- .../editpage/Constraint/EditRightConstraint.php | 105 ------------- .../Constraint/LinkPurgeRateLimitConstraint.php | 78 ++++++++++ .../editpage/Constraint/UserBlockConstraint.php | 79 ---------- .../Constraint/UserRateLimitConstraint.php | 89 ----------- includes/editpage/EditPage.php | 44 +++--- .../includes/editpage/EditPageConstraintsTest.php | 59 ++++--- .../mocks/permissions/MockAuthorityTrait.php | 1 + .../Constraint/AuthorizationConstraintTest.php | 111 ++++++++++++++ .../Constraint/EditConstraintFactoryTest.php | 7 - .../Constraint/EditRightConstraintTest.php | 170 --------------------- .../LinkPurgeRateLimitConstraintTest.php | 70 +++++++++ .../Constraint/UserBlockConstraintTest.php | 72 --------- .../Constraint/UserRateLimitConstraintTest.php | 79 ---------- 18 files changed, 425 insertions(+), 735 deletions(-) create mode 100644 includes/editpage/Constraint/AuthorizationConstraint.php delete mode 100644 includes/editpage/Constraint/EditRightConstraint.php create mode 100644 includes/editpage/Constraint/LinkPurgeRateLimitConstraint.php delete mode 100644 includes/editpage/Constraint/UserBlockConstraint.php delete mode 100644 includes/editpage/Constraint/UserRateLimitConstraint.php create mode 100644 tests/phpunit/unit/includes/editpage/Constraint/AuthorizationConstraintTest.php delete mode 100644 tests/phpunit/unit/includes/editpage/Constraint/EditRightConstraintTest.php create mode 100644 tests/phpunit/unit/includes/editpage/Constraint/LinkPurgeRateLimitConstraintTest.php delete mode 100644 tests/phpunit/unit/includes/editpage/Constraint/UserBlockConstraintTest.php delete mode 100644 tests/phpunit/unit/includes/editpage/Constraint/UserRateLimitConstraintTest.php diff --git a/autoload.php b/autoload.php index ffcec57a34b9..42d2dfac6e1a 100644 --- a/autoload.php +++ b/autoload.php @@ -1294,6 +1294,7 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\DomainEvent\\EventSubscriberBase' => __DIR__ . '/includes/DomainEvent/DomainEventIngress.php', 'MediaWiki\\DomainEvent\\InitializableDomainEventSubscriber' => __DIR__ . '/includes/DomainEvent/InitializableDomainEventSubscriber.php', 'MediaWiki\\EditPage\\Constraint\\AccidentalRecreationConstraint' => __DIR__ . '/includes/editpage/Constraint/AccidentalRecreationConstraint.php', + 'MediaWiki\\EditPage\\Constraint\\AuthorizationConstraint' => __DIR__ . '/includes/editpage/Constraint/AuthorizationConstraint.php', 'MediaWiki\\EditPage\\Constraint\\BrokenRedirectConstraint' => __DIR__ . '/includes/editpage/Constraint/BrokenRedirectConstraint.php', 'MediaWiki\\EditPage\\Constraint\\ChangeTagsConstraint' => __DIR__ . '/includes/editpage/Constraint/ChangeTagsConstraint.php', 'MediaWiki\\EditPage\\Constraint\\ContentModelChangeConstraint' => __DIR__ . '/includes/editpage/Constraint/ContentModelChangeConstraint.php', @@ -1302,10 +1303,10 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\EditPage\\Constraint\\EditConstraintFactory' => __DIR__ . '/includes/editpage/Constraint/EditConstraintFactory.php', 'MediaWiki\\EditPage\\Constraint\\EditConstraintRunner' => __DIR__ . '/includes/editpage/Constraint/EditConstraintRunner.php', 'MediaWiki\\EditPage\\Constraint\\EditFilterMergedContentHookConstraint' => __DIR__ . '/includes/editpage/Constraint/EditFilterMergedContentHookConstraint.php', - 'MediaWiki\\EditPage\\Constraint\\EditRightConstraint' => __DIR__ . '/includes/editpage/Constraint/EditRightConstraint.php', 'MediaWiki\\EditPage\\Constraint\\ExistingSectionEditConstraint' => __DIR__ . '/includes/editpage/Constraint/ExistingSectionEditConstraint.php', 'MediaWiki\\EditPage\\Constraint\\IEditConstraint' => __DIR__ . '/includes/editpage/Constraint/IEditConstraint.php', 'MediaWiki\\EditPage\\Constraint\\ImageRedirectConstraint' => __DIR__ . '/includes/editpage/Constraint/ImageRedirectConstraint.php', + 'MediaWiki\\EditPage\\Constraint\\LinkPurgeRateLimitConstraint' => __DIR__ . '/includes/editpage/Constraint/LinkPurgeRateLimitConstraint.php', 'MediaWiki\\EditPage\\Constraint\\MissingCommentConstraint' => __DIR__ . '/includes/editpage/Constraint/MissingCommentConstraint.php', 'MediaWiki\\EditPage\\Constraint\\NewSectionMissingSubjectConstraint' => __DIR__ . '/includes/editpage/Constraint/NewSectionMissingSubjectConstraint.php', 'MediaWiki\\EditPage\\Constraint\\PageSizeConstraint' => __DIR__ . '/includes/editpage/Constraint/PageSizeConstraint.php', @@ -1314,8 +1315,6 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\EditPage\\Constraint\\SimpleAntiSpamConstraint' => __DIR__ . '/includes/editpage/Constraint/SimpleAntiSpamConstraint.php', 'MediaWiki\\EditPage\\Constraint\\SpamRegexConstraint' => __DIR__ . '/includes/editpage/Constraint/SpamRegexConstraint.php', 'MediaWiki\\EditPage\\Constraint\\UnicodeConstraint' => __DIR__ . '/includes/editpage/Constraint/UnicodeConstraint.php', - 'MediaWiki\\EditPage\\Constraint\\UserBlockConstraint' => __DIR__ . '/includes/editpage/Constraint/UserBlockConstraint.php', - 'MediaWiki\\EditPage\\Constraint\\UserRateLimitConstraint' => __DIR__ . '/includes/editpage/Constraint/UserRateLimitConstraint.php', 'MediaWiki\\EditPage\\EditPage' => __DIR__ . '/includes/editpage/EditPage.php', 'MediaWiki\\EditPage\\IEditObject' => __DIR__ . '/includes/editpage/IEditObject.php', 'MediaWiki\\EditPage\\IntroMessageBuilder' => __DIR__ . '/includes/editpage/IntroMessageBuilder.php', diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index f82bb3598935..68ec604820dd 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -2793,9 +2793,6 @@ return [ ), LoggerFactory::getProvider(), - // UserBlockConstraint - $services->getPermissionManager(), - // EditFilterMergedContentHookConstraint $services->getHookContainer(), @@ -2805,7 +2802,7 @@ return [ // SpamRegexConstraint $services->getSpamChecker(), - // UserRateLimitConstraint + // LinkPurgeRateLimitConstraint $services->getRateLimiter() ); }, diff --git a/includes/editpage/Constraint/AuthorizationConstraint.php b/includes/editpage/Constraint/AuthorizationConstraint.php new file mode 100644 index 000000000000..73247160fa7b --- /dev/null +++ b/includes/editpage/Constraint/AuthorizationConstraint.php @@ -0,0 +1,88 @@ +performer = $performer; + $this->target = $target; + $this->new = $new; + } + + public function checkConstraint(): string { + $this->status = PermissionStatus::newEmpty(); + + if ( $this->new && !$this->performer->authorizeWrite( 'create', $this->target, $this->status ) ) { + return self::CONSTRAINT_FAILED; + } + + if ( !$this->performer->authorizeWrite( 'edit', $this->target, $this->status ) ) { + return self::CONSTRAINT_FAILED; + } + + return self::CONSTRAINT_PASSED; + } + + public function getLegacyStatus(): StatusValue { + $statusValue = StatusValue::newGood(); + + if ( !$this->status->isGood() ) { + // Report the most specific errors first + if ( $this->status->isBlocked() ) { + $statusValue->setResult( false, self::AS_BLOCKED_PAGE_FOR_USER ); + } elseif ( $this->status->isRateLimitExceeded() ) { + $statusValue->setResult( false, self::AS_RATE_LIMITED ); + } elseif ( $this->status->getPermission() === 'create' ) { + $statusValue->setResult( false, self::AS_NO_CREATE_PERMISSION ); + } elseif ( !$this->performer->isRegistered() ) { + $statusValue->setResult( false, self::AS_READ_ONLY_PAGE_ANON ); + } else { + $statusValue->setResult( false, self::AS_READ_ONLY_PAGE_LOGGED ); + } + } + + // TODO: Use error messages from the PermissionStatus ($this->status) here - T384399 + return $statusValue; + } + +} diff --git a/includes/editpage/Constraint/ContentModelChangeConstraint.php b/includes/editpage/Constraint/ContentModelChangeConstraint.php index 8ed2e56debde..2581ebab3c14 100644 --- a/includes/editpage/Constraint/ContentModelChangeConstraint.php +++ b/includes/editpage/Constraint/ContentModelChangeConstraint.php @@ -21,6 +21,7 @@ namespace MediaWiki\EditPage\Constraint; use MediaWiki\Permissions\Authority; +use MediaWiki\Permissions\PermissionStatus; use MediaWiki\Title\Title; use StatusValue; @@ -28,6 +29,7 @@ use StatusValue; * Verify user permissions if changing content model: * Must have editcontentmodel rights * Must be able to edit under the new content model + * Must not have exceeded the rate limit * * @since 1.36 * @internal @@ -35,10 +37,11 @@ use StatusValue; */ class ContentModelChangeConstraint implements IEditConstraint { + private PermissionStatus $status; + private Authority $performer; private Title $title; private string $newContentModel; - private string $result; /** * @param Authority $performer @@ -56,45 +59,43 @@ class ContentModelChangeConstraint implements IEditConstraint { } public function checkConstraint(): string { + $this->status = PermissionStatus::newEmpty(); + if ( $this->newContentModel === $this->title->getContentModel() ) { // No change - $this->result = self::CONSTRAINT_PASSED; return self::CONSTRAINT_PASSED; } - if ( !$this->performer->isAllowed( 'editcontentmodel' ) ) { - $this->result = self::CONSTRAINT_FAILED; + if ( !$this->performer->authorizeWrite( 'editcontentmodel', $this->title, $this->status ) ) { return self::CONSTRAINT_FAILED; } - // Make sure the user can edit the page under the new content model too + // Make sure the user can edit the page under the new content model too. + // We rely on caching in UserAuthority to avoid bumping the rate limit counter twice. $titleWithNewContentModel = clone $this->title; $titleWithNewContentModel->setContentModel( $this->newContentModel ); - - $canEditModel = $this->performer->authorizeWrite( - 'editcontentmodel', - $titleWithNewContentModel - ); - if ( - !$canEditModel - || !$this->performer->authorizeWrite( 'edit', $titleWithNewContentModel ) + !$this->performer->authorizeWrite( 'editcontentmodel', $titleWithNewContentModel, $this->status ) + || !$this->performer->authorizeWrite( 'edit', $titleWithNewContentModel, $this->status ) ) { - $this->result = self::CONSTRAINT_FAILED; return self::CONSTRAINT_FAILED; } - $this->result = self::CONSTRAINT_PASSED; return self::CONSTRAINT_PASSED; } public function getLegacyStatus(): StatusValue { $statusValue = StatusValue::newGood(); - if ( $this->result === self::CONSTRAINT_FAILED ) { - $statusValue->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL ); + if ( !$this->status->isGood() ) { + if ( $this->status->isRateLimitExceeded() ) { + $statusValue->setResult( false, self::AS_RATE_LIMITED ); + } else { + $statusValue->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL ); + } } + // TODO: Use error messages from the PermissionStatus ($this->status) here - T384399 return $statusValue; } diff --git a/includes/editpage/Constraint/EditConstraintFactory.php b/includes/editpage/Constraint/EditConstraintFactory.php index 750c977d2312..e9fede9f256c 100644 --- a/includes/editpage/Constraint/EditConstraintFactory.php +++ b/includes/editpage/Constraint/EditConstraintFactory.php @@ -26,10 +26,8 @@ use MediaWiki\Context\IContextSource; use MediaWiki\EditPage\SpamChecker; use MediaWiki\HookContainer\HookContainer; use MediaWiki\Language\Language; -use MediaWiki\Linker\LinkTarget; use MediaWiki\Logger\Spi; use MediaWiki\MainConfigNames; -use MediaWiki\Permissions\PermissionManager; use MediaWiki\Permissions\RateLimiter; use MediaWiki\Permissions\RateLimitSubject; use MediaWiki\Title\Title; @@ -54,7 +52,6 @@ class EditConstraintFactory { private ServiceOptions $options; private Spi $loggerFactory; - private PermissionManager $permissionManager; private HookContainer $hookContainer; private ReadOnlyMode $readOnlyMode; private SpamChecker $spamRegexChecker; @@ -74,7 +71,6 @@ class EditConstraintFactory { * * @param ServiceOptions $options * @param Spi $loggerFactory - * @param PermissionManager $permissionManager * @param HookContainer $hookContainer * @param ReadOnlyMode $readOnlyMode * @param SpamChecker $spamRegexChecker @@ -83,7 +79,6 @@ class EditConstraintFactory { public function __construct( ServiceOptions $options, Spi $loggerFactory, - PermissionManager $permissionManager, HookContainer $hookContainer, ReadOnlyMode $readOnlyMode, SpamChecker $spamRegexChecker, @@ -95,9 +90,6 @@ class EditConstraintFactory { $this->options = $options; $this->loggerFactory = $loggerFactory; - // UserBlockConstraint - $this->permissionManager = $permissionManager; - // EditFilterMergedContentHookConstraint $this->hookContainer = $hookContainer; @@ -107,7 +99,7 @@ class EditConstraintFactory { // SpamRegexConstraint $this->spamRegexChecker = $spamRegexChecker; - // UserRateLimitConstraint + // LinkPurgeRateLimitConstraint $this->rateLimiter = $rateLimiter; } @@ -163,21 +155,15 @@ class EditConstraintFactory { /** * @param RateLimitSubject $subject - * @param string $oldModel - * @param string $newModel * - * @return UserRateLimitConstraint + * @return LinkPurgeRateLimitConstraint */ - public function newUserRateLimitConstraint( - RateLimitSubject $subject, - string $oldModel, - string $newModel - ): UserRateLimitConstraint { - return new UserRateLimitConstraint( + public function newLinkPurgeRateLimitConstraint( + RateLimitSubject $subject + ): LinkPurgeRateLimitConstraint { + return new LinkPurgeRateLimitConstraint( $this->rateLimiter, - $subject, - $oldModel, - $newModel + $subject ); } @@ -226,39 +212,4 @@ class EditConstraintFactory { ); } - /** - * @param LinkTarget $title - * @param User $user - * @return UserBlockConstraint - */ - public function newUserBlockConstraint( - LinkTarget $title, - User $user - ): UserBlockConstraint { - return new UserBlockConstraint( - $this->permissionManager, - $title, - $user - ); - } - - /** - * @param User $performer - * @param Title $title - * @param bool $new - * @return EditRightConstraint - */ - public function newEditRightConstraint( - User $performer, - Title $title, - bool $new - ): EditRightConstraint { - return new EditRightConstraint( - $performer, - $this->permissionManager, - $title, - $new - ); - } - } diff --git a/includes/editpage/Constraint/EditRightConstraint.php b/includes/editpage/Constraint/EditRightConstraint.php deleted file mode 100644 index 47be037e4dad..000000000000 --- a/includes/editpage/Constraint/EditRightConstraint.php +++ /dev/null @@ -1,105 +0,0 @@ -performer = $performer; - $this->permManager = $permManager; - $this->title = $title; - $this->new = $new; - } - - public function checkConstraint(): string { - if ( $this->new ) { - // Check isn't simple enough to just repeat when getting the status - if ( !$this->performer->authorizeWrite( 'create', $this->title ) ) { - $this->result = (string)self::AS_NO_CREATE_PERMISSION; - return self::CONSTRAINT_FAILED; - } - } - - // Check isn't simple enough to just repeat when getting the status - // Prior to 1.41 this checked if the user had edit rights in general - // instead of for the specific page in question. - if ( !$this->permManager->userCan( - 'edit', - $this->performer, - $this->title - ) ) { - $this->result = self::CONSTRAINT_FAILED; - return self::CONSTRAINT_FAILED; - } - - $this->result = self::CONSTRAINT_PASSED; - return self::CONSTRAINT_PASSED; - } - - public function getLegacyStatus(): StatusValue { - $statusValue = StatusValue::newGood(); - - if ( $this->result === self::CONSTRAINT_FAILED ) { - if ( !$this->performer->isRegistered() ) { - $statusValue->setResult( false, self::AS_READ_ONLY_PAGE_ANON ); - } else { - $statusValue->fatal( 'readonlytext' ); - $statusValue->value = self::AS_READ_ONLY_PAGE_LOGGED; - } - } elseif ( $this->result === (string)self::AS_NO_CREATE_PERMISSION ) { - $statusValue->fatal( 'nocreatetext' ); - $statusValue->value = self::AS_NO_CREATE_PERMISSION; - } - - return $statusValue; - } - -} diff --git a/includes/editpage/Constraint/LinkPurgeRateLimitConstraint.php b/includes/editpage/Constraint/LinkPurgeRateLimitConstraint.php new file mode 100644 index 000000000000..d25cf9336d88 --- /dev/null +++ b/includes/editpage/Constraint/LinkPurgeRateLimitConstraint.php @@ -0,0 +1,78 @@ +limiter = $limiter; + $this->subject = $subject; + } + + private function limit( string $action, int $inc = 1 ): bool { + return $this->limiter->limit( $this->subject, $action, $inc ); + } + + public function checkConstraint(): string { + // TODO inject and use a ThrottleStore once available, see T261744 + // Checking if the user is rate limited increments the counts, so we cannot perform + // the check again when getting the status; thus, store the result + if ( $this->limit( 'linkpurge', /* only counted after the fact */ 0 ) ) { + $this->result = self::CONSTRAINT_FAILED; + } else { + $this->result = self::CONSTRAINT_PASSED; + } + + return $this->result; + } + + public function getLegacyStatus(): StatusValue { + $statusValue = StatusValue::newGood(); + + if ( $this->result === self::CONSTRAINT_FAILED ) { + $statusValue->fatal( 'actionthrottledtext' ); + $statusValue->value = self::AS_RATE_LIMITED; + } + + return $statusValue; + } + +} diff --git a/includes/editpage/Constraint/UserBlockConstraint.php b/includes/editpage/Constraint/UserBlockConstraint.php deleted file mode 100644 index bdd33262d434..000000000000 --- a/includes/editpage/Constraint/UserBlockConstraint.php +++ /dev/null @@ -1,79 +0,0 @@ -permissionManager = $permissionManager; - $this->title = $title; - $this->user = $user; - } - - public function checkConstraint(): string { - // Check isn't simple enough to just repeat when getting the status - if ( $this->permissionManager->isBlockedFrom( $this->user, $this->title ) ) { - $this->result = self::CONSTRAINT_FAILED; - return self::CONSTRAINT_FAILED; - } - - $this->result = self::CONSTRAINT_PASSED; - return self::CONSTRAINT_PASSED; - } - - public function getLegacyStatus(): StatusValue { - $statusValue = StatusValue::newGood(); - - if ( $this->result === self::CONSTRAINT_FAILED ) { - $statusValue->setResult( false, self::AS_BLOCKED_PAGE_FOR_USER ); - } - - return $statusValue; - } - -} diff --git a/includes/editpage/Constraint/UserRateLimitConstraint.php b/includes/editpage/Constraint/UserRateLimitConstraint.php deleted file mode 100644 index 8f35dd5879d0..000000000000 --- a/includes/editpage/Constraint/UserRateLimitConstraint.php +++ /dev/null @@ -1,89 +0,0 @@ -limiter = $limiter; - $this->subject = $subject; - $this->oldContentModel = $oldContentModel; - $this->newContentModel = $newContentModel; - } - - private function limit( string $action, int $inc = 1 ): bool { - return $this->limiter->limit( $this->subject, $action, $inc ); - } - - public function checkConstraint(): string { - // Need to check for rate limits on `editcontentmodel` if it is changing - $contentModelChange = ( $this->newContentModel !== $this->oldContentModel ); - - // TODO inject and use a ThrottleStore once available, see T261744 - // Checking if the user is rate limited increments the counts, so we cannot perform - // the check again when getting the status; thus, store the result - if ( $this->limit( 'edit' ) - || $this->limit( 'linkpurge', 0 ) // only counted after the fact - || ( $contentModelChange && $this->limit( 'editcontentmodel' ) ) - ) { - $this->result = self::CONSTRAINT_FAILED; - } else { - $this->result = self::CONSTRAINT_PASSED; - } - - return $this->result; - } - - public function getLegacyStatus(): StatusValue { - $statusValue = StatusValue::newGood(); - - if ( $this->result === self::CONSTRAINT_FAILED ) { - $statusValue->fatal( 'actionthrottledtext' ); - $statusValue->value = self::AS_RATE_LIMITED; - } - - return $statusValue; - } - -} diff --git a/includes/editpage/EditPage.php b/includes/editpage/EditPage.php index 93a3ad5a28e8..f08196894d88 100644 --- a/includes/editpage/EditPage.php +++ b/includes/editpage/EditPage.php @@ -37,6 +37,7 @@ use MediaWiki\Context\IContextSource; use MediaWiki\Debug\DeprecationHelper; use MediaWiki\Deferred\DeferredUpdates; use MediaWiki\EditPage\Constraint\AccidentalRecreationConstraint; +use MediaWiki\EditPage\Constraint\AuthorizationConstraint; use MediaWiki\EditPage\Constraint\BrokenRedirectConstraint; use MediaWiki\EditPage\Constraint\ChangeTagsConstraint; use MediaWiki\EditPage\Constraint\ContentModelChangeConstraint; @@ -54,7 +55,6 @@ use MediaWiki\EditPage\Constraint\PageSizeConstraint; use MediaWiki\EditPage\Constraint\SelfRedirectConstraint; use MediaWiki\EditPage\Constraint\SpamRegexConstraint; use MediaWiki\EditPage\Constraint\UnicodeConstraint; -use MediaWiki\EditPage\Constraint\UserBlockConstraint; use MediaWiki\Exception\ErrorPageError; use MediaWiki\Exception\MWContentSerializationException; use MediaWiki\Exception\MWException; @@ -2189,24 +2189,31 @@ class EditPage implements IEditObject { ) ); $constraintRunner->addConstraint( - $constraintFactory->newUserBlockConstraint( $this->mTitle, $requestUser ) + $constraintFactory->newReadOnlyConstraint() ); + + // Load the page data from the primary DB. If anything changes in the meantime, + // we detect it by using page_latest like a token in a 1 try compare-and-swap. + $this->page->loadPageData( IDBAccessObject::READ_LATEST ); + $new = !$this->page->exists(); + $constraintRunner->addConstraint( - new ContentModelChangeConstraint( + new AuthorizationConstraint( $authority, $this->mTitle, - $this->contentModel + $new ) ); - $constraintRunner->addConstraint( - $constraintFactory->newReadOnlyConstraint() + new ContentModelChangeConstraint( + $authority, + $this->mTitle, + $this->contentModel + ) ); $constraintRunner->addConstraint( - $constraintFactory->newUserRateLimitConstraint( - $requestUser->toRateLimitSubject(), - $this->mTitle->getContentModel(), - $this->contentModel + $constraintFactory->newLinkPurgeRateLimitConstraint( + $requestUser->toRateLimitSubject() ) ); $constraintRunner->addConstraint( @@ -2230,16 +2237,6 @@ class EditPage implements IEditObject { ) ); - // Load the page data from the primary DB. If anything changes in the meantime, - // we detect it by using page_latest like a token in a 1 try compare-and-swap. - $this->page->loadPageData( IDBAccessObject::READ_LATEST ); - $new = !$this->page->exists(); - - // We do this last, as some of the other constraints are more specific - $constraintRunner->addConstraint( - $constraintFactory->newEditRightConstraint( $this->getUserForPermissions(), $this->mTitle, $new ) - ); - // Check the constraints if ( !$constraintRunner->checkConstraints() ) { $failed = $constraintRunner->getFailedConstraint(); @@ -2625,9 +2622,12 @@ class EditPage implements IEditObject { * result from the backend. */ private function handleFailedConstraint( IEditConstraint $failed ): void { - if ( $failed instanceof UserBlockConstraint ) { + if ( $failed instanceof AuthorizationConstraint ) { // Auto-block user's IP if the account was "hard" blocked - if ( !MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) { + if ( + !MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() + && $failed->getLegacyStatus()->value === self::AS_BLOCKED_PAGE_FOR_USER + ) { $this->context->getUser()->spreadAnyEditBlock(); } } elseif ( $failed instanceof DefaultTextConstraint ) { diff --git a/tests/phpunit/includes/editpage/EditPageConstraintsTest.php b/tests/phpunit/includes/editpage/EditPageConstraintsTest.php index 2ca607066035..84dbbc7c7723 100644 --- a/tests/phpunit/includes/editpage/EditPageConstraintsTest.php +++ b/tests/phpunit/includes/editpage/EditPageConstraintsTest.php @@ -7,7 +7,9 @@ use MediaWiki\EditPage\EditPage; use MediaWiki\EditPage\SpamChecker; use MediaWiki\MainConfigNames; use MediaWiki\Permissions\PermissionManager; +use MediaWiki\Permissions\PermissionStatus; use MediaWiki\Request\FauxRequest; +use MediaWiki\Tests\Unit\MockBlockTrait; use MediaWiki\Tests\User\TempUser\TempUserTestTrait; use MediaWiki\Title\Title; use MediaWiki\User\User; @@ -27,6 +29,7 @@ use Wikimedia\Rdbms\ReadOnlyMode; class EditPageConstraintsTest extends MediaWikiLangTestCase { use TempUserTestTrait; + use MockBlockTrait; protected function setUp(): void { parent::setUp(); @@ -183,8 +186,8 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { $user = $this->getTestUser()->getUser(); $permissionManager = $this->getServiceContainer()->getPermissionManager(); - // Needs edit rights to pass EditRightConstraint and reach AccidentalRecreationConstraint - $permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] ); + // Needs these rights to pass AuthorizationConstraint and reach AccidentalRecreationConstraint + $permissionManager->overrideUserRightsForTesting( $user, [ 'edit', 'createpage' ] ); // Started the edit on 1 January 2019, page was deleted on 1 January 2020 $edit = [ @@ -215,7 +218,7 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { $user = $this->getTestUser()->getUser(); $permissionManager = $this->getServiceContainer()->getPermissionManager(); - // Needs edit rights to pass EditRightConstraint and reach NewSectionMissingSubjectConstraint + // Needs these rights to pass AuthorizationConstraint and reach NewSectionMissingSubjectConstraint $permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] ); $edit = [ @@ -258,7 +261,7 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { public function testContentModelChangeConstraint() { $user = $this->getTestUser()->getUser(); $permissionManager = $this->getServiceContainer()->getPermissionManager(); - // Needs edit rights to pass EditRightConstraint and reach ContentModelChangeConstraint + // Needs these rights to pass AuthorizationConstraint and reach ContentModelChangeConstraint $permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] ); $edit = [ @@ -285,14 +288,13 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { ); } - /** CreationPermissionConstraint integration */ - public function testCreationPermissionConstraint() { - $page = $this->getNonexistingTestPage( 'CreationPermissionConstraint page does not exist' ); + /** AuthorizationConstraint integration - 'create' rights */ + public function testAuthorizationConstraint_create() { + $page = $this->getNonexistingTestPage( 'AuthorizationConstraint_create page does not exist' ); $title = $page->getTitle(); $user = $this->getTestUser()->getUser(); $permissionManager = $this->getServiceContainer()->getPermissionManager(); - // Needs edit rights to pass EditRightConstraint and reach CreationPermissionConstraint $permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] ); $edit = [ @@ -316,7 +318,7 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { $user = $this->getTestUser()->getUser(); $permissionManager = $this->getServiceContainer()->getPermissionManager(); - // Needs edit and createpage rights to pass EditRightConstraint and CreationPermissionConstraint + // Needs these rights to pass AuthorizationConstraint $permissionManager->overrideUserRightsForTesting( $user, [ 'edit', 'createpage' ] ); $edit = [ @@ -366,7 +368,7 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { $user = $this->getTestUser()->getUser(); $permissionManager = $this->getServiceContainer()->getPermissionManager(); - // Needs edit and createpage rights to pass EditRightConstraint and CreationPermissionConstraint + // Needs these rights to pass AuthorizationConstraint $permissionManager->overrideUserRightsForTesting( $user, [ 'edit', 'createpage' ] ); $edit = [ @@ -399,12 +401,12 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { } /** - * EditRightConstraint integration - * @dataProvider provideTestEditRightConstraint + * AuthorizationConstraint integration - 'edit' rights + * @dataProvider provideTestAuthorizationConstraint_edit * @param bool $anon * @param int $expectedErrorCode */ - public function testEditRightConstraint( $anon, $expectedErrorCode ) { + public function testAuthorizationConstraint_edit( $anon, $expectedErrorCode ) { if ( $anon ) { $this->disableAutoCreateTempUser(); $user = $this->getServiceContainer()->getUserFactory()->newAnonymous( '127.0.0.1' ); @@ -428,7 +430,7 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { ); } - public static function provideTestEditRightConstraint() { + public static function provideTestAuthorizationConstraint_edit() { yield 'Anonymous user' => [ true, EditPage::AS_READ_ONLY_PAGE_ANON ]; yield 'Registered user' => [ false, EditPage::AS_READ_ONLY_PAGE_LOGGED ]; } @@ -448,7 +450,7 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { } $permissionManager = $this->getServiceContainer()->getPermissionManager(); - // Needs edit rights to pass EditRightConstraint and reach ImageRedirectConstraint + // Needs these rights to pass AuthorizationConstraint and reach ImageRedirectConstraint $permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] ); $edit = [ @@ -480,7 +482,7 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { $user = $this->getTestUser()->getUser(); $permissionManager = $this->getServiceContainer()->getPermissionManager(); - // Needs edit rights to pass EditRightConstraint and reach MissingCommentConstraint + // Needs these rights to pass AuthorizationConstraint and reach MissingCommentConstraint $permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] ); $edit = [ @@ -512,7 +514,7 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { $user = $this->getTestUser()->getUser(); $permissionManager = $this->getServiceContainer()->getPermissionManager(); - // Needs edit rights to pass EditRightConstraint and reach NewSectionMissingSubjectConstraint + // Needs these rights to pass AuthorizationConstraint and reach NewSectionMissingSubjectConstraint $permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] ); $edit = [ @@ -646,21 +648,14 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { ); } - /** UserBlockConstraint integration */ - public function testUserBlockConstraint() { - $user = $this->createMock( User::class ); - $user->method( 'getName' )->willReturn( 'NameGoesHere' ); - $user->method( 'getId' )->willReturn( 12345 ); - + /** AuthorizationConstraint integration - user blocks */ + public function testAuthorizationConstraint_block() { $permissionManager = $this->createMock( PermissionManager::class ); - // Needs edit rights to pass EditRightConstraint and reach UserBlockConstraint - $permissionManager->method( 'userHasRight' )->willReturn( true ); - $permissionManager->method( 'userCan' )->willReturn( true ); - + $permissionStatus = PermissionStatus::newEmpty(); + $permissionStatus->setBlock( $this->makeMockBlock() ); // Not worried about the specifics of the method call, those are tested in - // the UserBlockConstraintTest - $permissionManager->method( 'isBlockedFrom' )->willReturn( true ); - + // the AuthorizationConstraintTest + $permissionManager->method( 'getPermissionStatus' )->willReturn( $permissionStatus ); $this->setService( 'PermissionManager', $permissionManager ); $edit = [ @@ -677,8 +672,8 @@ class EditPageConstraintsTest extends MediaWikiLangTestCase { ); } - /** UserRateLimitConstraint integration */ - public function testUserRateLimitConstraint() { + /** LinkPurgeRateLimitConstraint integration */ + public function testLinkPurgeRateLimitConstraint() { $this->setTemporaryHook( 'PingLimiter', static function ( $user, $action, &$result, $incrBy ) { diff --git a/tests/phpunit/mocks/permissions/MockAuthorityTrait.php b/tests/phpunit/mocks/permissions/MockAuthorityTrait.php index b9b0c9350cbd..eedb4e3d7c70 100644 --- a/tests/phpunit/mocks/permissions/MockAuthorityTrait.php +++ b/tests/phpunit/mocks/permissions/MockAuthorityTrait.php @@ -334,6 +334,7 @@ trait MockAuthorityTrait { return true; } ); $mock->method( 'getBlock' )->willReturn( $block ); + $mock->method( 'isRegistered' )->willReturn( $user->isRegistered() ); $mock->method( 'isTemp' )->willReturn( $isTemp ); $mock->method( 'isNamed' )->willReturn( $user->isRegistered() && !$isTemp ); return $mock; diff --git a/tests/phpunit/unit/includes/editpage/Constraint/AuthorizationConstraintTest.php b/tests/phpunit/unit/includes/editpage/Constraint/AuthorizationConstraintTest.php new file mode 100644 index 000000000000..68d8c6541858 --- /dev/null +++ b/tests/phpunit/unit/includes/editpage/Constraint/AuthorizationConstraintTest.php @@ -0,0 +1,111 @@ +assertConstraintPassed( $constraint ); + } + + public function provideTestPass(): iterable { + yield 'Edit existing page' => [ + 'performer' => $this->mockAnonAuthorityWithPermissions( [ 'edit' ] ), + 'page' => PageIdentityValue::localIdentity( 123, NS_MAIN, 'AuthorizationConstraintTest' ), + 'new' => false, + ]; + yield 'Create a new page' => [ + 'performer' => $this->mockAnonAuthorityWithPermissions( [ 'edit', 'create' ] ), + 'page' => PageIdentityValue::localIdentity( 0, NS_MAIN, 'AuthorizationConstraintTest' ), + 'new' => true, + ]; + } + + /** + * @dataProvider provideTestFailure + */ + public function testFailure( + Authority $performer, PageIdentity $page, bool $new, int $expectedValue + ): void { + $constraint = new AuthorizationConstraint( + $performer, + $page, + $new + ); + $this->assertConstraintFailed( $constraint, $expectedValue ); + } + + public function provideTestFailure(): iterable { + yield 'Anonymous user' => [ + 'performer' => $this->mockAnonAuthorityWithoutPermissions( [ 'edit' ] ), + 'page' => PageIdentityValue::localIdentity( 123, NS_MAIN, 'AuthorizationConstraintTest' ), + 'new' => false, + 'expectedValue' => IEditConstraint::AS_READ_ONLY_PAGE_ANON, + ]; + yield 'Registered user' => [ + 'performer' => $this->mockRegisteredAuthorityWithoutPermissions( [ 'edit' ] ), + 'page' => PageIdentityValue::localIdentity( 123, NS_MAIN, 'AuthorizationConstraintTest' ), + 'new' => false, + 'expectedValue' => IEditConstraint::AS_READ_ONLY_PAGE_LOGGED, + ]; + yield 'User without create permission creates a page' => [ + 'performer' => $this->mockAnonAuthorityWithoutPermissions( [ 'create' ] ), + 'page' => PageIdentityValue::localIdentity( 0, NS_MAIN, 'AuthorizationConstraintTest' ), + 'new' => true, + 'expectedValue' => IEditConstraint::AS_NO_CREATE_PERMISSION, + ]; + yield 'Blocked user' => [ + 'performer' => $this->mockUserAuthorityWithBlock( + UserIdentityValue::newRegistered( 42, 'AuthorizationConstraintTest User' ), + $this->makeMockBlock( [ + 'decodedExpiry' => 'infinity', + ] ), + [ 'edit' ] + ), + 'page' => PageIdentityValue::localIdentity( 123, NS_MAIN, 'AuthorizationConstraintTest' ), + 'new' => false, + 'expectedValue' => IEditConstraint::AS_BLOCKED_PAGE_FOR_USER, + ]; + } + +} diff --git a/tests/phpunit/unit/includes/editpage/Constraint/EditConstraintFactoryTest.php b/tests/phpunit/unit/includes/editpage/Constraint/EditConstraintFactoryTest.php index dabb27a15ff1..76ce13cb71d5 100644 --- a/tests/phpunit/unit/includes/editpage/Constraint/EditConstraintFactoryTest.php +++ b/tests/phpunit/unit/includes/editpage/Constraint/EditConstraintFactoryTest.php @@ -27,13 +27,11 @@ use MediaWiki\EditPage\Constraint\PageSizeConstraint; use MediaWiki\EditPage\Constraint\ReadOnlyConstraint; use MediaWiki\EditPage\Constraint\SimpleAntiSpamConstraint; use MediaWiki\EditPage\Constraint\SpamRegexConstraint; -use MediaWiki\EditPage\Constraint\UserBlockConstraint; use MediaWiki\EditPage\SpamChecker; use MediaWiki\HookContainer\HookContainer; use MediaWiki\Language\Language; use MediaWiki\Logger\Spi; use MediaWiki\MainConfigNames; -use MediaWiki\Permissions\PermissionManager; use MediaWiki\Permissions\RateLimiter; use MediaWiki\Title\Title; use MediaWiki\User\User; @@ -61,7 +59,6 @@ class EditConstraintFactoryTest extends MediaWikiUnitTestCase { $factory = new EditConstraintFactory( $options, $loggerFactory, - $this->createMock( PermissionManager::class ), $this->createMock( HookContainer::class ), $this->createMock( ReadOnlyMode::class ), $this->createMock( SpamChecker::class ), @@ -107,9 +104,5 @@ class EditConstraintFactoryTest extends MediaWikiUnitTestCase { $title ) ); - $this->assertInstanceOf( - UserBlockConstraint::class, - $factory->newUserBlockConstraint( $title, $user ) - ); } } diff --git a/tests/phpunit/unit/includes/editpage/Constraint/EditRightConstraintTest.php b/tests/phpunit/unit/includes/editpage/Constraint/EditRightConstraintTest.php deleted file mode 100644 index 9d3346e87d01..000000000000 --- a/tests/phpunit/unit/includes/editpage/Constraint/EditRightConstraintTest.php +++ /dev/null @@ -1,170 +0,0 @@ -createMock( Title::class ), - $new - ); - $this->assertConstraintPassed( $constraint ); - } - - public function provideTestPass() { - $title = $this->createMock( Title::class ); - $userEdit = $this->createMock( User::class ); - $permissionManagerEdit = $this->createMock( PermissionManager::class ); - $permissionManagerEdit->expects( $this->once() ) - ->method( 'userCan' ) - ->with( - 'edit', - $userEdit, - $title - ) - ->willReturn( true ); - $userCreateAndEdit = $this->createMock( User::class ); - $userCreateAndEdit->expects( $this->once() ) - ->method( 'authorizeWrite' ) - ->with( - 'create', - $title - ) - ->willReturn( true ); - $permissionManagerCreateAndEdit = $this->createMock( PermissionManager::class ); - $permissionManagerCreateAndEdit->expects( $this->once() ) - ->method( 'userCan' ) - ->with( - 'edit', - $userCreateAndEdit, - $title - ) - ->willReturn( true ); - yield 'Edit existing page' => [ - 'performer' => $userEdit, - 'new' => false, - 'permissionManager' => $permissionManagerEdit - ]; - yield 'Create a new page' => [ - 'performer' => $userCreateAndEdit, - 'new' => true, - 'permissionManager' => $permissionManagerCreateAndEdit - ]; - } - - /** - * @dataProvider provideTestFailure - * @param User $performer - * @param bool $new - * @param PermissionManager $permissionManager - * @param int $expectedValue - */ - public function testFailure( - User $performer, bool $new, PermissionManager $permissionManager, int $expectedValue - ) { - $title = $this->createMock( Title::class ); - $constraint = new EditRightConstraint( - $performer, - $permissionManager, - $title, - $new - ); - $this->assertConstraintFailed( $constraint, $expectedValue ); - } - - public function provideTestFailure() { - $title = $this->createMock( Title::class ); - $anon = $this->createMock( User::class ); - $anon->expects( $this->once() )->method( 'isRegistered' )->willReturn( false ); - $permissionManagerAnon = $this->createMock( PermissionManager::class ); - $permissionManagerAnon->expects( $this->once() ) - ->method( 'userCan' ) - ->with( - 'edit', - $anon, - $title - ) - ->willReturn( false ); - $reg = $this->createMock( User::class ); - $reg->expects( $this->once() )->method( 'isRegistered' )->willReturn( true ); - $permissionManagerReg = $this->createMock( PermissionManager::class ); - $permissionManagerReg->expects( $this->once() ) - ->method( 'userCan' ) - ->with( - 'edit', - $reg, - $title - ) - ->willReturn( false ); - $userWithoutCreatePerm = $this->createMock( User::class ); - $userWithoutCreatePerm->expects( $this->once() ) - ->method( 'authorizeWrite' ) - ->with( - 'create', - $title - ) - ->willReturn( false ); - yield 'Anonymous user' => [ - 'performer' => $anon, - 'new' => false, - 'permissionManager' => $permissionManagerAnon, - 'expectedValue' => IEditConstraint::AS_READ_ONLY_PAGE_ANON, - ]; - yield 'Registered user' => [ - 'performer' => $reg, - 'new' => false, - 'permissionManager' => $permissionManagerReg, - 'expectedValue' => IEditConstraint::AS_READ_ONLY_PAGE_LOGGED, - ]; - yield 'User without create permission creates a page' => [ - 'performer' => $userWithoutCreatePerm, - 'new' => true, - 'permissionManager' => $this->createMock( PermissionManager::class ), - 'expectedValue' => IEditConstraint::AS_NO_CREATE_PERMISSION, - ]; - } - -} diff --git a/tests/phpunit/unit/includes/editpage/Constraint/LinkPurgeRateLimitConstraintTest.php b/tests/phpunit/unit/includes/editpage/Constraint/LinkPurgeRateLimitConstraintTest.php new file mode 100644 index 000000000000..7363601e0f72 --- /dev/null +++ b/tests/phpunit/unit/includes/editpage/Constraint/LinkPurgeRateLimitConstraintTest.php @@ -0,0 +1,70 @@ +createNoOpMock( RateLimiter::class, [ 'limit' ] ); + $mock->expects( $this->once() ) + ->method( 'limit' ) + ->with( self::anything(), 'linkpurge', 0 ) + ->willReturn( $fail ); + return $mock; + } + + public function testPass() { + $limiter = $this->getRateLimiter( false ); + + $subject = new RateLimitSubject( new UserIdentityValue( 1, 'test' ), null, [] ); + + $constraint = new LinkPurgeRateLimitConstraint( $limiter, $subject ); + $this->assertConstraintPassed( $constraint ); + } + + public function testFailure() { + $limiter = $this->getRateLimiter( true ); + + $subject = new RateLimitSubject( new UserIdentityValue( 1, 'test' ), null, [] ); + + $constraint = new LinkPurgeRateLimitConstraint( $limiter, $subject ); + $this->assertConstraintFailed( $constraint, IEditConstraint::AS_RATE_LIMITED ); + } + +} diff --git a/tests/phpunit/unit/includes/editpage/Constraint/UserBlockConstraintTest.php b/tests/phpunit/unit/includes/editpage/Constraint/UserBlockConstraintTest.php deleted file mode 100644 index e231e4a50c66..000000000000 --- a/tests/phpunit/unit/includes/editpage/Constraint/UserBlockConstraintTest.php +++ /dev/null @@ -1,72 +0,0 @@ -createMock( Title::class ); - $user = $this->createMock( User::class ); - $permissionManager = $this->createMock( PermissionManager::class ); - $permissionManager->expects( $this->once() ) - ->method( 'isBlockedFrom' ) - ->with( - $user, - $title - ) - ->willReturn( false ); - - $constraint = new UserBlockConstraint( $permissionManager, $title, $user ); - $this->assertConstraintPassed( $constraint ); - } - - public function testFailure() { - $title = $this->createMock( Title::class ); - $user = $this->createMock( User::class ); - $permissionManager = $this->createMock( PermissionManager::class ); - $permissionManager->expects( $this->once() ) - ->method( 'isBlockedFrom' ) - ->with( - $user, - $title - ) - ->willReturn( true ); - - $constraint = new UserBlockConstraint( $permissionManager, $title, $user ); - $this->assertConstraintFailed( - $constraint, - IEditConstraint::AS_BLOCKED_PAGE_FOR_USER - ); - } - -} diff --git a/tests/phpunit/unit/includes/editpage/Constraint/UserRateLimitConstraintTest.php b/tests/phpunit/unit/includes/editpage/Constraint/UserRateLimitConstraintTest.php deleted file mode 100644 index b832aa7ee5e5..000000000000 --- a/tests/phpunit/unit/includes/editpage/Constraint/UserRateLimitConstraintTest.php +++ /dev/null @@ -1,79 +0,0 @@ -createNoOpMock( RateLimiter::class, [ 'limit' ] ); - $expectedArgs = [ - [ 'edit', 1, false ], - [ 'linkpurge', 0, false ], - [ 'editcontentmodel', 1, $fail ] - ]; - $mock->expects( $this->exactly( 3 ) ) - ->method( 'limit' ) - ->willReturnCallback( function ( $_, $action, $incrBy ) use ( &$expectedArgs ) { - $curExpectedArgs = array_shift( $expectedArgs ); - $this->assertSame( $curExpectedArgs[0], $action ); - $this->assertSame( $curExpectedArgs[1], $incrBy ); - return $curExpectedArgs[2]; - } ); - return $mock; - } - - public function testPass() { - $limiter = $this->getRateLimiter( false ); - - $subject = new RateLimitSubject( new UserIdentityValue( 1, 'test' ), null, [] ); - - $constraint = new UserRateLimitConstraint( $limiter, $subject, 'OldContentModel', 'NewContentModel' ); - $this->assertConstraintPassed( $constraint ); - } - - public function testFailure() { - $limiter = $this->getRateLimiter( true ); - - $subject = new RateLimitSubject( new UserIdentityValue( 1, 'test' ), null, [] ); - - $constraint = new UserRateLimitConstraint( $limiter, $subject, 'OldContentModel', 'NewContentModel' ); - $this->assertConstraintFailed( $constraint, IEditConstraint::AS_RATE_LIMITED ); - } - -} -- cgit v1.2.3