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