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