Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
26.01% |
77 / 296 |
|
0.00% |
0 / 18 |
CRAP | |
0.00% |
0 / 1 |
| BlockUser | |
26.01% |
77 / 296 |
|
0.00% |
0 / 18 |
4920.83 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
240 | |||
| setLogDeletionFlags | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| parseExpiryInput | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| isPartial | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
| configureBlock | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
12 | |||
| getPriorBlocksForTarget | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| wasTargetHidden | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| placeBlock | |
55.88% |
19 / 34 |
|
0.00% |
0 / 1 |
27.51 | |||
| placeBlockUnsafe | |
50.00% |
18 / 36 |
|
0.00% |
0 / 1 |
53.12 | |||
| placeBlockInternal | |
58.82% |
40 / 68 |
|
0.00% |
0 / 1 |
37.18 | |||
| getHideUserTarget | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| getNamespaceRestrictions | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| getPageRestrictions | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| getActionRestrictions | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| constructLogParams | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
42 | |||
| prepareLogEntry | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
| log | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| blockLogFlags | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
110 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * @license GPL-2.0-or-later |
| 5 | * @file |
| 6 | */ |
| 7 | |
| 8 | namespace MediaWiki\Block; |
| 9 | |
| 10 | use InvalidArgumentException; |
| 11 | use MediaWiki\Block\Restriction\AbstractRestriction; |
| 12 | use MediaWiki\Block\Restriction\ActionRestriction; |
| 13 | use MediaWiki\Block\Restriction\NamespaceRestriction; |
| 14 | use MediaWiki\Block\Restriction\PageRestriction; |
| 15 | use MediaWiki\ChangeTags\ChangeTags; |
| 16 | use MediaWiki\Config\ServiceOptions; |
| 17 | use MediaWiki\Deferred\DeferredUpdates; |
| 18 | use MediaWiki\HookContainer\HookContainer; |
| 19 | use MediaWiki\HookContainer\HookRunner; |
| 20 | use MediaWiki\Logging\ManualLogEntry; |
| 21 | use MediaWiki\MainConfigNames; |
| 22 | use MediaWiki\Message\Message; |
| 23 | use MediaWiki\Permissions\Authority; |
| 24 | use MediaWiki\Status\Status; |
| 25 | use MediaWiki\Title\MalformedTitleException; |
| 26 | use MediaWiki\Title\Title; |
| 27 | use MediaWiki\Title\TitleFactory; |
| 28 | use MediaWiki\User\UserEditTracker; |
| 29 | use MediaWiki\User\UserFactory; |
| 30 | use MediaWiki\User\UserIdentity; |
| 31 | use Psr\Log\LoggerInterface; |
| 32 | use RevisionDeleteUser; |
| 33 | use Wikimedia\ParamValidator\TypeDef\ExpiryDef; |
| 34 | use Wikimedia\Timestamp\TimestampFormat as TS; |
| 35 | |
| 36 | /** |
| 37 | * Handles the backend logic of blocking users |
| 38 | * |
| 39 | * @since 1.36 |
| 40 | */ |
| 41 | class BlockUser { |
| 42 | /** On conflict, do not insert the block. The value is false for b/c */ |
| 43 | public const CONFLICT_FAIL = false; |
| 44 | /** On conflict, create a new block. */ |
| 45 | public const CONFLICT_NEW = 'new'; |
| 46 | /** On conflict, update the block if there was only one block. The value is true for b/c. */ |
| 47 | public const CONFLICT_REBLOCK = true; |
| 48 | |
| 49 | /** |
| 50 | * @var BlockTarget|null |
| 51 | * |
| 52 | * Target of the block. This is null in case BlockTargetFactory failed to |
| 53 | * parse the target. |
| 54 | */ |
| 55 | private $target; |
| 56 | |
| 57 | /** @var DatabaseBlock|null */ |
| 58 | private $blockToUpdate; |
| 59 | |
| 60 | /** @var Authority Performer of the block */ |
| 61 | private $performer; |
| 62 | |
| 63 | /** @var DatabaseBlock[]|null */ |
| 64 | private $priorBlocksForTarget; |
| 65 | |
| 66 | private ServiceOptions $options; |
| 67 | private BlockRestrictionStore $blockRestrictionStore; |
| 68 | private BlockPermissionChecker $blockPermissionChecker; |
| 69 | private BlockTargetFactory $blockTargetFactory; |
| 70 | private BlockActionInfo $blockActionInfo; |
| 71 | private HookRunner $hookRunner; |
| 72 | private DatabaseBlockStore $blockStore; |
| 73 | private UserFactory $userFactory; |
| 74 | private UserEditTracker $userEditTracker; |
| 75 | private LoggerInterface $logger; |
| 76 | private TitleFactory $titleFactory; |
| 77 | |
| 78 | /** |
| 79 | * @internal For use by UserBlockCommandFactory |
| 80 | */ |
| 81 | public const CONSTRUCTOR_OPTIONS = [ |
| 82 | MainConfigNames::HideUserContribLimit, |
| 83 | MainConfigNames::BlockAllowsUTEdit, |
| 84 | MainConfigNames::EnableMultiBlocks, |
| 85 | ]; |
| 86 | |
| 87 | /** |
| 88 | * @var string |
| 89 | * |
| 90 | * Expiry of the to-be-placed block exactly as it was passed to the constructor. |
| 91 | */ |
| 92 | private $rawExpiry; |
| 93 | |
| 94 | /** |
| 95 | * @var string|bool |
| 96 | * |
| 97 | * Parsed expiry. This may be false in case of an error in parsing. |
| 98 | */ |
| 99 | private $expiryTime; |
| 100 | |
| 101 | /** @var string */ |
| 102 | private $reason; |
| 103 | |
| 104 | /** @var bool */ |
| 105 | private $isCreateAccountBlocked = false; |
| 106 | |
| 107 | /** |
| 108 | * @var bool|null |
| 109 | * |
| 110 | * This may be null when an invalid option was passed to the constructor. |
| 111 | * Such a case is caught in placeBlockUnsafe. |
| 112 | */ |
| 113 | private $isUserTalkEditBlocked = null; |
| 114 | |
| 115 | /** @var bool */ |
| 116 | private $isEmailBlocked = false; |
| 117 | |
| 118 | /** @var bool */ |
| 119 | private $isHardBlock = true; |
| 120 | |
| 121 | /** @var bool */ |
| 122 | private $isAutoblocking = true; |
| 123 | |
| 124 | /** @var bool */ |
| 125 | private $isHideUser = false; |
| 126 | |
| 127 | /** |
| 128 | * @var bool |
| 129 | * |
| 130 | * Flag that needs to be true when the to-be-created block allows all editing, |
| 131 | * but does not allow some other action. |
| 132 | * |
| 133 | * This flag is used only by isPartial(), and should not be used anywhere else, |
| 134 | * even within this class. If you want to determine whether the block will be partial, |
| 135 | * use $this->isPartial(). |
| 136 | */ |
| 137 | private $isPartialRaw; |
| 138 | |
| 139 | /** @var AbstractRestriction[] */ |
| 140 | private $blockRestrictions = []; |
| 141 | |
| 142 | /** @var string[] */ |
| 143 | private $tags = []; |
| 144 | |
| 145 | /** @var int|null */ |
| 146 | private $logDeletionFlags; |
| 147 | |
| 148 | /** |
| 149 | * @param ServiceOptions $options |
| 150 | * @param BlockRestrictionStore $blockRestrictionStore |
| 151 | * @param BlockPermissionCheckerFactory $blockPermissionCheckerFactory |
| 152 | * @param BlockTargetFactory $blockTargetFactory |
| 153 | * @param BlockActionInfo $blockActionInfo |
| 154 | * @param HookContainer $hookContainer |
| 155 | * @param DatabaseBlockStore $databaseBlockStore |
| 156 | * @param UserFactory $userFactory |
| 157 | * @param UserEditTracker $userEditTracker |
| 158 | * @param LoggerInterface $logger |
| 159 | * @param TitleFactory $titleFactory |
| 160 | * @param DatabaseBlock|null $blockToUpdate |
| 161 | * @param BlockTarget|string|UserIdentity|null $target Target of the block |
| 162 | * @param Authority $performer Performer of the block |
| 163 | * @param string $expiry Expiry of the block (timestamp or 'infinity') |
| 164 | * @param string $reason Reason of the block |
| 165 | * @param bool[] $blockOptions |
| 166 | * Valid options: |
| 167 | * - isCreateAccountBlocked : Are account creations prevented? |
| 168 | * - isEmailBlocked : Is emailing other users prevented? |
| 169 | * - isHardBlock : Are named (non-temporary) users prevented from editing? |
| 170 | * - isAutoblocking : Should this block spread to others to |
| 171 | * limit block evasion? |
| 172 | * - isUserTalkEditBlocked : Is editing blocked user's own talk page prevented? |
| 173 | * - isHideUser : Should blocked user's name be hidden (needs hideuser)? |
| 174 | * - isPartial : Is this block partial? This is ignored when |
| 175 | * blockRestrictions is not an empty array. |
| 176 | * @param AbstractRestriction[] $blockRestrictions |
| 177 | * @param string[] $tags Tags that should be assigned to the log entry |
| 178 | */ |
| 179 | public function __construct( |
| 180 | ServiceOptions $options, |
| 181 | BlockRestrictionStore $blockRestrictionStore, |
| 182 | BlockPermissionCheckerFactory $blockPermissionCheckerFactory, |
| 183 | BlockTargetFactory $blockTargetFactory, |
| 184 | BlockActionInfo $blockActionInfo, |
| 185 | HookContainer $hookContainer, |
| 186 | DatabaseBlockStore $databaseBlockStore, |
| 187 | UserFactory $userFactory, |
| 188 | UserEditTracker $userEditTracker, |
| 189 | LoggerInterface $logger, |
| 190 | TitleFactory $titleFactory, |
| 191 | ?DatabaseBlock $blockToUpdate, |
| 192 | $target, |
| 193 | Authority $performer, |
| 194 | string $expiry, |
| 195 | string $reason, |
| 196 | array $blockOptions, |
| 197 | array $blockRestrictions, |
| 198 | array $tags |
| 199 | ) { |
| 200 | // Process dependencies |
| 201 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
| 202 | $this->options = $options; |
| 203 | $this->blockRestrictionStore = $blockRestrictionStore; |
| 204 | $this->blockTargetFactory = $blockTargetFactory; |
| 205 | $this->hookRunner = new HookRunner( $hookContainer ); |
| 206 | $this->blockStore = $databaseBlockStore; |
| 207 | $this->userFactory = $userFactory; |
| 208 | $this->userEditTracker = $userEditTracker; |
| 209 | $this->logger = $logger; |
| 210 | $this->titleFactory = $titleFactory; |
| 211 | $this->blockActionInfo = $blockActionInfo; |
| 212 | |
| 213 | // Process block target |
| 214 | if ( $blockToUpdate !== null ) { |
| 215 | if ( $blockToUpdate->getType() === AbstractBlock::TYPE_AUTO ) { |
| 216 | // Caller must check this |
| 217 | throw new \InvalidArgumentException( "Can't update an autoblock" ); |
| 218 | } |
| 219 | $this->blockToUpdate = $blockToUpdate; |
| 220 | $this->target = $blockToUpdate->getTarget(); |
| 221 | } elseif ( $target instanceof BlockTarget ) { |
| 222 | $this->target = $target; |
| 223 | } elseif ( $target === null ) { |
| 224 | throw new \InvalidArgumentException( |
| 225 | 'Either $target or $blockToUpdate must be specified' ); |
| 226 | } else { |
| 227 | // TODO: deprecate |
| 228 | $this->target = $this->blockTargetFactory->newFromLegacyUnion( $target ); |
| 229 | } |
| 230 | |
| 231 | $this->blockPermissionChecker = $blockPermissionCheckerFactory |
| 232 | ->newChecker( |
| 233 | $performer |
| 234 | ); |
| 235 | |
| 236 | // Process other block parameters |
| 237 | $this->performer = $performer; |
| 238 | $this->rawExpiry = $expiry; |
| 239 | $this->expiryTime = self::parseExpiryInput( $this->rawExpiry ); |
| 240 | $this->reason = $reason; |
| 241 | $this->blockRestrictions = $blockRestrictions; |
| 242 | $this->tags = $tags; |
| 243 | |
| 244 | // Process blockOptions |
| 245 | foreach ( [ |
| 246 | 'isCreateAccountBlocked', |
| 247 | 'isEmailBlocked', |
| 248 | 'isHardBlock', |
| 249 | 'isAutoblocking', |
| 250 | ] as $possibleBlockOption ) { |
| 251 | if ( isset( $blockOptions[ $possibleBlockOption ] ) ) { |
| 252 | $this->$possibleBlockOption = $blockOptions[ $possibleBlockOption ]; |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | $this->isPartialRaw = !empty( $blockOptions['isPartial'] ) && !$blockRestrictions; |
| 257 | |
| 258 | if ( |
| 259 | !$this->isPartial() || |
| 260 | in_array( NS_USER_TALK, $this->getNamespaceRestrictions() ) |
| 261 | ) { |
| 262 | |
| 263 | // It is possible to block user talk edit. User talk edit is: |
| 264 | // - always blocked if the config says so; |
| 265 | // - otherwise blocked/unblocked if the option was passed in; |
| 266 | // - otherwise defaults to not blocked. |
| 267 | if ( !$this->options->get( MainConfigNames::BlockAllowsUTEdit ) ) { |
| 268 | $this->isUserTalkEditBlocked = true; |
| 269 | } else { |
| 270 | $this->isUserTalkEditBlocked = $blockOptions['isUserTalkEditBlocked'] ?? false; |
| 271 | } |
| 272 | |
| 273 | } else { |
| 274 | |
| 275 | // It is not possible to block user talk edit. If the option |
| 276 | // was passed, an error will be thrown in ::placeBlockUnsafe. |
| 277 | // Otherwise, set to not blocked. |
| 278 | if ( !isset( $blockOptions['isUserTalkEditBlocked'] ) || !$blockOptions['isUserTalkEditBlocked'] ) { |
| 279 | $this->isUserTalkEditBlocked = false; |
| 280 | } |
| 281 | |
| 282 | } |
| 283 | |
| 284 | if ( isset( $blockOptions['isHideUser'] ) && $this->target instanceof UserBlockTarget ) { |
| 285 | $this->isHideUser = $blockOptions['isHideUser']; |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | /** |
| 290 | * @unstable This method might be removed without prior notice (see T271101) |
| 291 | * @param int $flags One of LogPage::* constants |
| 292 | */ |
| 293 | public function setLogDeletionFlags( int $flags ): void { |
| 294 | $this->logDeletionFlags = $flags; |
| 295 | } |
| 296 | |
| 297 | /** |
| 298 | * Convert a submitted expiry time, which may be relative ("2 weeks", etc) or absolute |
| 299 | * ("24 May 2034", etc), into an absolute timestamp we can put into the database. |
| 300 | * |
| 301 | * @todo strtotime() only accepts English strings. This means the expiry input |
| 302 | * can only be specified in English. |
| 303 | * @see https://www.php.net/manual/en/function.strtotime.php |
| 304 | * |
| 305 | * @param string $expiry Whatever was typed into the form |
| 306 | * |
| 307 | * @return string|false Timestamp (format TS::MW) or 'infinity' or false on error. |
| 308 | */ |
| 309 | public static function parseExpiryInput( string $expiry ) { |
| 310 | try { |
| 311 | return ExpiryDef::normalizeExpiry( $expiry, TS::MW ); |
| 312 | } catch ( InvalidArgumentException ) { |
| 313 | return false; |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | /** |
| 318 | * Is the to-be-placed block partial? |
| 319 | */ |
| 320 | private function isPartial(): bool { |
| 321 | return $this->blockRestrictions !== [] || $this->isPartialRaw; |
| 322 | } |
| 323 | |
| 324 | /** |
| 325 | * Configure DatabaseBlock according to class properties |
| 326 | * |
| 327 | * @param DatabaseBlock|null $sourceBlock Copy any options from this block. |
| 328 | * Null to construct a new one. |
| 329 | * |
| 330 | * @return DatabaseBlock |
| 331 | */ |
| 332 | private function configureBlock( $sourceBlock = null ): DatabaseBlock { |
| 333 | if ( $sourceBlock === null ) { |
| 334 | $block = new DatabaseBlock(); |
| 335 | } else { |
| 336 | $block = clone $sourceBlock; |
| 337 | } |
| 338 | |
| 339 | $isSitewide = !$this->isPartial(); |
| 340 | |
| 341 | $block->setTarget( $this->target ); |
| 342 | $block->setBlocker( $this->performer->getUser() ); |
| 343 | $block->setReason( $this->reason ); |
| 344 | $block->setExpiry( $this->expiryTime ); |
| 345 | $block->isCreateAccountBlocked( $this->isCreateAccountBlocked ); |
| 346 | $block->isEmailBlocked( $this->isEmailBlocked ); |
| 347 | $block->isHardblock( $this->isHardBlock ); |
| 348 | $block->isAutoblocking( $this->isAutoblocking ); |
| 349 | $block->isSitewide( $isSitewide ); |
| 350 | $block->isUsertalkEditAllowed( !$this->isUserTalkEditBlocked ); |
| 351 | $block->setHideName( $this->isHideUser ); |
| 352 | |
| 353 | $blockId = $block->getId(); |
| 354 | if ( $blockId === null ) { |
| 355 | // Block wasn't inserted into the DB yet |
| 356 | $block->setRestrictions( $this->blockRestrictions ); |
| 357 | } else { |
| 358 | // Block is in the DB, we need to set restrictions through a service |
| 359 | $block->setRestrictions( |
| 360 | $this->blockRestrictionStore->setBlockId( |
| 361 | $blockId, |
| 362 | $this->blockRestrictions |
| 363 | ) |
| 364 | ); |
| 365 | } |
| 366 | |
| 367 | return $block; |
| 368 | } |
| 369 | |
| 370 | /** |
| 371 | * Get prior blocks matching the current target. If we are updating a block |
| 372 | * by ID, this will include blocks for the same target as that ID. |
| 373 | * |
| 374 | * @return DatabaseBlock[] |
| 375 | */ |
| 376 | private function getPriorBlocksForTarget() { |
| 377 | if ( $this->priorBlocksForTarget === null ) { |
| 378 | $this->priorBlocksForTarget = $this->blockStore->newListFromTarget( |
| 379 | $this->target, null, true, |
| 380 | // If we're blocking an IP, ignore any matching autoblocks (T287798) |
| 381 | DatabaseBlockStore::AUTO_SPECIFIED |
| 382 | ); |
| 383 | } |
| 384 | return $this->priorBlocksForTarget; |
| 385 | } |
| 386 | |
| 387 | /** |
| 388 | * Determine if the target user is hidden (prior to applying pending changes) |
| 389 | * @return bool |
| 390 | */ |
| 391 | private function wasTargetHidden() { |
| 392 | if ( $this->target->getType() !== AbstractBlock::TYPE_USER ) { |
| 393 | return false; |
| 394 | } |
| 395 | foreach ( $this->getPriorBlocksForTarget() as $block ) { |
| 396 | if ( $block->getHideName() ) { |
| 397 | return true; |
| 398 | } |
| 399 | } |
| 400 | return false; |
| 401 | } |
| 402 | |
| 403 | /** |
| 404 | * Place a block, checking permissions |
| 405 | * |
| 406 | * @param string|bool $conflictMode The insertion conflict mode. Ignored if |
| 407 | * a block to update was specified in the constructor, for example by |
| 408 | * calling UserBlockCommandFactory::newUpdateBlock(). May be one of: |
| 409 | * - self::CONFLICT_FAIL: Allow the block only if there are no prior |
| 410 | * blocks on the same target. |
| 411 | * - self::CONFLICT_NEW: Create an additional block regardless of |
| 412 | * pre-existing blocks on the same target. This is allowed only if |
| 413 | * $wgEnableMultiBlocks is true. |
| 414 | * - self::CONFLICT_REBLOCK: This value is deprecated. If there is one |
| 415 | * prior block on the target, update it. If there is more than one block, |
| 416 | * throw an exception. |
| 417 | * |
| 418 | * @return Status If the block is successful, the value of the returned |
| 419 | * Status is an instance of a newly placed block. |
| 420 | */ |
| 421 | public function placeBlock( $conflictMode = self::CONFLICT_FAIL ): Status { |
| 422 | $priorHideUser = $this->wasTargetHidden(); |
| 423 | if ( |
| 424 | $this->blockPermissionChecker |
| 425 | ->checkBasePermissions( |
| 426 | $this->isHideUser || $priorHideUser |
| 427 | ) !== true |
| 428 | ) { |
| 429 | $this->logger->debug( 'placeBlock: checkBasePermissions failed' ); |
| 430 | return Status::newFatal( $priorHideUser ? 'cant-see-hidden-user' : 'badaccess-group0' ); |
| 431 | } |
| 432 | |
| 433 | $blockCheckResult = $this->blockPermissionChecker->checkBlockPermissions( $this->target ); |
| 434 | if ( $blockCheckResult !== true ) { |
| 435 | $this->logger->debug( 'placeBlock: checkBlockPermissions failed' ); |
| 436 | return Status::newFatal( $blockCheckResult ); |
| 437 | } |
| 438 | |
| 439 | if ( |
| 440 | $this->isEmailBlocked && |
| 441 | !$this->blockPermissionChecker->checkEmailPermissions() |
| 442 | ) { |
| 443 | // TODO: Maybe not ignore the error here? |
| 444 | $this->isEmailBlocked = false; |
| 445 | } |
| 446 | |
| 447 | if ( $this->tags !== [] ) { |
| 448 | $status = ChangeTags::canAddTagsAccompanyingChange( |
| 449 | $this->tags, |
| 450 | $this->performer |
| 451 | ); |
| 452 | |
| 453 | if ( !$status->isOK() ) { |
| 454 | $this->logger->debug( 'placeBlock: ChangeTags::canAddTagsAccompanyingChange failed' ); |
| 455 | return $status; |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | $status = Status::newGood(); |
| 460 | foreach ( $this->getPageRestrictions() as $pageRestriction ) { |
| 461 | try { |
| 462 | $title = $this->titleFactory->newFromTextThrow( $pageRestriction ); |
| 463 | if ( !$title->exists() ) { |
| 464 | $this->logger->debug( "placeBlock: nonexistent page restriction $title" ); |
| 465 | $status->fatal( 'cant-block-nonexistent-page', $pageRestriction ); |
| 466 | } |
| 467 | } catch ( MalformedTitleException $e ) { |
| 468 | $this->logger->debug( 'placeBlock: malformed page restriction title' ); |
| 469 | $status->fatal( $e->getMessageObject() ); |
| 470 | } |
| 471 | } |
| 472 | if ( !$status->isOK() ) { |
| 473 | return $status; |
| 474 | } |
| 475 | |
| 476 | return $this->placeBlockUnsafe( $conflictMode ); |
| 477 | } |
| 478 | |
| 479 | /** |
| 480 | * Place a block without any sort of permissions checks. |
| 481 | * |
| 482 | * @param string|bool $conflictMode |
| 483 | * |
| 484 | * @return Status If the block is successful, the value of the returned |
| 485 | * Status is an instance of a newly placed block. |
| 486 | */ |
| 487 | public function placeBlockUnsafe( $conflictMode = self::CONFLICT_FAIL ): Status { |
| 488 | $status = Status::wrap( $this->target->validateForCreation() ); |
| 489 | |
| 490 | if ( !$status->isOK() ) { |
| 491 | $this->logger->debug( 'placeBlockUnsafe: invalid target' ); |
| 492 | return $status; |
| 493 | } |
| 494 | |
| 495 | if ( $this->isUserTalkEditBlocked === null ) { |
| 496 | $this->logger->debug( 'placeBlockUnsafe: partial block on user talk page' ); |
| 497 | return Status::newFatal( 'ipb-prevent-user-talk-edit' ); |
| 498 | } |
| 499 | |
| 500 | if ( |
| 501 | // There should be some expiry |
| 502 | $this->rawExpiry === '' || |
| 503 | // can't be a larger string as 50 (it should be a time format in any way) |
| 504 | strlen( $this->rawExpiry ) > 50 || |
| 505 | // the time can't be parsed |
| 506 | !$this->expiryTime |
| 507 | ) { |
| 508 | $this->logger->debug( 'placeBlockUnsafe: invalid expiry' ); |
| 509 | return Status::newFatal( 'ipb_expiry_invalid' ); |
| 510 | } |
| 511 | |
| 512 | if ( $this->expiryTime < wfTimestampNow() ) { |
| 513 | $this->logger->debug( 'placeBlockUnsafe: expiry in the past' ); |
| 514 | return Status::newFatal( 'ipb_expiry_old' ); |
| 515 | } |
| 516 | |
| 517 | $hideUserTarget = $this->getHideUserTarget(); |
| 518 | if ( $hideUserTarget ) { |
| 519 | if ( $this->isPartial() ) { |
| 520 | $this->logger->debug( 'placeBlockUnsafe: partial block cannot hide user' ); |
| 521 | return Status::newFatal( 'ipb_hide_partial' ); |
| 522 | } |
| 523 | |
| 524 | if ( !wfIsInfinity( $this->rawExpiry ) ) { |
| 525 | $this->logger->debug( 'placeBlockUnsafe: temp user block has expiry' ); |
| 526 | return Status::newFatal( 'ipb_expiry_temp' ); |
| 527 | } |
| 528 | |
| 529 | $hideUserContribLimit = $this->options->get( MainConfigNames::HideUserContribLimit ); |
| 530 | if ( |
| 531 | $hideUserContribLimit !== false && |
| 532 | $this->userEditTracker->getUserEditCount( $hideUserTarget ) > $hideUserContribLimit |
| 533 | ) { |
| 534 | $this->logger->debug( 'placeBlockUnsafe: hide user with too many contribs' ); |
| 535 | return Status::newFatal( 'ipb_hide_invalid', Message::numParam( $hideUserContribLimit ) ); |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | if ( $this->isPartial() ) { |
| 540 | if ( |
| 541 | $this->blockRestrictions === [] && |
| 542 | !$this->isEmailBlocked && |
| 543 | !$this->isCreateAccountBlocked && |
| 544 | !$this->isUserTalkEditBlocked |
| 545 | ) { |
| 546 | $this->logger->debug( 'placeBlockUnsafe: empty partial block' ); |
| 547 | return Status::newFatal( 'ipb-empty-block' ); |
| 548 | } |
| 549 | } |
| 550 | |
| 551 | return $this->placeBlockInternal( $conflictMode ); |
| 552 | } |
| 553 | |
| 554 | /** |
| 555 | * Places a block without any sort of permission or double checking, hooks can still |
| 556 | * abort the block through, as well as already existing block. |
| 557 | * |
| 558 | * @param string|bool $conflictMode |
| 559 | * |
| 560 | * @return Status |
| 561 | */ |
| 562 | private function placeBlockInternal( $conflictMode ): Status { |
| 563 | $block = $this->configureBlock( $this->blockToUpdate ); |
| 564 | |
| 565 | $denyReason = [ 'hookaborted' ]; |
| 566 | $legacyUser = $this->userFactory->newFromAuthority( $this->performer ); |
| 567 | if ( !$this->hookRunner->onBlockIp( $block, $legacyUser, $denyReason ) ) { |
| 568 | $status = Status::newGood(); |
| 569 | foreach ( $denyReason as $key ) { |
| 570 | $this->logger->debug( "placeBlockInternal: hook aborted with message \"$key\"" ); |
| 571 | $status->fatal( $key ); |
| 572 | } |
| 573 | return $status; |
| 574 | } |
| 575 | |
| 576 | $expectedTargetCount = 0; |
| 577 | $priorBlocks = $this->getPriorBlocksForTarget(); |
| 578 | |
| 579 | if ( $this->blockToUpdate !== null ) { |
| 580 | if ( $block->equals( $this->blockToUpdate ) ) { |
| 581 | $this->logger->debug( 'placeBlockInternal: ' . |
| 582 | 'already blocked with same params (blockToUpdate case)' ); |
| 583 | return Status::newFatal( 'ipb_already_blocked', $block->getTargetName() ); |
| 584 | } |
| 585 | $priorBlock = $this->blockToUpdate; |
| 586 | $update = true; |
| 587 | } elseif ( $conflictMode === self::CONFLICT_NEW |
| 588 | && $this->options->get( MainConfigNames::EnableMultiBlocks ) |
| 589 | ) { |
| 590 | foreach ( $priorBlocks as $priorBlock ) { |
| 591 | if ( $block->equals( $priorBlock ) ) { |
| 592 | // Block settings are equal => user is already blocked |
| 593 | $this->logger->debug( 'placeBlockInternal: ' . |
| 594 | 'already blocked with same params (CONFLICT_NEW case)' ); |
| 595 | return Status::newFatal( 'ipb_already_blocked', $block->getTargetName() ); |
| 596 | } |
| 597 | } |
| 598 | $expectedTargetCount = null; |
| 599 | $priorBlock = null; |
| 600 | $update = false; |
| 601 | } elseif ( !$priorBlocks ) { |
| 602 | $priorBlock = null; |
| 603 | $update = false; |
| 604 | } else { |
| 605 | // Reblock only if the caller wants so |
| 606 | if ( $conflictMode !== self::CONFLICT_REBLOCK ) { |
| 607 | $this->logger->debug( |
| 608 | 'placeBlockInternal: already blocked and reblock not requested' ); |
| 609 | return Status::newFatal( 'ipb_already_blocked', $block->getTargetName() ); |
| 610 | } |
| 611 | |
| 612 | // Can't update multiple blocks unless blockToUpdate was given |
| 613 | if ( count( $priorBlocks ) > 1 ) { |
| 614 | throw new MultiblocksException( |
| 615 | "Can\'t reblock a user with multiple blocks already present. " . |
| 616 | "Update calling code for multiblocks, providing a specific block to update." ); |
| 617 | } |
| 618 | |
| 619 | // Check for identical blocks |
| 620 | $priorBlock = $priorBlocks[0]; |
| 621 | if ( $block->equals( $priorBlock ) ) { |
| 622 | // Block settings are equal => user is already blocked |
| 623 | $this->logger->debug( 'placeBlockInternal: already blocked, no change' ); |
| 624 | return Status::newFatal( 'ipb_already_blocked', $block->getTargetName() ); |
| 625 | } |
| 626 | |
| 627 | $update = true; |
| 628 | $block = $this->configureBlock( $priorBlock ); |
| 629 | } |
| 630 | |
| 631 | if ( $update ) { |
| 632 | $logEntry = $this->prepareLogEntry( true ); |
| 633 | $this->blockStore->updateBlock( $block ); |
| 634 | } else { |
| 635 | $logEntry = $this->prepareLogEntry( false ); |
| 636 | // Try to insert block. |
| 637 | $insertStatus = $this->blockStore->insertBlock( $block, $expectedTargetCount ); |
| 638 | if ( !$insertStatus ) { |
| 639 | $this->logger->warning( 'Block could not be inserted. No existing block was found.' ); |
| 640 | return Status::newFatal( 'ipb-block-not-found', $block->getTargetName() ); |
| 641 | } |
| 642 | if ( $insertStatus['finalTargetCount'] > 1 ) { |
| 643 | $logEntry->addParameter( 'finalTargetCount', $insertStatus['finalTargetCount'] ); |
| 644 | } |
| 645 | } |
| 646 | // Relate log ID to block ID (T27763) |
| 647 | $logEntry->setRelations( [ 'ipb_id' => $block->getId() ] ); |
| 648 | // Also save the ID to log_params, since MW 1.44 |
| 649 | $logEntry->addParameter( 'blockId', $block->getId() ); |
| 650 | |
| 651 | // Set *_deleted fields if requested |
| 652 | $hideUserTarget = $this->getHideUserTarget(); |
| 653 | if ( $hideUserTarget ) { |
| 654 | RevisionDeleteUser::suppressUserName( $hideUserTarget->getName(), $hideUserTarget->getId() ); |
| 655 | } |
| 656 | |
| 657 | DeferredUpdates::addCallableUpdate( function () use ( $block, $legacyUser, $priorBlock ) { |
| 658 | $this->hookRunner->onBlockIpComplete( $block, $legacyUser, $priorBlock ); |
| 659 | } ); |
| 660 | |
| 661 | // DatabaseBlock constructor sanitizes certain block options on insert |
| 662 | $this->isEmailBlocked = $block->isEmailBlocked(); |
| 663 | $this->isAutoblocking = $block->isAutoblocking(); |
| 664 | |
| 665 | $this->log( $logEntry ); |
| 666 | |
| 667 | $this->logger->debug( 'placeBlockInternal: success' ); |
| 668 | return Status::newGood( $block ); |
| 669 | } |
| 670 | |
| 671 | /** |
| 672 | * If the operation is hiding a user, get the user being hidden |
| 673 | * |
| 674 | * @return UserIdentity|null |
| 675 | */ |
| 676 | private function getHideUserTarget(): ?UserIdentity { |
| 677 | if ( !$this->isHideUser ) { |
| 678 | return null; |
| 679 | } |
| 680 | if ( !( $this->target instanceof UserBlockTarget ) ) { |
| 681 | // Should be unreachable -- constructor checks this |
| 682 | throw new \LogicException( 'Wrong target type used with hide user option' ); |
| 683 | } |
| 684 | return $this->target->getUserIdentity(); |
| 685 | } |
| 686 | |
| 687 | /** |
| 688 | * Build namespace restrictions array from $this->blockRestrictions |
| 689 | * |
| 690 | * Returns an array of namespace IDs. |
| 691 | * |
| 692 | * @return int[] |
| 693 | */ |
| 694 | private function getNamespaceRestrictions(): array { |
| 695 | $namespaceRestrictions = []; |
| 696 | foreach ( $this->blockRestrictions as $restriction ) { |
| 697 | if ( $restriction instanceof NamespaceRestriction ) { |
| 698 | $namespaceRestrictions[] = $restriction->getValue(); |
| 699 | } |
| 700 | } |
| 701 | return $namespaceRestrictions; |
| 702 | } |
| 703 | |
| 704 | /** |
| 705 | * Build an array of page restrictions from $this->blockRestrictions |
| 706 | * |
| 707 | * Returns an array of stringified full page titles. |
| 708 | * |
| 709 | * @return string[] |
| 710 | */ |
| 711 | private function getPageRestrictions(): array { |
| 712 | $pageRestrictions = []; |
| 713 | foreach ( $this->blockRestrictions as $restriction ) { |
| 714 | if ( $restriction instanceof PageRestriction ) { |
| 715 | $pageRestrictions[] = $restriction->getTitle()->getFullText(); |
| 716 | } |
| 717 | } |
| 718 | return $pageRestrictions; |
| 719 | } |
| 720 | |
| 721 | /** |
| 722 | * Build an array of actions from $this->blockRestrictions |
| 723 | * |
| 724 | * Returns an array of stringified actions. |
| 725 | * |
| 726 | * @return string[] |
| 727 | */ |
| 728 | private function getActionRestrictions(): array { |
| 729 | $actionRestrictions = []; |
| 730 | foreach ( $this->blockRestrictions as $restriction ) { |
| 731 | if ( $restriction instanceof ActionRestriction ) { |
| 732 | $actionRestrictions[] = $this->blockActionInfo->getActionFromId( $restriction->getValue() ); |
| 733 | } |
| 734 | } |
| 735 | return $actionRestrictions; |
| 736 | } |
| 737 | |
| 738 | /** |
| 739 | * Prepare $logParams |
| 740 | * |
| 741 | * Helper method for $this->log() |
| 742 | */ |
| 743 | private function constructLogParams(): array { |
| 744 | $logExpiry = wfIsInfinity( $this->rawExpiry ) ? 'infinity' : $this->rawExpiry; |
| 745 | $logParams = [ |
| 746 | '5::duration' => $logExpiry, |
| 747 | '6::flags' => $this->blockLogFlags(), |
| 748 | 'sitewide' => !$this->isPartial() |
| 749 | ]; |
| 750 | |
| 751 | if ( $this->isPartial() ) { |
| 752 | $pageRestrictions = $this->getPageRestrictions(); |
| 753 | $namespaceRestrictions = $this->getNamespaceRestrictions(); |
| 754 | $actionRestrictions = $this->getActionRestrictions(); |
| 755 | |
| 756 | if ( count( $pageRestrictions ) > 0 ) { |
| 757 | $logParams['7::restrictions']['pages'] = $pageRestrictions; |
| 758 | } |
| 759 | if ( count( $namespaceRestrictions ) > 0 ) { |
| 760 | $logParams['7::restrictions']['namespaces'] = $namespaceRestrictions; |
| 761 | } |
| 762 | if ( count( $actionRestrictions ) ) { |
| 763 | $logParams['7::restrictions']['actions'] = $actionRestrictions; |
| 764 | } |
| 765 | } |
| 766 | return $logParams; |
| 767 | } |
| 768 | |
| 769 | /** |
| 770 | * Create the log entry object to be inserted. Do read queries here before |
| 771 | * we start locking block_target rows. |
| 772 | * |
| 773 | * @param bool $isReblock |
| 774 | * @return ManualLogEntry |
| 775 | */ |
| 776 | private function prepareLogEntry( bool $isReblock ) { |
| 777 | $logType = $this->isHideUser ? 'suppress' : 'block'; |
| 778 | $logAction = $isReblock ? 'reblock' : 'block'; |
| 779 | // FIXME: Shouldn't this use BlockTarget::getLogPage? |
| 780 | $title = Title::makeTitle( NS_USER, $this->target->toString() ); |
| 781 | // Preload the page_id: needed for log_page in ManualLogEntry::insert() |
| 782 | $title->getArticleID(); |
| 783 | |
| 784 | $logEntry = new ManualLogEntry( $logType, $logAction ); |
| 785 | $logEntry->setTarget( $title ); |
| 786 | $logEntry->setComment( $this->reason ); |
| 787 | $logEntry->setPerformer( $this->performer->getUser() ); |
| 788 | $logEntry->setParameters( $this->constructLogParams() ); |
| 789 | $logEntry->addTags( $this->tags ); |
| 790 | if ( $this->logDeletionFlags !== null ) { |
| 791 | $logEntry->setDeleted( $this->logDeletionFlags ); |
| 792 | } |
| 793 | return $logEntry; |
| 794 | } |
| 795 | |
| 796 | /** |
| 797 | * Log the block to Special:Log |
| 798 | */ |
| 799 | private function log( ManualLogEntry $logEntry ) { |
| 800 | $logId = $logEntry->insert(); |
| 801 | $logEntry->publish( $logId ); |
| 802 | } |
| 803 | |
| 804 | /** |
| 805 | * Return a comma-delimited list of flags to be passed to the log |
| 806 | * reader for this block, to provide more information in the logs. |
| 807 | */ |
| 808 | private function blockLogFlags(): string { |
| 809 | $flags = []; |
| 810 | |
| 811 | if ( $this->target->getType() != AbstractBlock::TYPE_USER && !$this->isHardBlock ) { |
| 812 | // For grepping: message block-log-flags-anononly |
| 813 | $flags[] = 'anononly'; |
| 814 | } |
| 815 | |
| 816 | if ( $this->isCreateAccountBlocked ) { |
| 817 | // For grepping: message block-log-flags-nocreate |
| 818 | $flags[] = 'nocreate'; |
| 819 | } |
| 820 | |
| 821 | if ( $this->target->getType() == AbstractBlock::TYPE_USER && !$this->isAutoblocking ) { |
| 822 | // For grepping: message block-log-flags-noautoblock |
| 823 | $flags[] = 'noautoblock'; |
| 824 | } |
| 825 | |
| 826 | if ( $this->isEmailBlocked ) { |
| 827 | // For grepping: message block-log-flags-noemail |
| 828 | $flags[] = 'noemail'; |
| 829 | } |
| 830 | |
| 831 | if ( $this->options->get( MainConfigNames::BlockAllowsUTEdit ) && $this->isUserTalkEditBlocked ) { |
| 832 | // For grepping: message block-log-flags-nousertalk |
| 833 | $flags[] = 'nousertalk'; |
| 834 | } |
| 835 | |
| 836 | if ( $this->isHideUser ) { |
| 837 | // For grepping: message block-log-flags-hiddenname |
| 838 | $flags[] = 'hiddenname'; |
| 839 | } |
| 840 | |
| 841 | return implode( ',', $flags ); |
| 842 | } |
| 843 | } |