Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.22% covered (warning)
85.22%
611 / 717
50.00% covered (danger)
50.00%
20 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
DatabaseBlockStore
85.22% covered (warning)
85.22%
611 / 717
50.00% covered (danger)
50.00%
20 / 40
248.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 newFromID
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getQueryInfo
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
1
 newLoad
83.87% covered (warning)
83.87%
52 / 62
0.00% covered (danger)
0.00%
0 / 1
25.22
 chooseMostSpecificBlock
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getRangeCond
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 getIpFragment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 newFromRow
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 newFromTarget
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 newListFromTarget
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 newListFromIPs
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
8.10
 newListFromConds
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 newUnsaved
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 purgeExpiredBlocks
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 deleteBlocksMatchingConds
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
3.02
 mapActorAlias
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 hasActorAlias
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 mapConds
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 deleteBlockRows
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 releaseTargets
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
2.00
 getReplicaDB
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrimaryDB
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 insertBlock
81.58% covered (warning)
81.58%
31 / 38
0.00% covered (danger)
0.00%
0 / 1
12.90
 insertBlockWithParams
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 attemptInsert
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
5.03
 purgeExpiredConflicts
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getExpiredConflictingBlockRows
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getTargetConds
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 acquireTarget
84.85% covered (warning)
84.85%
56 / 66
0.00% covered (danger)
0.00%
0 / 1
11.42
 updateBlock
89.47% covered (warning)
89.47%
34 / 38
0.00% covered (danger)
0.00%
0 / 1
7.06
 updateTarget
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
 deleteBlock
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 getArrayForBlockUpdate
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
2
 getArrayForAutoblockUpdate
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
3
 doRetroactiveAutoblock
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 performRetroactiveAutoblock
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
6.15
 doAutoblock
