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