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