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