Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.01% covered (danger)
26.01%
77 / 296
0.00% covered (danger)
0.00%
0 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockUser
26.01% covered (danger)
26.01%
77 / 296
0.00% covered (danger)
0.00%
0 / 18
4920.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
240
 setLogDeletionFlags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parseExpiryInput
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isPartial
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 configureBlock
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 getPriorBlocksForTarget
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 wasTargetHidden
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 placeBlock
55.88% covered (warning)
55.88%
19 / 34
0.00% covered (danger)
0.00%
0 / 1
27.51
 placeBlockUnsafe
50.00% covered (danger)
50.00%
18 / 36
0.00% covered (danger)
0.00%
0 / 1
53.12
 placeBlockInternal
58.82% covered (warning)
58.82%
40 / 68
0.00% covered (danger)
0.00%
0 / 1
37.18
 getHideUserTarget
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getNamespaceRestrictions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getPageRestrictions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getActionRestrictions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 constructLogParams
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 prepareLogEntry
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 log
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 blockLogFlags
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
110
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\Status\Status;
25use MediaWiki\Title\MalformedTitleException;
26use MediaWiki\Title\Title;
27use MediaWiki\Title\TitleFactory;
28use MediaWiki\User\UserEditTracker;
29use MediaWiki\User\UserFactory;
30use MediaWiki\User\UserIdentity;
31use Psr\Log\LoggerInterface;
32use RevisionDeleteUser;
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    /**
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}