81.82% covered (warning)
81.82%
36 / 44
0.00% covered (danger)
0.00%
0 / 1
11.73
 getAutoblockReason
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 updateTimestamp
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 getAutoblockExpiry
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2/**
3 * Class for DatabaseBlock objects to interact with the database
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Block;
24
25use InvalidArgumentException;
26use MediaWiki\CommentStore\CommentStore;
27use MediaWiki\Config\ServiceOptions;
28use MediaWiki\Deferred\AutoCommitUpdate;
29use MediaWiki\Deferred\DeferredUpdates;
30use MediaWiki\HookContainer\HookContainer;
31use MediaWiki\HookContainer\HookRunner;
32use MediaWiki\MainConfigNames;
33use MediaWiki\Session\SessionManager;
34use MediaWiki\User\ActorStoreFactory;
35use MediaWiki\User\TempUser\TempUserConfig;
36use MediaWiki\User\UserFactory;
37use MediaWiki\User\UserIdentity;
38use Psr\Log\LoggerInterface;
39use RuntimeException;
40use stdClass;
41use Wikimedia\IPUtils;
42use Wikimedia\Rdbms\IConnectionProvider;
43use Wikimedia\Rdbms\IDatabase;
44use Wikimedia\Rdbms\IExpression;
45use Wikimedia\Rdbms\IReadableDatabase;
46use Wikimedia\Rdbms\IResultWrapper;
47use Wikimedia\Rdbms\LikeValue;
48use Wikimedia\Rdbms\RawSQLExpression;
49use Wikimedia\Rdbms\RawSQLValue;
50use Wikimedia\Rdbms\ReadOnlyMode;
51use Wikimedia\Rdbms\SelectQueryBuilder;
52use function array_key_exists;
53
54/**
55 * @since 1.36
56 *
57 * @author DannyS712
58 */
59class DatabaseBlockStore {
60    /**
61     * @internal For use by ServiceWiring
62     */
63    public const CONSTRUCTOR_OPTIONS = [
64        MainConfigNames::AutoblockExpiry,
65        MainConfigNames::BlockCIDRLimit,
66        MainConfigNames::BlockDisablesLogin,
67        MainConfigNames::PutIPinRC,
68        MainConfigNames::UpdateRowsPerQuery,
69    ];
70
71    /** @var string|false */
72    private $wikiId;
73
74    private ServiceOptions $options;
75    private LoggerInterface $logger;
76    private ActorStoreFactory $actorStoreFactory;
77    private BlockRestrictionStore $blockRestrictionStore;
78    private CommentStore $commentStore;
79    private HookRunner $hookRunner;
80    private IConnectionProvider $dbProvider;
81    private ReadOnlyMode $readOnlyMode;
82    private UserFactory $userFactory;
83    private TempUserConfig $tempUserConfig;
84    private BlockTargetFactory $blockTargetFactory;
85    private AutoblockExemptionList $autoblockExemptionList;
86
87    public function __construct(
88        ServiceOptions $options,
89        LoggerInterface $logger,
90        ActorStoreFactory $actorStoreFactory,
91        BlockRestrictionStore $blockRestrictionStore,
92        CommentStore $commentStore,
93        HookContainer $hookContainer,
94        IConnectionProvider $dbProvider,
95        ReadOnlyMode $readOnlyMode,
96        UserFactory $userFactory,
97        TempUserConfig $tempUserConfig,
98        BlockTargetFactory $blockTargetFactory,
99        AutoblockExemptionList $autoblockExemptionList,
100        /* string|false */ $wikiId = DatabaseBlock::LOCAL
101    ) {
102        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
103
104        $this->wikiId = $wikiId;
105
106        $this->options = $options;
107        $this->logger = $logger;
108        $this->actorStoreFactory = $actorStoreFactory;
109        $this->blockRestrictionStore = $blockRestrictionStore;
110        $this->commentStore = $commentStore;
111        $this->hookRunner = new HookRunner( $hookContainer );
112        $this->dbProvider = $dbProvider;
113        $this->readOnlyMode = $readOnlyMode;
114        $this->userFactory = $userFactory;
115        $this->tempUserConfig = $tempUserConfig;
116        $this->blockTargetFactory = $blockTargetFactory;
117        $this->autoblockExemptionList = $autoblockExemptionList;
118    }
119
120    /***************************************************************************/
121    // region   Database read methods
122    /** @name   Database read methods */
123
124    /**
125     * Load a block from the block ID.
126     *
127     * @since 1.42
128     * @param int $id ID to search for
129     * @param bool $fromPrimary Whether to use the DB_PRIMARY database (since 1.44)
130     * @param bool $includeExpired Whether to include expired blocks (since 1.44)
131     * @return DatabaseBlock|null
132     */
133    public function newFromID( $id, $fromPrimary = false, $includeExpired = false ) {
134        $blocks = $this->newListFromConds( [ 'bl_id' => $id ], $fromPrimary, $includeExpired );
135        return $blocks ? $blocks[0] : null;
136    }
137
138    /**
139     * Return the tables, fields, and join conditions to be selected to create
140     * a new block object.
141     *
142     * @since 1.42
143     * @internal Prefer newListFromConds() and deleteBlocksMatchingConds().
144     *
145     * @return array[] With three keys:
146     *   - tables: (string[]) to include in the `$table` to `IDatabase->select()`
147     *     or `SelectQueryBuilder::tables`
148     *   - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
149     *     or `SelectQueryBuilder::fields`
150     *   - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
151     *     or `SelectQueryBuilder::joinConds`
152     * @phan-return array{tables:string[],fields:string[],joins:array}
153     */
154    public function getQueryInfo() {
155        $commentQuery = $this->commentStore->getJoin( 'bl_reason' );
156        return [
157            'tables' => [
158                'block',
159                'block_target',
160                'block_by_actor' => 'actor',
161            ] + $commentQuery['tables'],
162            'fields' => [
163                'bl_id',
164                'bt_address',
165                'bt_user',
166                'bt_user_text',
167                'bl_timestamp',
168                'bt_auto',
169                'bl_anon_only',
170                'bl_create_account',
171                'bl_enable_autoblock',
172                'bl_expiry',
173                'bl_deleted',
174                'bl_block_email',
175                'bl_allow_usertalk',
176                'bl_parent_block_id',
177                'bl_sitewide',
178                'bl_by_actor',
179                'bl_by' => 'block_by_actor.actor_user',
180                'bl_by_text' => 'block_by_actor.actor_name',
181            ] + $commentQuery['fields'],
182            'joins' => [
183                'block_target' => [ 'JOIN', 'bt_id=bl_target' ],
184                'block_by_actor' => [ 'JOIN', 'actor_id=bl_by_actor' ],
185            ] + $commentQuery['joins'],
186        ];
187    }
188
189    /**
190     * Load blocks from the database which target the specific target exactly, or which cover the
191     * vague target.
192     *
193     * @param BlockTarget|null $specificTarget
194     * @param bool $fromPrimary
195     * @param BlockTarget|null $vagueTarget Also search for blocks affecting
196     *     this target. Doesn't make any sense to use TYPE_AUTO here. Leave blank to
197     *     skip IP lookups.
198     * @return DatabaseBlock[] Any relevant blocks
199     */
200    private function newLoad(
201        $specificTarget,
202        $fromPrimary,
203        $vagueTarget = null
204    ) {
205        if ( $fromPrimary ) {
206            $db = $this->getPrimaryDB();
207        } else {
208            $db = $this->getReplicaDB();
209        }
210
211        $userIds = [];
212        $userNames = [];
213        $addresses = [];
214        $ranges = [];
215        if ( $specificTarget instanceof UserBlockTarget ) {
216            $userId = $specificTarget->getUserIdentity()->getId( $this->wikiId );
217            if ( $userId ) {
218                $userIds[] = $userId;
219            } else {
220                // A nonexistent user can have no blocks.
221                // This case is hit in testing, possibly production too.
222                // Ignoring the user is optimal for production performance.
223            }
224        } elseif ( $specificTarget instanceof AnonIpBlockTarget
225            || $specificTarget instanceof RangeBlockTarget
226        ) {
227            $addresses[] = (string)$specificTarget;
228        }
229
230        // Be aware that the != '' check is explicit, since empty values will be
231        // passed by some callers (T31116)
232        if ( $vagueTarget !== null ) {
233            if ( $vagueTarget instanceof UserBlockTarget ) {
234                // Slightly weird, but who are we to argue?
235                $vagueUser = $vagueTarget->getUserIdentity();
236                $userId = $vagueUser->getId( $this->wikiId );
237                if ( $userId ) {
238                    $userIds[] = $userId;
239                } else {
240                    $userNames[] = $vagueUser->getName();
241                }
242            } elseif ( $vagueTarget instanceof BlockTargetWithIp ) {
243                $ranges[] = $vagueTarget->toHexRange();
244            } else {
245                $this->logger->debug( "Ignoring invalid vague target" );
246            }
247        }
248
249        $orConds = [];
250        if ( $userIds ) {
251            // @phan-suppress-next-line PhanTypeMismatchArgument -- array_unique() result is non-empty
252            $orConds[] = $db->expr( 'bt_user', '=', array_unique( $userIds ) );
253        }
254        if ( $userNames ) {
255            // Add bt_ip_hex to the condition since it is in the index
256            $orConds[] = $db->expr( 'bt_ip_hex', '=', null )
257                // @phan-suppress-next-line PhanTypeMismatchArgument -- array_unique() result is non-empty
258                ->and( 'bt_user_text', '=', array_unique( $userNames ) );
259        }
260        if ( $addresses ) {
261            // @phan-suppress-next-line PhanTypeMismatchArgument
262            $orConds[] = $db->expr( 'bt_address', '=', array_unique( $addresses ) );
263        }
264        foreach ( $ranges as $range ) {
265            $orConds[] = new RawSQLExpression( $this->getRangeCond( $range[0], $range[1] ) );
266        }
267        if ( !$orConds ) {
268            return [];
269        }
270
271        $blockQuery = $this->getQueryInfo();
272        $res = $db->newSelectQueryBuilder()
273            ->queryInfo( $blockQuery )
274            ->where( $db->orExpr( $orConds ) )
275            ->caller( __METHOD__ )
276            ->fetchResultSet();
277
278        $blocks = [];
279        $blockIds = [];
280        $autoBlocks = [];
281        foreach ( $res as $row ) {
282            $block = $this->newFromRow( $db, $row );
283
284            // Don't use expired blocks
285            if ( $block->isExpired() ) {
286                continue;
287            }
288
289            // Don't use anon only blocks on users
290            if (
291                $specificTarget instanceof UserBlockTarget &&
292                !$block->isHardblock() &&
293                !$this->tempUserConfig->isTempName( $specificTarget->toString() )
294            ) {
295                continue;
296            }
297
298            // Check for duplicate autoblocks
299            if ( $block->getType() === Block::TYPE_AUTO ) {
300                $autoBlocks[] = $block;
301            } else {
302                $blocks[] = $block;
303                $blockIds[] = $block->getId( $this->wikiId );
304            }
305        }
306
307        // Only add autoblocks that aren't duplicates
308        foreach ( $autoBlocks as $block ) {
309            if ( !in_array( $block->getParentBlockId(), $blockIds ) ) {
310                $blocks[] = $block;
311            }
312        }
313
314        return $blocks;
315    }
316
317    /**
318     * Choose the most specific block from some combination of user, IP and IP range
319     * blocks. Decreasing order of specificity: user > IP > narrower IP range > wider IP
320     * range. A range that encompasses one IP address is ranked equally to a singe IP.
321     *
322     * @param DatabaseBlock[] $blocks These should not include autoblocks or ID blocks
323     * @return DatabaseBlock|null The block with the most specific target
324     */
325    private function chooseMostSpecificBlock( array $blocks ) {
326        if ( count( $blocks ) === 1 ) {
327            return $blocks[0];
328        }
329
330        // This result could contain a block on the user, a block on the IP, and a russian-doll
331        // set of range blocks.  We want to choose the most specific one, so keep a leader board.
332        $bestBlock = null;
333
334        // Lower will be better
335        $bestBlockScore = 100;
336        foreach ( $blocks as $block ) {
337            $score = $block->getTarget()->getSpecificity();
338            if ( $score < $bestBlockScore ) {
339                $bestBlockScore = $score;
340                $bestBlock = $block;
341            }
342        }
343
344        return $bestBlock;
345    }
346
347    /**
348     * Get a set of SQL conditions which select range blocks encompassing a
349     * given range. If the given range is a single IP with start=end, it will
350     * also select single IP blocks with that IP.
351     *
352     * @since 1.42
353     * @param string $start Hexadecimal IP representation
354     * @param string|null $end Hexadecimal IP representation, or null to use $start = $end
355     * @return string
356     */
357    public function getRangeCond( $start, $end ) {
358        // Per T16634, we want to include relevant active range blocks; for
359        // range blocks, we want to include larger ranges which enclose the given
360        // range. We know that all blocks must be smaller than $wgBlockCIDRLimit,
361        // so we can improve performance by filtering on a LIKE clause
362        $chunk = $this->getIpFragment( $start );
363        $dbr = $this->getReplicaDB();
364        $end ??= $start;
365
366        $expr = $dbr->expr(
367                'bt_range_start',
368                IExpression::LIKE,
369                new LikeValue( $chunk, $dbr->anyString() )
370            )
371            ->and( 'bt_range_start', '<=', $start )
372            ->and( 'bt_range_end', '>=', $end );
373        if ( $start === $end ) {
374            // Also select single IP blocks for this target
375            $expr = $dbr->orExpr( [
376                $dbr->expr( 'bt_ip_hex', '=', $start )
377                    ->and( 'bt_range_start', '=', null ),
378                $expr
379            ] );
380        }
381        return $expr->toSql( $dbr );
382    }
383
384    /**
385     * Get the component of an IP address which is certain to be the same between an IP
386     * address and a range block containing that IP address.
387     *
388     * @param string $hex Hexadecimal IP representation
389     * @return string
390     */
391    private function getIpFragment( $hex ) {
392        $blockCIDRLimit = $this->options->get( MainConfigNames::BlockCIDRLimit );
393        if ( str_starts_with( $hex, 'v6-' ) ) {
394            return 'v6-' . substr( substr( $hex, 3 ), 0, (int)floor( $blockCIDRLimit['IPv6'] / 4 ) );
395        } else {
396            return substr( $hex, 0, (int)floor( $blockCIDRLimit['IPv4'] / 4 ) );
397        }
398    }
399
400    /**
401     * Create a new DatabaseBlock object from a database row
402     *
403     * @since 1.42
404     * @param IReadableDatabase $db The database you got the row from
405     * @param stdClass $row Row from the block table
406     * @return DatabaseBlock
407     */
408    public function newFromRow( IReadableDatabase $db, $row ) {
409        return new DatabaseBlock( [
410            'target' => $this->blockTargetFactory->newFromRowRaw( $row ),
411            'wiki' => $this->wikiId,
412            'timestamp' => $row->bl_timestamp,
413            'auto' => (bool)$row->bt_auto,
414            'hideName' => (bool)$row->bl_deleted,
415            'id' => (int)$row->bl_id,
416            // Blocks with no parent ID should have bl_parent_block_id as null,
417            // don't save that as 0 though, see T282890
418            'parentBlockId' => $row->bl_parent_block_id
419                ? (int)$row->bl_parent_block_id : null,
420            'by' => $this->actorStoreFactory
421                ->getActorStore( $this->wikiId )
422                ->newActorFromRowFields( $row->bl_by, $row->bl_by_text, $row->bl_by_actor ),
423            'decodedExpiry' => $db->decodeExpiry( $row->bl_expiry ),
424            'reason' => $this->commentStore->getComment( 'bl_reason', $row ),
425            'anonOnly' => $row->bl_anon_only,
426            'enableAutoblock' => (bool)$row->bl_enable_autoblock,
427            'sitewide' => (bool)$row->bl_sitewide,
428            'createAccount' => (bool)$row->bl_create_account,
429            'blockEmail' => (bool)$row->bl_block_email,
430            'allowUsertalk' => (bool)$row->bl_allow_usertalk
431        ] );
432    }
433
434    /**
435     * Given a target and the target's type, get an existing block object if possible.
436     *
437     * @since 1.42
438     * @param BlockTarget|string|UserIdentity|int|null $specificTarget A block target, which may be one of
439     *   several types:
440     *     * A user to block, in which case $target will be a User
441     *     * An IP to block, in which case $target will be a User generated by using
442     *       User::newFromName( $ip, false ) to turn off name validation
443     *     * An IP range, in which case $target will be a String "123.123.123.123/18" etc
444     *     * The ID of an existing block, in the format "#12345" (since pure numbers are valid
445     *       usernames
446     *     Calling this with a user, IP address or range will not select autoblocks, and will
447     *     only select a block where the targets match exactly (so looking for blocks on
448     *     1.2.3.4 will not select 1.2.0.0/16 or even 1.2.3.4/32)
449     * @param BlockTarget|string|UserIdentity|int|null $vagueTarget As above, but we will search for *any*
450     *     block which affects that target (so for an IP address, get ranges containing that IP;
451     *     and also get any relevant autoblocks). Leave empty or blank to skip IP-based lookups.
452     * @param bool $fromPrimary Whether to use the DB_PRIMARY database
453     * @return DatabaseBlock|null (null if no relevant block could be found). The target and type
454     *     of the returned block will refer to the actual block which was found, which might
455     *     not be the same as the target you gave if you used $vagueTarget!
456     */
457    public function newFromTarget(
458        $specificTarget,
459        $vagueTarget = null,
460        $fromPrimary = false
461    ) {
462        $blocks = $this->newListFromTarget( $specificTarget, $vagueTarget, $fromPrimary );
463        return $this->chooseMostSpecificBlock( $blocks );
464    }
465
466    /**
467     * This is similar to DatabaseBlockStore::newFromTarget, but it returns all the relevant blocks.
468     *
469     * @since 1.42
470     * @param BlockTarget|string|UserIdentity|int|null $specificTarget
471     * @param BlockTarget|string|UserIdentity|int|null $vagueTarget
472     * @param bool $fromPrimary
473     * @return DatabaseBlock[] Any relevant blocks
474     */
475    public function newListFromTarget(
476        $specificTarget,
477        $vagueTarget = null,
478        $fromPrimary = false
479    ) {
480        if ( !( $specificTarget instanceof BlockTarget ) ) {
481            $specificTarget = $this->blockTargetFactory->newFromLegacyUnion( $specificTarget );
482        }
483        if ( $vagueTarget !== null && !( $vagueTarget instanceof BlockTarget ) ) {
484            $vagueTarget = $this->blockTargetFactory->newFromLegacyUnion( $vagueTarget );
485        }
486        if ( $specificTarget instanceof AutoBlockTarget ) {
487            $block = $this->newFromID( $specificTarget->getId() );
488            return $block ? [ $block ] : [];
489        } elseif ( $specificTarget === null && $vagueTarget === null ) {
490            // We're not going to find anything useful here
491            return [];
492        } else {
493            return $this->newLoad( $specificTarget, $fromPrimary, $vagueTarget );
494        }
495    }
496
497    /**
498     * Get all blocks that match any IP from an array of IP addresses
499     *
500     * @since 1.42
501     * @param string[] $addresses Validated list of IP addresses
502     * @param bool $applySoftBlocks Include soft blocks (anonymous-only blocks). These
503     *     should only block anonymous and temporary users.
504     * @param bool $fromPrimary Whether to query the primary or replica DB
505     * @return DatabaseBlock[]
506     */
507    public function newListFromIPs( array $addresses, $applySoftBlocks, $fromPrimary = false ) {
508        if ( $addresses === [] ) {
509            return [];
510        }
511
512        $conds = [];
513        foreach ( array_unique( $addresses ) as $ipaddr ) {
514            $conds[] = $this->getRangeCond( IPUtils::toHex( $ipaddr ), null );
515        }
516
517        if ( $conds === [] ) {
518            return [];
519        }
520
521        if ( $fromPrimary ) {
522            $db = $this->getPrimaryDB();
523        } else {
524            $db = $this->getReplicaDB();
525        }
526        $conds = $db->makeList( $conds, LIST_OR );
527        if ( !$applySoftBlocks ) {
528            $conds = [ $conds, 'bl_anon_only' => 0 ];
529        }
530        $blockQuery = $this->getQueryInfo();
531        $rows = $db->newSelectQueryBuilder()
532            ->queryInfo( $blockQuery )
533            ->fields( [ 'bt_range_start', 'bt_range_end' ] )
534            ->where( $conds )
535            ->caller( __METHOD__ )
536            ->fetchResultSet();
537
538        $blocks = [];
539        foreach ( $rows as $row ) {
540            $block = $this->newFromRow( $db, $row );
541            if ( !$block->isExpired() ) {
542                $blocks[] = $block;
543            }
544        }
545
546        return $blocks;
547    }
548
549    /**
550     * Construct an array of blocks from database conditions.
551     *
552     * @since 1.42
553     * @param array $conds Query conditions, given as an associative array
554     *   mapping field names to values.
555     * @param bool $fromPrimary
556     * @param bool $includeExpired
557     * @return DatabaseBlock[]
558     */
559    public function newListFromConds( $conds, $fromPrimary = false, $includeExpired = false ) {
560        $db = $fromPrimary ? $this->getPrimaryDB() : $this->getReplicaDB();
561        $conds = self::mapActorAlias( $conds );
562        if ( !$includeExpired ) {
563            $conds[] = $db->expr( 'bl_expiry', '>=', $db->timestamp() );
564        }
565        $res = $db->newSelectQueryBuilder()
566            ->queryInfo( $this->getQueryInfo() )
567            ->conds( $conds )
568            ->caller( __METHOD__ )
569            ->fetchResultSet();
570        $blocks = [];
571        foreach ( $res as $row ) {
572            $blocks[] = $this->newFromRow( $db, $row );
573        }
574        return $blocks;
575    }
576
577    // endregion -- end of database read methods
578
579    /***************************************************************************/
580    // region   Database write methods
581    /** @name   Database write methods */
582
583    /**
584     * Create a DatabaseBlock representing an unsaved block. Pass the returned
585     * object to insertBlock().
586     *
587     * @since 1.44
588     *
589     * @param array $options Options as documented in DatabaseBlock and
590     *   AbstractBlock, and additionally:
591     *   - address: (string) A string specifying the block target. This is not
592     *     the same as the legacy address parameter which allows UserIdentity.
593     *   - targetUser: (UserIdentity) The UserIdentity to block
594     * @return DatabaseBlock
595     */
596    public function newUnsaved( array $options ): DatabaseBlock {
597        if ( isset( $options['targetUser'] ) ) {
598            $options['target'] = $this->blockTargetFactory
599                ->newFromUser( $options['targetUser'] );
600            unset( $options['targetUser'] );
601        }
602        if ( isset( $options['address'] ) ) {
603            $target = $this->blockTargetFactory
604                ->newFromString( $options['address'] );
605            if ( !$target ) {
606                throw new InvalidArgumentException( 'Invalid target address' );
607            }
608            $options['target'] = $target;
609            unset( $options['address'] );
610        }
611        return new DatabaseBlock( $options );
612    }
613
614    /**
615     * Delete expired blocks from the block table
616     *
617     * @internal only public for use in DatabaseBlock
618     */
619    public function purgeExpiredBlocks() {
620        if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
621            return;
622        }
623
624        $dbw = $this->getPrimaryDB();
625
626        DeferredUpdates::addUpdate( new AutoCommitUpdate(
627            $dbw,
628            __METHOD__,
629            function ( IDatabase $dbw, $fname ) {
630                $limit = $this->options->get( MainConfigNames::UpdateRowsPerQuery );
631                $res = $dbw->newSelectQueryBuilder()
632                    ->select( [ 'bl_id', 'bl_target' ] )
633                    ->from( 'block' )
634                    ->where( $dbw->expr( 'bl_expiry', '<', $dbw->timestamp() ) )
635                    // Set a limit to avoid causing replication lag (T301742)
636                    ->limit( $limit )
637                    ->caller( $fname )->fetchResultSet();
638                $this->deleteBlockRows( $res );
639            }
640        ) );
641    }
642
643    /**
644     * Delete all blocks matching the given conditions.
645     *
646     * @since 1.42
647     * @param array $conds An associative array mapping the field name to the
648     *   matched value.
649     * @param int|null $limit The maximum number of blocks to delete
650     * @return int The number of blocks deleted
651     */
652    public function deleteBlocksMatchingConds( array $conds, $limit = null ) {
653        $dbw = $this->getPrimaryDB();
654        $conds = self::mapActorAlias( $conds );
655        $qb = $dbw->newSelectQueryBuilder()
656            ->select( [ 'bl_id', 'bl_target' ] )
657            ->from( 'block' )
658            // Typical input conds need block_target
659            ->join( 'block_target', null, 'bt_id=bl_target' )
660            ->where( $conds )
661            ->caller( __METHOD__ );
662        if ( self::hasActorAlias( $conds ) ) {
663            $qb->join( 'actor', 'ipblocks_actor', 'actor_id=bl_by_actor' );
664        }
665        if ( $limit !== null ) {
666            $qb->limit( $limit );
667        }
668        $res = $qb->fetchResultSet();
669        return $this->deleteBlockRows( $res );
670    }
671
672    /**
673     * Helper for deleteBlocksMatchingConds()
674     *
675     * @param array $conds
676     * @return array
677     */
678    private static function mapActorAlias( $conds ) {
679        return self::mapConds(
680            [
681                'bl_by' => 'ipblocks_actor.actor_user',
682            ],
683            $conds
684        );
685    }
686
687    /**
688     * @param array $conds
689     * @return bool
690     */
691    private static function hasActorAlias( $conds ) {
692        return array_key_exists( 'ipblocks_actor.actor_user', $conds )
693            || array_key_exists( 'ipblocks_actor.actor_name', $conds );
694    }
695
696    /**
697     * Remap the keys in an array
698     *
699     * @param array $map
700     * @param array $conds
701     * @return array
702     */
703    private static function mapConds( $map, $conds ) {
704        $newConds = [];
705        foreach ( $conds as $field => $value ) {
706            if ( isset( $map[$field] ) ) {
707                $newConds[$map[$field]] = $value;
708            } else {
709                $newConds[$field] = $value;
710            }
711        }
712        return $newConds;
713    }
714
715    /**
716     * Delete rows from the block table and update the block_target
717     * and ipblocks_restrictions tables accordingly.
718     *
719     * @param IResultWrapper $rows Rows containing bl_id and bl_target
720     * @return int Number of deleted block rows
721     */
722    private function deleteBlockRows( $rows ) {
723        $ids = [];
724        $deltasByTarget = [];
725        foreach ( $rows as $row ) {
726            $ids[] = (int)$row->bl_id;
727            $target = (int)$row->bl_target;
728            if ( !isset( $deltasByTarget[$target] ) ) {
729                $deltasByTarget[$target] = 0;
730            }
731            $deltasByTarget[$target]++;
732        }
733        if ( !$ids ) {
734            return 0;
735        }
736        $dbw = $this->getPrimaryDB();
737        $dbw->startAtomic( __METHOD__ );
738
739        $maxTargetCount = max( $deltasByTarget );
740        for ( $delta = 1; $delta <= $maxTargetCount; $delta++ ) {
741            $targetsWithThisDelta = array_keys( $deltasByTarget, $delta, true );
742            if ( $targetsWithThisDelta ) {
743                $this->releaseTargets( $dbw, $targetsWithThisDelta, $delta );
744            }
745        }
746
747        $dbw->newDeleteQueryBuilder()
748            ->deleteFrom( 'block' )
749            ->where( [ 'bl_id' => $ids ] )
750            ->caller( __METHOD__ )->execute();
751        $numDeleted = $dbw->affectedRows();
752        $dbw->endAtomic( __METHOD__ );
753        $this->blockRestrictionStore->deleteByBlockId( $ids );
754        return $numDeleted;
755    }
756
757    /**
758     * Decrement the bt_count field of a set of block_target rows and delete
759     * the rows if the count falls to zero.
760     *
761     * @param IDatabase $dbw
762     * @param int[] $targetIds
763     * @param int $delta The amount to decrement by
764     */
765    private function releaseTargets( IDatabase $dbw, $targetIds, int $delta = 1 ) {
766        if ( !$targetIds ) {
767            return;
768        }
769        $dbw->newUpdateQueryBuilder()
770            ->update( 'block_target' )
771            ->set( [ 'bt_count' => new RawSQLValue( "bt_count-$delta" ) ] )
772            ->where( [ 'bt_id' => $targetIds ] )
773            ->caller( __METHOD__ )
774            ->execute();
775        $dbw->newDeleteQueryBuilder()
776            ->deleteFrom( 'block_target' )
777            ->where( [
778                'bt_count<1',
779                'bt_id' => $targetIds
780            ] )
781            ->caller( __METHOD__ )
782            ->execute();
783    }
784
785    private function getReplicaDB(): IReadableDatabase {
786        return $this->dbProvider->getReplicaDatabase( $this->wikiId );
787    }
788
789    private function getPrimaryDB(): IDatabase {
790        return $this->dbProvider->getPrimaryDatabase( $this->wikiId );
791    }
792
793    /**
794     * Insert a block into the block table. Will fail if there is a conflicting
795     * block (same name and options) already in the database.
796     *
797     * @param DatabaseBlock $block
798     * @param int|null $expectedTargetCount The expected number of existing blocks
799     *   on the specified target. If this is zero but there is an existing
800     *   block, the insertion will fail.
801     * @return bool|array False on failure, assoc array on success:
802     *   - id: block ID
803     *   - autoIds: array of autoblock IDs
804     *   - finalTargetCount: The updated number of blocks for the specified target.
805     */
806    public function insertBlock(
807        DatabaseBlock $block,
808        $expectedTargetCount = 0
809    ) {
810        $block->assertWiki( $this->wikiId );
811
812        $blocker = $block->getBlocker();
813        if ( !$blocker || $blocker->getName() === '' ) {
814            throw new InvalidArgumentException( 'Cannot insert a block without a blocker set' );
815        }
816
817        if ( $expectedTargetCount instanceof IDatabase ) {
818            throw new InvalidArgumentException(
819                'Old method signature: Passing a custom database connection to '
820                    . 'DatabaseBlockStore::insertBlock is no longer supported'
821            );
822        }
823
824        $this->logger->debug( 'Inserting block; timestamp ' . $block->getTimestamp() );
825
826        // Purge expired blocks. This now just queues a deferred update, so it
827        // is possible for expired blocks to conflict with inserted blocks below.
828        $this->purgeExpiredBlocks();
829
830        $dbw = $this->getPrimaryDB();
831        $dbw->startAtomic( __METHOD__ );
832        $finalTargetCount = $this->attemptInsert( $block, $dbw, $expectedTargetCount );
833        $purgeDone = false;
834
835        // Don't collide with expired blocks.
836        // Do this after trying to insert to avoid locking.
837        if ( !$finalTargetCount ) {
838            if ( $this->purgeExpiredConflicts( $block, $dbw ) ) {
839                $finalTargetCount = $this->attemptInsert( $block, $dbw, $expectedTargetCount );
840                $purgeDone = true;
841            }
842        }
843        $dbw->endAtomic( __METHOD__ );
844
845        if ( $finalTargetCount > 1 && !$purgeDone ) {
846            // Subtract expired blocks from the target count
847            $expiredBlockCount = $this->getExpiredConflictingBlockRows( $block, $dbw )->count();
848            if ( $expiredBlockCount >= $finalTargetCount ) {
849                $finalTargetCount = 1;
850            } else {
851                $finalTargetCount -= $expiredBlockCount;
852            }
853        }
854
855        if ( $finalTargetCount ) {
856            $autoBlockIds = $this->doRetroactiveAutoblock( $block );
857
858            if ( $this->options->get( MainConfigNames::BlockDisablesLogin ) ) {
859                $targetUserIdentity = $block->getTargetUserIdentity();
860                if ( $targetUserIdentity ) {
861                    $targetUser = $this->userFactory->newFromUserIdentity( $targetUserIdentity );
862                    SessionManager::singleton()->invalidateSessionsForUser( $targetUser );
863                }
864            }
865
866            return [
867                'id' => $block->getId( $this->wikiId ),
868                'autoIds' => $autoBlockIds,
869                'finalTargetCount' => $finalTargetCount
870            ];
871        }
872
873        return false;
874    }
875
876    /**
877     * Create a block with an array of parameters and immediately insert it.
878     * Throw an exception on failure. This is a convenience method for testing.
879     *
880     * Duplicate blocks for a given target are allowed by default.
881     *
882     * @since 1.44
883     * @param array $params Parameters for newUnsaved(), and also:
884     *   - expectedTargetCount: Use this to override conflict checking
885     * @return DatabaseBlock The inserted Block
886     */
887    public function insertBlockWithParams( array $params ): DatabaseBlock {
888        $block = $this->newUnsaved( $params );
889        $status = $this->insertBlock( $block, $params['expectedTargetCount'] ?? null );
890        if ( !$status ) {
891            throw new RuntimeException( 'Failed to insert block' );
892        }
893        return $block;
894    }
895
896    /**
897     * Attempt to insert rows into block, block_target and ipblocks_restrictions.
898     * If there is a conflict, return false.
899     *
900     * @param DatabaseBlock $block
901     * @param IDatabase $dbw
902     * @param int|null $expectedTargetCount
903     * @return int|false The updated number of blocks for the target, or false on failure
904     */
905    private function attemptInsert(
906        DatabaseBlock $block,
907        IDatabase $dbw,
908        $expectedTargetCount
909    ) {
910        [ $targetId, $finalCount ] = $this->acquireTarget( $block, $dbw, $expectedTargetCount );
911        if ( !$targetId ) {
912            return false;
913        }
914        $row = $this->getArrayForBlockUpdate( $block, $dbw );
915        $row['bl_target'] = $targetId;
916        $dbw->newInsertQueryBuilder()
917            ->insertInto( 'block' )
918            ->row( $row )
919            ->caller( __METHOD__ )->execute();
920        if ( !$dbw->affectedRows() ) {
921            return false;
922        }
923        $id = $dbw->insertId();
924
925        if ( !$id ) {
926            throw new RuntimeException( 'block insert ID is falsey' );
927        }
928        $block->setId( $id );
929        $restrictions = $block->getRawRestrictions();
930        if ( $restrictions ) {
931            $this->blockRestrictionStore->insert( $restrictions );
932        }
933
934        return $finalCount;
935    }
936
937    /**
938     * Purge expired blocks that have the same target as the specified block
939     *
940     * @param DatabaseBlock $block
941     * @param IDatabase $dbw
942     * @return bool True if a conflicting block was deleted
943     */
944    private function purgeExpiredConflicts(
945        DatabaseBlock $block,
946        IDatabase $dbw
947    ) {
948        return (bool)$this->deleteBlockRows(
949            $this->getExpiredConflictingBlockRows( $block, $dbw )
950        );
951    }
952
953    /**
954     * Get rows with bl_id/bl_target for expired blocks that have the same
955     * target as the specified block.
956     *
957     * @param DatabaseBlock $block
958     * @param IDatabase $dbw
959     * @return IResultWrapper
960     */
961    private function getExpiredConflictingBlockRows(
962        DatabaseBlock $block,
963        IDatabase $dbw
964    ) {
965        $targetConds = $this->getTargetConds( $block );
966        return $dbw->newSelectQueryBuilder()
967            ->select( [ 'bl_id', 'bl_target' ] )
968            ->from( 'block' )
969            ->join( 'block_target', null, [ 'bt_id=bl_target' ] )
970            ->where( $targetConds )
971            ->andWhere( $dbw->expr( 'bl_expiry', '<', $dbw->timestamp() ) )
972            ->caller( __METHOD__ )->fetchResultSet();
973    }
974
975    /**
976     * Get conditions matching an existing block's block_target row
977     *
978     * @param DatabaseBlock $block
979     * @return array
980     */
981    private function getTargetConds( DatabaseBlock $block ) {
982        $target = $block->getTarget();
983        if ( $target instanceof UserBlockTarget ) {
984            return [
985                'bt_user' => $target->getUserIdentity()->getId( $this->wikiId )
986            ];
987        } else {
988            return [ 'bt_address' => $target->toString() ];
989        }
990    }
991
992    /**
993     * Insert a new block_target row, or update bt_count in the existing target
994     * row for a given block, and return the target ID and new bt_count.
995     *
996     * An atomic section should be active while calling this function.
997     *
998     * @param DatabaseBlock $block
999     * @param IDatabase $dbw
1000     * @param int|null $expectedTargetCount If this is zero and a row already
1001     *   exists, abort the insert and return null. If this is greater than zero
1002     *   and the pre-increment bt_count value does not match, abort the update
1003     *   and return null. If this is null, do not perform any conflict checks.
1004     * @return array{?int,int}
1005     */
1006    private function acquireTarget(
1007        DatabaseBlock $block,
1008        IDatabase $dbw,
1009        $expectedTargetCount
1010    ) {
1011        $target = $block->getTarget();
1012        // Note: for new autoblocks, the target is an IpBlockTarget
1013        $isAuto = $block->getType() === Block::TYPE_AUTO;
1014        if ( $target instanceof UserBlockTarget ) {
1015            $targetAddress = null;
1016            $targetUserName = (string)$target;
1017            $targetUserId = $target->getUserIdentity()->getId( $this->wikiId );
1018            $targetConds = [ 'bt_user' => $targetUserId ];
1019        } else {
1020            $targetAddress = (string)$target;
1021            $targetUserName = null;
1022            $targetUserId = null;
1023            $targetConds = [
1024                'bt_address' => $targetAddress,
1025                'bt_auto' => $isAuto,
1026            ];
1027        }
1028
1029        $condsWithCount = $targetConds;
1030        if ( $expectedTargetCount !== null ) {
1031            $condsWithCount['bt_count'] = $expectedTargetCount;
1032        }
1033
1034        // This query locks the index gap when the target doesn't exist yet,
1035        // so there is a risk of throttling adjacent block insertions,
1036        // especially on small wikis which have larger gaps. If this proves to
1037        // be a problem, we could have getPrimaryDB() return an autocommit
1038        // connection.
1039        $dbw->newUpdateQueryBuilder()
1040            ->update( 'block_target' )
1041            ->set( [ 'bt_count' => new RawSQLValue( 'bt_count+1' ) ] )
1042            ->where( $condsWithCount )
1043            ->caller( __METHOD__ )->execute();
1044        $numUpdatedRows = $dbw->affectedRows();
1045
1046        // Now that the row is locked, find the target ID
1047        $res = $dbw->newSelectQueryBuilder()
1048            ->select( [ 'bt_id', 'bt_count' ] )
1049            ->from( 'block_target' )
1050            ->where( $targetConds )
1051            ->caller( __METHOD__ )
1052            ->fetchResultSet();
1053        if ( $res->numRows() > 1 ) {
1054            $ids = [];
1055            foreach ( $res as $row ) {
1056                $ids[] = $row->bt_id;
1057            }
1058            throw new RuntimeException( "Duplicate block_target rows detected: " .
1059                implode( ',', $ids ) );
1060        }
1061        $row = $res->fetchObject();
1062
1063        if ( $row ) {
1064            $count = (int)$row->bt_count;
1065            if ( !$numUpdatedRows ) {
1066                // ID found but count update failed -- must be a conflict due to bt_count mismatch
1067                return [ null, $count ];
1068            }
1069            $id = (int)$row->bt_id;
1070        } else {
1071            if ( $numUpdatedRows ) {
1072                throw new RuntimeException(
1073                    'block_target row unexpectedly missing after we locked it' );
1074            }
1075            if ( $expectedTargetCount !== 0 && $expectedTargetCount !== null ) {
1076                // Conflict (expectation failure)
1077                return [ null, 0 ];
1078            }
1079
1080            // Insert new row
1081            $targetRow = [
1082                'bt_address' => $targetAddress,
1083                'bt_user' => $targetUserId,
1084                'bt_user_text' => $targetUserName,
1085                'bt_auto' => $isAuto,
1086                'bt_range_start' => $block->getRangeStart(),
1087                'bt_range_end' => $block->getRangeEnd(),
1088                'bt_ip_hex' => $block->getIpHex(),
1089                'bt_count' => 1
1090            ];
1091            $dbw->newInsertQueryBuilder()
1092                ->insertInto( 'block_target' )
1093                ->row( $targetRow )
1094                ->caller( __METHOD__ )->execute();
1095            $id = $dbw->insertId();
1096            if ( !$id ) {
1097                throw new RuntimeException(
1098                    'block_target insert ID is falsey despite unconditional insert' );
1099            }
1100            $count = 1;
1101        }
1102
1103        return [ $id, $count ];
1104    }
1105
1106    /**
1107     * Update a block in the DB with new parameters.
1108     * The ID field needs to be loaded first. The target must stay the same.
1109     *
1110     * TODO: remove the possibility of false return. The cases where this
1111     *   happens are exotic enough that they should just be exceptions.
1112     *
1113     * @param DatabaseBlock $block
1114     * @return bool|array False on failure, array on success:
1115     *   ('id' => block ID, 'autoIds' => array of autoblock IDs)
1116     */
1117    public function updateBlock( DatabaseBlock $block ) {
1118        $this->logger->debug( 'Updating block; timestamp ' . $block->getTimestamp() );
1119
1120        $block->assertWiki( $this->wikiId );
1121
1122        $blockId = $block->getId( $this->wikiId );
1123        if ( !$blockId ) {
1124            throw new InvalidArgumentException(
1125                __METHOD__ . ' requires that a block id be set'
1126            );
1127        }
1128
1129        $dbw = $this->getPrimaryDB();
1130
1131        $dbw->startAtomic( __METHOD__ );
1132
1133        $row = $this->getArrayForBlockUpdate( $block, $dbw );
1134        $dbw->newUpdateQueryBuilder()
1135            ->update( 'block' )
1136            ->set( $row )
1137            ->where( [ 'bl_id' => $blockId ] )
1138            ->caller( __METHOD__ )->execute();
1139
1140        // Only update the restrictions if they have been modified.
1141        $result = true;
1142        $restrictions = $block->getRawRestrictions();
1143        if ( $restrictions !== null ) {
1144            // An empty array should remove all of the restrictions.
1145            if ( $restrictions === [] ) {
1146                $result = $this->blockRestrictionStore->deleteByBlockId( $blockId );
1147            } else {
1148                $result = $this->blockRestrictionStore->update( $restrictions );
1149            }
1150        }
1151
1152        if ( $block->isAutoblocking() ) {
1153            // Update corresponding autoblock(s) (T50813)
1154            $dbw->newUpdateQueryBuilder()
1155                ->update( 'block' )
1156                ->set( $this->getArrayForAutoblockUpdate( $block ) )
1157                ->where( [ 'bl_parent_block_id' => $blockId ] )
1158                ->caller( __METHOD__ )->execute();
1159
1160            // Only update the restrictions if they have been modified.
1161            if ( $restrictions !== null ) {
1162                $this->blockRestrictionStore->updateByParentBlockId(
1163                    $blockId,
1164                    $restrictions
1165                );
1166            }
1167        } else {
1168            // Autoblock no longer required, delete corresponding autoblock(s)
1169            $this->deleteBlocksMatchingConds( [ 'bl_parent_block_id' => $blockId ] );
1170        }
1171
1172        $dbw->endAtomic( __METHOD__ );
1173
1174        if ( $result ) {
1175            $autoBlockIds = $this->doRetroactiveAutoblock( $block );
1176            return [ 'id' => $blockId, 'autoIds' => $autoBlockIds ];
1177        }
1178
1179        return false;
1180    }
1181
1182    /**
1183     * Update the target in the specified object and in the database. The block
1184     * ID must be set.
1185     *
1186     * This is an unusual operation, currently used only by the UserMerge
1187     * extension.
1188     *
1189     * @since 1.42
1190     * @param DatabaseBlock $block
1191     * @param BlockTarget|UserIdentity|string $newTarget
1192     * @return bool True if the update was successful, false if there was no
1193     *   match for the block ID.
1194     */
1195    public function updateTarget( DatabaseBlock $block, $newTarget ) {
1196        $dbw = $this->getPrimaryDB();
1197        $blockId = $block->getId( $this->wikiId );
1198        if ( !$blockId ) {
1199            throw new InvalidArgumentException(
1200                __METHOD__ . " requires that a block id be set\n"
1201            );
1202        }
1203        if ( !( $newTarget instanceof BlockTarget ) ) {
1204            $newTarget = $this->blockTargetFactory->newFromLegacyUnion( $newTarget );
1205        }
1206
1207        $oldTargetConds = $this->getTargetConds( $block );
1208        $block->setTarget( $newTarget );
1209
1210        $dbw->startAtomic( __METHOD__ );
1211        [ $targetId, $count ] = $this->acquireTarget( $block, $dbw, null );
1212        if ( !$targetId ) {
1213            // This is an exotic and unlikely error -- perhaps an exception should be thrown
1214            $dbw->endAtomic( __METHOD__ );
1215            return false;
1216        }
1217        $oldTargetId = $dbw->newSelectQueryBuilder()
1218            ->select( 'bt_id' )
1219            ->from( 'block_target' )
1220            ->where( $oldTargetConds )
1221            ->caller( __METHOD__ )->fetchField();
1222        $this->releaseTargets( $dbw, [ $oldTargetId ] );
1223
1224        $dbw->newUpdateQueryBuilder()
1225            ->update( 'block' )
1226            ->set( [ 'bl_target' => $targetId ] )
1227            ->where( [ 'bl_id' => $blockId ] )
1228            ->caller( __METHOD__ )
1229            ->execute();
1230        $affected = $dbw->affectedRows();
1231        $dbw->endAtomic( __METHOD__ );
1232        return (bool)$affected;
1233    }
1234
1235    /**
1236     * Delete a DatabaseBlock from the database
1237     *
1238     * @param DatabaseBlock $block
1239     * @return bool whether it was deleted
1240     */
1241    public function deleteBlock( DatabaseBlock $block ): bool {
1242        if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
1243            return false;
1244        }
1245
1246        $block->assertWiki( $this->wikiId );
1247
1248        $blockId = $block->getId( $this->wikiId );
1249
1250        if ( !$blockId ) {
1251            throw new InvalidArgumentException(
1252                __METHOD__ . ' requires that a block id be set'
1253            );
1254        }
1255        $dbw = $this->getPrimaryDB();
1256        $dbw->startAtomic( __METHOD__ );
1257        $res = $dbw->newSelectQueryBuilder()
1258            ->select( [ 'bl_id', 'bl_target' ] )
1259            ->from( 'block' )
1260            ->where(
1261                $dbw->orExpr( [
1262                    'bl_parent_block_id' => $blockId,
1263                    'bl_id' => $blockId,
1264                ] )
1265            )
1266            ->caller( __METHOD__ )->fetchResultSet();
1267        $this->deleteBlockRows( $res );
1268        $affected = $res->numRows();
1269        $dbw->endAtomic( __METHOD__ );
1270
1271        return $affected > 0;
1272    }
1273
1274    /**
1275     * Get an array suitable for passing to $dbw->insert() or $dbw->update()
1276     *
1277     * @param DatabaseBlock $block
1278     * @param IDatabase $dbw Database to use if not the same as the one in the load balancer.
1279     *                       Must connect to the wiki identified by $block->getBlocker->getWikiId().
1280     * @return array
1281     */
1282    private function getArrayForBlockUpdate(
1283        DatabaseBlock $block,
1284        IDatabase $dbw
1285    ): array {
1286        $expiry = $dbw->encodeExpiry( $block->getExpiry() );
1287
1288        $blocker = $block->getBlocker();
1289        if ( !$blocker ) {
1290            throw new RuntimeException( __METHOD__ . ': this block does not have a blocker' );
1291        }
1292        // DatabaseBlockStore supports inserting cross-wiki blocks by passing
1293        // non-local IDatabase and blocker.
1294        $blockerActor = $this->actorStoreFactory
1295            ->getActorStore( $dbw->getDomainID() )
1296            ->acquireActorId( $blocker, $dbw );
1297
1298        $blockArray = [
1299            'bl_by_actor'         => $blockerActor,
1300            'bl_timestamp'        => $dbw->timestamp( $block->getTimestamp() ),
1301            'bl_anon_only'        => !$block->isHardblock(),
1302            'bl_create_account'   => $block->isCreateAccountBlocked(),
1303            'bl_enable_autoblock' => $block->isAutoblocking(),
1304            'bl_expiry'           => $expiry,
1305            'bl_deleted'          => intval( $block->getHideName() ), // typecast required for SQLite
1306            'bl_block_email'      => $block->isEmailBlocked(),
1307            'bl_allow_usertalk'   => $block->isUsertalkEditAllowed(),
1308            'bl_parent_block_id'  => $block->getParentBlockId(),
1309            'bl_sitewide'         => $block->isSitewide(),
1310        ];
1311        $commentArray = $this->commentStore->insert(
1312            $dbw,
1313            'bl_reason',
1314            $block->getReasonComment()
1315        );
1316
1317        $combinedArray = $blockArray + $commentArray;
1318        return $combinedArray;
1319    }
1320
1321    /**
1322     * Get an array suitable for autoblock updates
1323     *
1324     * @param DatabaseBlock $block
1325     * @return array
1326     */
1327    private function getArrayForAutoblockUpdate( DatabaseBlock $block ): array {
1328        $blocker = $block->getBlocker();
1329        if ( !$blocker ) {
1330            throw new RuntimeException( __METHOD__ . ': this block does not have a blocker' );
1331        }
1332        $dbw = $this->getPrimaryDB();
1333        $blockerActor = $this->actorStoreFactory
1334            ->getActorNormalization( $this->wikiId )
1335            ->acquireActorId( $blocker, $dbw );
1336
1337        $blockArray = [
1338            'bl_by_actor'       => $blockerActor,
1339            'bl_create_account' => $block->isCreateAccountBlocked(),
1340            'bl_deleted'        => (int)$block->getHideName(), // typecast required for SQLite
1341            'bl_allow_usertalk' => $block->isUsertalkEditAllowed(),
1342            'bl_sitewide'       => $block->isSitewide(),
1343        ];
1344
1345        // Shorten the autoblock expiry if the parent block expiry is sooner.
1346        // Don't lengthen -- that is only done when the IP address is actually
1347        // used by the blocked user.
1348        if ( $block->getExpiry() !== 'infinity' ) {
1349            $blockArray['bl_expiry'] = new RawSQLValue( $dbw->conditional(
1350                    $dbw->expr( 'bl_expiry', '>', $dbw->timestamp( $block->getExpiry() ) ),
1351                    $dbw->addQuotes( $dbw->timestamp( $block->getExpiry() ) ),
1352                    'bl_expiry'
1353                ) );
1354        }
1355
1356        $commentArray = $this->commentStore->insert(
1357            $dbw,
1358            'bl_reason',
1359            $this->getAutoblockReason( $block )
1360        );
1361
1362        $combinedArray = $blockArray + $commentArray;
1363        return $combinedArray;
1364    }
1365
1366    /**
1367     * Handle retroactively autoblocking the last IP used by the user (if it is a user)
1368     * blocked by an auto block.
1369     *
1370     * @param DatabaseBlock $block
1371     * @return array IDs of retroactive autoblocks made
1372     */
1373    private function doRetroactiveAutoblock( DatabaseBlock $block ): array {
1374        $autoBlockIds = [];
1375        // If autoblock is enabled, autoblock the LAST IP(s) used
1376        if ( $block->isAutoblocking() && $block->getType() == AbstractBlock::TYPE_USER ) {
1377            $this->logger->debug(
1378                'Doing retroactive autoblocks for ' . $block->getTargetName()
1379            );
1380
1381            $hookAutoBlocked = [];
1382            $continue = $this->hookRunner->onPerformRetroactiveAutoblock(
1383                $block,
1384                $hookAutoBlocked
1385            );
1386
1387            if ( $continue ) {
1388                $coreAutoBlocked = $this->performRetroactiveAutoblock( $block );
1389                $autoBlockIds = array_merge( $hookAutoBlocked, $coreAutoBlocked );
1390            } else {
1391                $autoBlockIds = $hookAutoBlocked;
1392            }
1393        }
1394        return $autoBlockIds;
1395    }
1396
1397    /**
1398     * Actually retroactively autoblocks the last IP used by the user (if it is a user)
1399     * blocked by this block. This will use the recentchanges table.
1400     *
1401     * @param DatabaseBlock $block
1402     * @return array
1403     */
1404    private function performRetroactiveAutoblock( DatabaseBlock $block ): array {
1405        if ( !$this->options->get( MainConfigNames::PutIPinRC ) ) {
1406            // No IPs in the recent changes table to autoblock
1407            return [];
1408        }
1409
1410        $target = $block->getTarget();
1411        if ( !( $target instanceof UserBlockTarget ) ) {
1412            // Autoblocks only apply to users
1413            return [];
1414        }
1415
1416        $dbr = $this->getReplicaDB();
1417
1418        $actor = $this->actorStoreFactory
1419            ->getActorNormalization( $this->wikiId )
1420            ->findActorId( $target->getUserIdentity(), $dbr );
1421
1422        if ( !$actor ) {
1423            $this->logger->debug( 'No actor found to retroactively autoblock' );
1424            return [];
1425        }
1426
1427        $rcIp = $dbr->newSelectQueryBuilder()
1428            ->select( 'rc_ip' )
1429            ->from( 'recentchanges' )
1430            ->where( [ 'rc_actor' => $actor ] )
1431            ->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC )
1432            ->caller( __METHOD__ )->fetchField();
1433
1434        if ( !$rcIp ) {
1435            $this->logger->debug( 'No IP found to retroactively autoblock' );
1436            return [];
1437        }
1438
1439        $id = $this->doAutoblock( $block, $rcIp );
1440        if ( !$id ) {
1441            return [];
1442        }
1443        return [ $id ];
1444    }
1445
1446    /**
1447     * Autoblocks the given IP, referring to the specified block.
1448     *
1449     * @since 1.42
1450     * @param DatabaseBlock $parentBlock
1451     * @param string $autoblockIP The IP to autoblock.
1452     * @return int|false ID if an autoblock was inserted, false if not.
1453     */
1454    public function doAutoblock( DatabaseBlock $parentBlock, $autoblockIP ) {
1455        // If autoblocks are disabled, go away.
1456        if ( !$parentBlock->isAutoblocking() ) {
1457            return false;
1458        }
1459        $parentBlock->assertWiki( $this->wikiId );
1460
1461        if ( !IPUtils::isValid( $autoblockIP ) ) {
1462            $this->logger->debug( "Invalid autoblock IP" );
1463            return false;
1464        }
1465        $target = $this->blockTargetFactory->newAnonIpBlockTarget( $autoblockIP );
1466
1467        // Check if autoblock exempt.
1468        if ( $this->autoblockExemptionList->isExempt( $autoblockIP ) ) {
1469            return false;
1470        }
1471
1472        // Allow hooks to cancel the autoblock.
1473        if ( !$this->hookRunner->onAbortAutoblock( $autoblockIP, $parentBlock ) ) {
1474            $this->logger->debug( "Autoblock aborted by hook." );
1475            return false;
1476        }
1477
1478        // It's okay to autoblock. Go ahead and insert/update the block...
1479
1480        // Do not add a *new* block if the IP is already blocked.
1481        $blocks = $this->newLoad( $target, false );
1482        if ( $blocks ) {
1483            foreach ( $blocks as $ipblock ) {
1484                // Check if the block is an autoblock and would exceed the user block
1485                // if renewed. If so, do nothing, otherwise prolong the block time...
1486                if ( $ipblock->getType() === Block::TYPE_AUTO
1487                    && $parentBlock->getExpiry() > $ipblock->getExpiry()
1488                ) {
1489                    // Reset block timestamp to now and its expiry to
1490                    // $wgAutoblockExpiry in the future
1491                    $this->updateTimestamp( $ipblock );
1492                }
1493            }
1494            return false;
1495        }
1496        $blocker = $parentBlock->getBlocker();
1497        if ( !$blocker ) {
1498            throw new RuntimeException( __METHOD__ . ': this block does not have a blocker' );
1499        }
1500
1501        $timestamp = wfTimestampNow();
1502        $expiry = $this->getAutoblockExpiry( $timestamp, $parentBlock->getExpiry() );
1503        $autoblock = new DatabaseBlock( [
1504            'wiki' => $this->wikiId,
1505            'target' => $target,
1506            'by' => $blocker,
1507            'reason' => $this->getAutoblockReason( $parentBlock ),
1508            'decodedTimestamp' => $timestamp,
1509            'auto' => true,
1510            'createAccount' => $parentBlock->isCreateAccountBlocked(),
1511            // Continue suppressing the name if needed
1512            'hideName' => $parentBlock->getHideName(),
1513            'allowUsertalk' => $parentBlock->isUsertalkEditAllowed(),
1514            'parentBlockId' => $parentBlock->getId( $this->wikiId ),
1515            'sitewide' => $parentBlock->isSitewide(),
1516            'restrictions' => $parentBlock->getRestrictions(),
1517            'decodedExpiry' => $expiry,
1518        ] );
1519
1520        $this->logger->debug( "Autoblocking {$parentBlock->getTargetName()}@" . $target );
1521
1522        $status = $this->insertBlock( $autoblock );
1523        return $status
1524            ? $status['id']
1525            : false;
1526    }
1527
1528    private function getAutoblockReason( DatabaseBlock $parentBlock ) {
1529        return wfMessage(
1530            'autoblocker',
1531            $parentBlock->getTargetName(),
1532            $parentBlock->getReasonComment()->text
1533        )->inContentLanguage()->plain();
1534    }
1535
1536    /**
1537     * Update the timestamp on autoblocks.
1538     *
1539     * @internal Public to support deprecated DatabaseBlock::updateTimestamp()
1540     * @param DatabaseBlock $block
1541     */
1542    public function updateTimestamp( DatabaseBlock $block ) {
1543        $block->assertWiki( $this->wikiId );
1544        if ( $block->getType() !== Block::TYPE_AUTO ) {
1545            return;
1546        }
1547        $now = wfTimestamp();
1548        $block->setTimestamp( $now );
1549        // No need to reduce the autoblock expiry to the expiry of the parent
1550        // block, since the caller already checked for that.
1551        $block->setExpiry( $this->getAutoblockExpiry( $now ) );
1552
1553        $dbw = $this->getPrimaryDB();
1554        $dbw->newUpdateQueryBuilder()
1555            ->update( 'block' )
1556            ->set(
1557                [
1558                    'bl_timestamp' => $dbw->timestamp( $block->getTimestamp() ),
1559                    'bl_expiry' => $dbw->timestamp( $block->getExpiry() ),
1560                ]
1561            )
1562            ->where( [ 'bl_id' => $block->getId( $this->wikiId ) ] )
1563            ->caller( __METHOD__ )->execute();
1564    }
1565
1566    /**
1567     * Get the expiry timestamp for an autoblock created at the given time.
1568     *
1569     * If the parent block expiry is specified, the return value will be earlier
1570     * than or equal to the parent block expiry.
1571     *
1572     * @internal Public to support deprecated DatabaseBlock method
1573     * @param string|int $timestamp
1574     * @param string|null $parentExpiry
1575     * @return string
1576     */
1577    public function getAutoblockExpiry( $timestamp, ?string $parentExpiry = null ) {
1578        $maxDuration = $this->options->get( MainConfigNames::AutoblockExpiry );
1579        $expiry = wfTimestamp( TS_MW, (int)wfTimestamp( TS_UNIX, $timestamp ) + $maxDuration );
1580        if ( $parentExpiry !== null && $parentExpiry !== 'infinity' ) {
1581            $expiry = min( $parentExpiry, $expiry );
1582        }
1583        return $expiry;
1584    }
1585
1586    // endregion -- end of database write methods
1587
1588}