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