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