isPartial(). */ private $isPartialRaw; /** @var AbstractRestriction[] */ private $blockRestrictions = []; /** @var string[] */ private $tags = []; /** @var int|null */ private $logDeletionFlags; /** * @param ServiceOptions $options * @param BlockRestrictionStore $blockRestrictionStore * @param BlockPermissionCheckerFactory $blockPermissionCheckerFactory * @param BlockTargetFactory $blockTargetFactory * @param BlockActionInfo $blockActionInfo * @param HookContainer $hookContainer * @param DatabaseBlockStore $databaseBlockStore * @param UserFactory $userFactory * @param UserEditTracker $userEditTracker * @param LoggerInterface $logger * @param TitleFactory $titleFactory * @param DatabaseBlock|null $blockToUpdate * @param BlockTarget|string|UserIdentity|null $target Target of the block * @param Authority $performer Performer of the block * @param string $expiry Expiry of the block (timestamp or 'infinity') * @param string $reason Reason of the block * @param bool[] $blockOptions * Valid options: * - isCreateAccountBlocked : Are account creations prevented? * - isEmailBlocked : Is emailing other users prevented? * - isHardBlock : Are named (non-temporary) users prevented from editing? * - isAutoblocking : Should this block spread to others to * limit block evasion? * - isUserTalkEditBlocked : Is editing blocked user's own talk page prevented? * - isHideUser : Should blocked user's name be hidden (needs hideuser)? * - isPartial : Is this block partial? This is ignored when * blockRestrictions is not an empty array. * @param AbstractRestriction[] $blockRestrictions * @param string[] $tags Tags that should be assigned to the log entry */ public function __construct( ServiceOptions $options, BlockRestrictionStore $blockRestrictionStore, BlockPermissionCheckerFactory $blockPermissionCheckerFactory, BlockTargetFactory $blockTargetFactory, BlockActionInfo $blockActionInfo, HookContainer $hookContainer, DatabaseBlockStore $databaseBlockStore, UserFactory $userFactory, UserEditTracker $userEditTracker, LoggerInterface $logger, TitleFactory $titleFactory, ?DatabaseBlock $blockToUpdate, $target, Authority $performer, string $expiry, string $reason, array $blockOptions, array $blockRestrictions, array $tags ) { // Process dependencies $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); $this->options = $options; $this->blockRestrictionStore = $blockRestrictionStore; $this->blockTargetFactory = $blockTargetFactory; $this->hookRunner = new HookRunner( $hookContainer ); $this->blockStore = $databaseBlockStore; $this->userFactory = $userFactory; $this->userEditTracker = $userEditTracker; $this->logger = $logger; $this->titleFactory = $titleFactory; $this->blockActionInfo = $blockActionInfo; // Process block target if ( $blockToUpdate !== null ) { if ( $blockToUpdate->getType() === AbstractBlock::TYPE_AUTO ) { // Caller must check this throw new \InvalidArgumentException( "Can't update an autoblock" ); } $this->blockToUpdate = $blockToUpdate; $this->target = $blockToUpdate->getTarget(); } elseif ( $target instanceof BlockTarget ) { $this->target = $target; } elseif ( $target === null ) { throw new \InvalidArgumentException( 'Either $target or $blockToUpdate must be specified' ); } else { // TODO: deprecate $this->target = $this->blockTargetFactory->newFromLegacyUnion( $target ); } $this->blockPermissionChecker = $blockPermissionCheckerFactory ->newChecker( $performer ); // Process other block parameters $this->performer = $performer; $this->rawExpiry = $expiry; $this->expiryTime = self::parseExpiryInput( $this->rawExpiry ); $this->reason = $reason; $this->blockRestrictions = $blockRestrictions; $this->tags = $tags; // Process blockOptions foreach ( [ 'isCreateAccountBlocked', 'isEmailBlocked', 'isHardBlock', 'isAutoblocking', ] as $possibleBlockOption ) { if ( isset( $blockOptions[ $possibleBlockOption ] ) ) { $this->$possibleBlockOption = $blockOptions[ $possibleBlockOption ]; } } $this->isPartialRaw = !empty( $blockOptions['isPartial'] ) && !$blockRestrictions; if ( !$this->isPartial() || in_array( NS_USER_TALK, $this->getNamespaceRestrictions() ) ) { // It is possible to block user talk edit. User talk edit is: // - always blocked if the config says so; // - otherwise blocked/unblocked if the option was passed in; // - otherwise defaults to not blocked. if ( !$this->options->get( MainConfigNames::BlockAllowsUTEdit ) ) { $this->isUserTalkEditBlocked = true; } else { $this->isUserTalkEditBlocked = $blockOptions['isUserTalkEditBlocked'] ?? false; } } else { // It is not possible to block user talk edit. If the option // was passed, an error will be thrown in ::placeBlockUnsafe. // Otherwise, set to not blocked. if ( !isset( $blockOptions['isUserTalkEditBlocked'] ) || !$blockOptions['isUserTalkEditBlocked'] ) { $this->isUserTalkEditBlocked = false; } } if ( isset( $blockOptions['isHideUser'] ) && $this->target instanceof UserBlockTarget ) { $this->isHideUser = $blockOptions['isHideUser']; } } /** * @unstable This method might be removed without prior notice (see T271101) * @param int $flags One of LogPage::* constants */ public function setLogDeletionFlags( int $flags ): void { $this->logDeletionFlags = $flags; } /** * Convert a submitted expiry time, which may be relative ("2 weeks", etc) or absolute * ("24 May 2034", etc), into an absolute timestamp we can put into the database. * * @todo strtotime() only accepts English strings. This means the expiry input * can only be specified in English. * @see https://www.php.net/manual/en/function.strtotime.php * * @param string $expiry Whatever was typed into the form * * @return string|false Timestamp (format TS_MW) or 'infinity' or false on error. */ public static function parseExpiryInput( string $expiry ) { try { return ExpiryDef::normalizeExpiry( $expiry, TS_MW ); } catch ( InvalidArgumentException $e ) { return false; } } /** * Is the to-be-placed block partial? */ private function isPartial(): bool { return $this->blockRestrictions !== [] || $this->isPartialRaw; } /** * Configure DatabaseBlock according to class properties * * @param DatabaseBlock|null $sourceBlock Copy any options from this block. * Null to construct a new one. * * @return DatabaseBlock */ private function configureBlock( $sourceBlock = null ): DatabaseBlock { if ( $sourceBlock === null ) { $block = new DatabaseBlock(); } else { $block = clone $sourceBlock; } $isSitewide = !$this->isPartial(); $block->setTarget( $this->target ); $block->setBlocker( $this->performer->getUser() ); $block->setReason( $this->reason ); $block->setExpiry( $this->expiryTime ); $block->isCreateAccountBlocked( $this->isCreateAccountBlocked ); $block->isEmailBlocked( $this->isEmailBlocked ); $block->isHardblock( $this->isHardBlock ); $block->isAutoblocking( $this->isAutoblocking ); $block->isSitewide( $isSitewide ); $block->isUsertalkEditAllowed( !$this->isUserTalkEditBlocked ); $block->setHideName( $this->isHideUser ); $blockId = $block->getId(); if ( $blockId === null ) { // Block wasn't inserted into the DB yet $block->setRestrictions( $this->blockRestrictions ); } else { // Block is in the DB, we need to set restrictions through a service $block->setRestrictions( $this->blockRestrictionStore->setBlockId( $blockId, $this->blockRestrictions ) ); } return $block; } /** * Get prior blocks matching the current target. If we are updating a block * by ID, this will include blocks for the same target as that ID. * * @return DatabaseBlock[] */ private function getPriorBlocksForTarget() { if ( $this->priorBlocksForTarget === null ) { $this->priorBlocksForTarget = $this->blockStore->newListFromTarget( $this->target, null, true, // If we're blocking an IP, ignore any matching autoblocks (T287798) DatabaseBlockStore::AUTO_SPECIFIED ); } return $this->priorBlocksForTarget; } /** * Determine if the target user is hidden (prior to applying pending changes) * @return bool */ private function wasTargetHidden() { if ( $this->target->getType() !== AbstractBlock::TYPE_USER ) { return false; } foreach ( $this->getPriorBlocksForTarget() as $block ) { if ( $block->getHideName() ) { return true; } } return false; } /** * Place a block, checking permissions * * @param string|bool $conflictMode The insertion conflict mode. Ignored if * a block to update was specified in the constructor, for example by * calling UserBlockCommandFactory::newUpdateBlock(). May be one of: * - self::CONFLICT_FAIL: Allow the block only if there are no prior * blocks on the same target. * - self::CONFLICT_NEW: Create an additional block regardless of * pre-existing blocks on the same target. This is allowed only if * $wgEnableMultiBlocks is true. * - self::CONFLICT_REBLOCK: This value is deprecated. If there is one * prior block on the target, update it. If there is more than one block, * throw an exception. * * @return Status If the block is successful, the value of the returned * Status is an instance of a newly placed block. */ public function placeBlock( $conflictMode = self::CONFLICT_FAIL ): Status { $priorHideUser = $this->wasTargetHidden(); if ( $this->blockPermissionChecker ->checkBasePermissions( $this->isHideUser || $priorHideUser ) !== true ) { $this->logger->debug( 'placeBlock: checkBasePermissions failed' ); return Status::newFatal( $priorHideUser ? 'cant-see-hidden-user' : 'badaccess-group0' ); } $blockCheckResult = $this->blockPermissionChecker->checkBlockPermissions( $this->target ); if ( $blockCheckResult !== true ) { $this->logger->debug( 'placeBlock: checkBlockPermissions failed' ); return Status::newFatal( $blockCheckResult ); } if ( $this->isEmailBlocked && !$this->blockPermissionChecker->checkEmailPermissions() ) { // TODO: Maybe not ignore the error here? $this->isEmailBlocked = false; } if ( $this->tags !== [] ) { $status = ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->performer ); if ( !$status->isOK() ) { $this->logger->debug( 'placeBlock: ChangeTags::canAddTagsAccompanyingChange failed' ); return $status; } } $status = Status::newGood(); foreach ( $this->getPageRestrictions() as $pageRestriction ) { try { $title = $this->titleFactory->newFromTextThrow( $pageRestriction ); if ( !$title->exists() ) { $this->logger->debug( "placeBlock: nonexistent page restriction $title" ); $status->fatal( 'cant-block-nonexistent-page', $pageRestriction ); } } catch ( MalformedTitleException $e ) { $this->logger->debug( 'placeBlock: malformed page restriction title' ); $status->fatal( $e->getMessageObject() ); } } if ( !$status->isOK() ) { return $status; } return $this->placeBlockUnsafe( $conflictMode ); } /** * Place a block without any sort of permissions checks. * * @param string|bool $conflictMode * * @return Status If the block is successful, the value of the returned * Status is an instance of a newly placed block. */ public function placeBlockUnsafe( $conflictMode = self::CONFLICT_FAIL ): Status { $status = Status::wrap( $this->target->validateForCreation() ); if ( !$status->isOK() ) { $this->logger->debug( 'placeBlockUnsafe: invalid target' ); return $status; } if ( $this->isUserTalkEditBlocked === null ) { $this->logger->debug( 'placeBlockUnsafe: partial block on user talk page' ); return Status::newFatal( 'ipb-prevent-user-talk-edit' ); } if ( // There should be some expiry $this->rawExpiry === '' || // can't be a larger string as 50 (it should be a time format in any way) strlen( $this->rawExpiry ) > 50 || // the time can't be parsed !$this->expiryTime ) { $this->logger->debug( 'placeBlockUnsafe: invalid expiry' ); return Status::newFatal( 'ipb_expiry_invalid' ); } if ( $this->expiryTime < wfTimestampNow() ) { $this->logger->debug( 'placeBlockUnsafe: expiry in the past' ); return Status::newFatal( 'ipb_expiry_old' ); } $hideUserTarget = $this->getHideUserTarget(); if ( $hideUserTarget ) { if ( $this->isPartial() ) { $this->logger->debug( 'placeBlockUnsafe: partial block cannot hide user' ); return Status::newFatal( 'ipb_hide_partial' ); } if ( !wfIsInfinity( $this->rawExpiry ) ) { $this->logger->debug( 'placeBlockUnsafe: temp user block has expiry' ); return Status::newFatal( 'ipb_expiry_temp' ); } $hideUserContribLimit = $this->options->get( MainConfigNames::HideUserContribLimit ); if ( $hideUserContribLimit !== false && $this->userEditTracker->getUserEditCount( $hideUserTarget ) > $hideUserContribLimit ) { $this->logger->debug( 'placeBlockUnsafe: hide user with too many contribs' ); return Status::newFatal( 'ipb_hide_invalid', Message::numParam( $hideUserContribLimit ) ); } } if ( $this->isPartial() ) { if ( $this->blockRestrictions === [] && !$this->isEmailBlocked && !$this->isCreateAccountBlocked && !$this->isUserTalkEditBlocked ) { $this->logger->debug( 'placeBlockUnsafe: empty partial block' ); return Status::newFatal( 'ipb-empty-block' ); } } return $this->placeBlockInternal( $conflictMode ); } /** * Places a block without any sort of permission or double checking, hooks can still * abort the block through, as well as already existing block. * * @param string|bool $conflictMode * * @return Status */ private function placeBlockInternal( $conflictMode ): Status { $block = $this->configureBlock( $this->blockToUpdate ); $denyReason = [ 'hookaborted' ]; $legacyUser = $this->userFactory->newFromAuthority( $this->performer ); if ( !$this->hookRunner->onBlockIp( $block, $legacyUser, $denyReason ) ) { $status = Status::newGood(); foreach ( $denyReason as $key ) { $this->logger->debug( "placeBlockInternal: hook aborted with message \"$key\"" ); $status->fatal( $key ); } return $status; } $expectedTargetCount = 0; $priorBlock = null; $priorBlocks = $this->getPriorBlocksForTarget(); if ( $this->blockToUpdate !== null ) { if ( $block->equals( $this->blockToUpdate ) ) { $this->logger->debug( 'placeBlockInternal: ' . 'already blocked with same params (blockToUpdate case)' ); return Status::newFatal( 'ipb_already_blocked', $block->getTargetName() ); } $priorBlock = $this->blockToUpdate; $update = true; } elseif ( $conflictMode === self::CONFLICT_NEW && $this->options->get( MainConfigNames::EnableMultiBlocks ) ) { foreach ( $this->getPriorBlocksForTarget() as $priorBlock ) { if ( $block->equals( $priorBlock ) ) { // Block settings are equal => user is already blocked $this->logger->debug( 'placeBlockInternal: ' . 'already blocked with same params (CONFLICT_NEW case)' ); return Status::newFatal( 'ipb_already_blocked', $block->getTargetName() ); } } $expectedTargetCount = null; $update = false; } elseif ( !$priorBlocks ) { $update = false; } else { // Reblock only if the caller wants so if ( $conflictMode !== self::CONFLICT_REBLOCK ) { $this->logger->debug( 'placeBlockInternal: already blocked and reblock not requested' ); return Status::newFatal( 'ipb_already_blocked', $block->getTargetName() ); } // Can't update multiple blocks unless blockToUpdate was given if ( count( $priorBlocks ) > 1 ) { throw new MultiblocksException( "Can\'t reblock a user with multiple blocks already present. " . "Update calling code for multiblocks, providing a specific block to update." ); } // Check for identical blocks $priorBlock = $priorBlocks[0]; if ( $block->equals( $priorBlock ) ) { // Block settings are equal => user is already blocked $this->logger->debug( 'placeBlockInternal: already blocked, no change' ); return Status::newFatal( 'ipb_already_blocked', $block->getTargetName() ); } $update = true; $block = $this->configureBlock( $priorBlock ); } if ( $update ) { $logEntry = $this->prepareLogEntry( true ); $this->blockStore->updateBlock( $block ); } else { $logEntry = $this->prepareLogEntry( false ); // Try to insert block. $insertStatus = $this->blockStore->insertBlock( $block, $expectedTargetCount ); if ( !$insertStatus ) { $this->logger->warning( 'Block could not be inserted. No existing block was found.' ); return Status::newFatal( 'ipb-block-not-found', $block->getTargetName() ); } if ( $insertStatus['finalTargetCount'] > 1 ) { $logEntry->addParameter( 'finalTargetCount', $insertStatus['finalTargetCount'] ); } } // Relate log ID to block ID (T27763) $logEntry->setRelations( [ 'ipb_id' => $block->getId() ] ); // Also save the ID to log_params, since MW 1.44 $logEntry->addParameter( 'blockId', $block->getId() ); // Set *_deleted fields if requested $hideUserTarget = $this->getHideUserTarget(); if ( $hideUserTarget ) { RevisionDeleteUser::suppressUserName( $hideUserTarget->getName(), $hideUserTarget->getId() ); } DeferredUpdates::addCallableUpdate( function () use ( $block, $legacyUser, $priorBlock ) { $this->hookRunner->onBlockIpComplete( $block, $legacyUser, $priorBlock ); } ); // DatabaseBlock constructor sanitizes certain block options on insert $this->isEmailBlocked = $block->isEmailBlocked(); $this->isAutoblocking = $block->isAutoblocking(); $this->log( $logEntry ); $this->logger->debug( 'placeBlockInternal: success' ); return Status::newGood( $block ); } /** * If the operation is hiding a user, get the user being hidden * * @return UserIdentity|null */ private function getHideUserTarget(): ?UserIdentity { if ( !$this->isHideUser ) { return null; } if ( !( $this->target instanceof UserBlockTarget ) ) { // Should be unreachable -- constructor checks this throw new \LogicException( 'Wrong target type used with hide user option' ); } return $this->target->getUserIdentity(); } /** * Build namespace restrictions array from $this->blockRestrictions * * Returns an array of namespace IDs. * * @return int[] */ private function getNamespaceRestrictions(): array { $namespaceRestrictions = []; foreach ( $this->blockRestrictions as $restriction ) { if ( $restriction instanceof NamespaceRestriction ) { $namespaceRestrictions[] = $restriction->getValue(); } } return $namespaceRestrictions; } /** * Build an array of page restrictions from $this->blockRestrictions * * Returns an array of stringified full page titles. * * @return string[] */ private function getPageRestrictions(): array { $pageRestrictions = []; foreach ( $this->blockRestrictions as $restriction ) { if ( $restriction instanceof PageRestriction ) { $pageRestrictions[] = $restriction->getTitle()->getFullText(); } } return $pageRestrictions; } /** * Build an array of actions from $this->blockRestrictions * * Returns an array of stringified actions. * * @return string[] */ private function getActionRestrictions(): array { $actionRestrictions = []; foreach ( $this->blockRestrictions as $restriction ) { if ( $restriction instanceof ActionRestriction ) { $actionRestrictions[] = $this->blockActionInfo->getActionFromId( $restriction->getValue() ); } } return $actionRestrictions; } /** * Prepare $logParams * * Helper method for $this->log() */ private function constructLogParams(): array { $logExpiry = wfIsInfinity( $this->rawExpiry ) ? 'infinity' : $this->rawExpiry; $logParams = [ '5::duration' => $logExpiry, '6::flags' => $this->blockLogFlags(), 'sitewide' => !$this->isPartial() ]; if ( $this->isPartial() ) { $pageRestrictions = $this->getPageRestrictions(); $namespaceRestrictions = $this->getNamespaceRestrictions(); $actionRestrictions = $this->getActionRestrictions(); if ( count( $pageRestrictions ) > 0 ) { $logParams['7::restrictions']['pages'] = $pageRestrictions; } if ( count( $namespaceRestrictions ) > 0 ) { $logParams['7::restrictions']['namespaces'] = $namespaceRestrictions; } if ( count( $actionRestrictions ) ) { $logParams['7::restrictions']['actions'] = $actionRestrictions; } } return $logParams; } /** * Create the log entry object to be inserted. Do read queries here before * we start locking block_target rows. * * @param bool $isReblock * @return ManualLogEntry */ private function prepareLogEntry( bool $isReblock ) { $logType = $this->isHideUser ? 'suppress' : 'block'; $logAction = $isReblock ? 'reblock' : 'block'; $title = Title::makeTitle( NS_USER, $this->target ); // Preload the page_id: needed for log_page in ManualLogEntry::insert() $title->getArticleID(); $logEntry = new ManualLogEntry( $logType, $logAction ); $logEntry->setTarget( $title ); $logEntry->setComment( $this->reason ); $logEntry->setPerformer( $this->performer->getUser() ); $logEntry->setParameters( $this->constructLogParams() ); $logEntry->addTags( $this->tags ); if ( $this->logDeletionFlags !== null ) { $logEntry->setDeleted( $this->logDeletionFlags ); } return $logEntry; } /** * Log the block to Special:Log */ private function log( ManualLogEntry $logEntry ) { $logId = $logEntry->insert(); $logEntry->publish( $logId ); } /** * Return a comma-delimited list of flags to be passed to the log * reader for this block, to provide more information in the logs. */ private function blockLogFlags(): string { $flags = []; if ( $this->target->getType() != AbstractBlock::TYPE_USER && !$this->isHardBlock ) { // For grepping: message block-log-flags-anononly $flags[] = 'anononly'; } if ( $this->isCreateAccountBlocked ) { // For grepping: message block-log-flags-nocreate $flags[] = 'nocreate'; } if ( $this->target->getType() == AbstractBlock::TYPE_USER && !$this->isAutoblocking ) { // For grepping: message block-log-flags-noautoblock $flags[] = 'noautoblock'; } if ( $this->isEmailBlocked ) { // For grepping: message block-log-flags-noemail $flags[] = 'noemail'; } if ( $this->options->get( MainConfigNames::BlockAllowsUTEdit ) && $this->isUserTalkEditBlocked ) { // For grepping: message block-log-flags-nousertalk $flags[] = 'nousertalk'; } if ( $this->isHideUser ) { // For grepping: message block-log-flags-hiddenname $flags[] = 'hiddenname'; } return implode( ',', $flags ); } }