Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.59% covered (success)
99.59%
240 / 241
93.75% covered (success)
93.75%
15 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
GlobalBlockLookup
99.59% covered (success)
99.59%
240 / 241
93.75% covered (success)
93.75%
15 / 16
73
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getUserBlock
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getUserBlockErrors
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addToUserBlockDetailsCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUserBlockDetailsCacheResult
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserBlockDetails
100.00% covered (success)
100.00%
89 / 89
100.00% covered (success)
100.00%
1 / 1
17
 getTargetType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 chooseMostSpecificBlock
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 getGlobalBlockingBlock
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getRangeCondition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGlobalBlockLookupConditions
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
15
 getIpFragment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 checkIpsForBlock
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
9
 getAppliedBlock
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getGlobalBlockId
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 selectFields
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\GlobalBlocking\Services;
4
5use InvalidArgumentException;
6use Language;
7use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
8use MediaWiki\Config\ServiceOptions;
9use MediaWiki\Extension\GlobalBlocking\GlobalBlock;
10use MediaWiki\Extension\GlobalBlocking\Hook\GlobalBlockingHookRunner;
11use MediaWiki\HookContainer\HookContainer;
12use MediaWiki\User\CentralId\CentralIdLookup;
13use MediaWiki\User\User;
14use MediaWiki\User\UserIdentity;
15use MediaWiki\WikiMap\WikiMap;
16use Message;
17use RequestContext;
18use stdClass;
19use Wikimedia\IPUtils;
20use Wikimedia\Rdbms\IExpression;
21use Wikimedia\Rdbms\IResultWrapper;
22use Wikimedia\Rdbms\LikeValue;
23use Wikimedia\Rdbms\OrExpressionGroup;
24
25/**
26 * Allows looking up global blocks in the globalblocks table.
27 *
28 * @since 1.42
29 */
30class GlobalBlockLookup {
31
32    public const CONSTRUCTOR_OPTIONS = [
33        'GlobalBlockingAllowedRanges',
34        'GlobalBlockingBlockXFF',
35        'GlobalBlockingCIDRLimit',
36        'GlobalBlockingAllowGlobalAccountBlocks',
37    ];
38
39    private const TYPE_USER = 1;
40    private const TYPE_IP = 2;
41    private const TYPE_RANGE = 3;
42
43    /** @var int Flag to ignore blocks on IP addresses which are marked as anon-only. */
44    public const SKIP_SOFT_IP_BLOCKS = 1;
45    /** @var int Flag to ignore all blocks on IP addresses. */
46    public const SKIP_IP_BLOCKS = 2;
47    /** @var int Flag to skip checking if the blocks that affect a target are locally disabled. */
48    public const SKIP_LOCAL_DISABLE_CHECK = 4;
49    /** @var int Flag to skip the excluding of IP blocks in the GlobalBlockingAllowedRanges config. */
50    public const SKIP_ALLOWED_RANGES_CHECK = 8;
51
52    private ServiceOptions $options;
53    private GlobalBlockingConnectionProvider $globalBlockingConnectionProvider;
54    private StatsdDataFactoryInterface $statsdFactory;
55    private GlobalBlockingHookRunner $hookRunner;
56    private CentralIdLookup $centralIdLookup;
57    private Language $contentLanguage;
58    private GlobalBlockReasonFormatter $globalBlockReasonFormatter;
59    private GlobalBlockLocalStatusLookup $globalBlockLocalStatusLookup;
60    private GlobalBlockingLinkBuilder $globalBlockingLinkBuilder;
61
62    private array $getUserBlockDetailsCache = [];
63
64    public function __construct(
65        ServiceOptions $options,
66        GlobalBlockingConnectionProvider $globalBlockingConnectionProvider,
67        StatsdDataFactoryInterface $statsdFactory,
68        HookContainer $hookContainer,
69        CentralIdLookup $centralIdLookup,
70        Language $contentLanguage,
71        GlobalBlockReasonFormatter $globalBlockReasonFormatter,
72        GlobalBlockLocalStatusLookup $globalBlockLocalStatusLookup,
73        GlobalBlockingLinkBuilder $globalBlockingLinkBuilder
74    ) {
75        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
76        $this->options = $options;
77        $this->globalBlockingConnectionProvider = $globalBlockingConnectionProvider;
78        $this->statsdFactory = $statsdFactory;
79        $this->hookRunner = new GlobalBlockingHookRunner( $hookContainer );
80        $this->centralIdLookup = $centralIdLookup;
81        $this->contentLanguage = $contentLanguage;
82        $this->globalBlockReasonFormatter = $globalBlockReasonFormatter;
83        $this->globalBlockLocalStatusLookup = $globalBlockLocalStatusLookup;
84        $this->globalBlockingLinkBuilder = $globalBlockingLinkBuilder;
85    }
86
87    /**
88     * Given a target and the IP address being used to make the request, get an existing
89     * GlobalBlock object that applies to the target or IP address being used. If no
90     * block exists, then this method returns null.
91     *
92     * @param User $user Filter for GlobalBlock objects that target this user or IP address
93     * @param string|null $ip The IP address being used by the user, used to apply global blocks
94     *     on IPs or IP ranges that are not anon-only. Specifying null when $user is an IP address
95     *     and is not the session user will cause the value to be autogenerated.
96     * @return GlobalBlock|null The GlobalBlock that applies to the given user or IP, or null if no block applies.
97     */
98    public function getUserBlock( User $user, ?string $ip ) {
99        $details = $this->getUserBlockDetails( $user, $ip );
100
101        if ( $details['block'] ) {
102            $row = $details['block'];
103            return new GlobalBlock(
104                $row,
105                [
106                    'address' => $row->gb_address,
107                    'reason' => $row->gb_reason,
108                    'timestamp' => $row->gb_timestamp,
109                    'anonOnly' => $row->gb_anon_only,
110                    'expiry' => $row->gb_expiry,
111                    'xff' => $details['xff'] ?? false,
112                ]
113            );
114        }
115
116        return null;
117    }
118
119    /**
120     * Given a target and the IP address being used to make the request, get the human
121     * readable error message(s) describing the GlobalBlock that applies to the user. If
122     * no GlobalBlock exists, then this returns an empty array.
123     *
124     * @param User $user Filter for GlobalBlock objects that target this user or IP address
125     * @param string|null $ip The IP address being used by the user, used to apply global blocks
126     *    on IPs or IP ranges that are not anon-only. Specifying null when $user is an IP address
127     *    and is not the session user will cause the value to be autogenerated.
128     * @return Message[] empty or message objects
129     * @deprecated Since 1.42. Use ::getUserBlock instead to get the block and then use BlockErrorFormatter to get
130     *    a human readable error message.
131     */
132    public function getUserBlockErrors( User $user, ?string $ip ): array {
133        $details = $this->getUserBlockDetails( $user, $ip );
134        return $details['error'];
135    }
136
137    /**
138     * Add the $result to the instance cache under the username of the given $user.
139     *
140     * @param array $result
141     * @param UserIdentity $user
142     * @return array The value of $result
143     */
144    private function addToUserBlockDetailsCache( array $result, UserIdentity $user ) {
145        $this->getUserBlockDetailsCache[$user->getName()] = $result;
146        return $result;
147    }
148
149    /**
150     * Get the cached result of ::getUserBlockDetails for the given user.
151     *
152     * @param UserIdentity $userIdentity
153     * @return array|null Array if the result is cached, null if no cache exists
154     */
155    protected function getUserBlockDetailsCacheResult( UserIdentity $userIdentity ): ?array {
156        return $this->getUserBlockDetailsCache[$userIdentity->getName()] ?? null;
157    }
158
159    /**
160     * Given a target and the IP address being used to make the request, get the
161     * most specific block that applies along with a human readable error message
162     * associated with the block. If no block exists, this returns an array with
163     * no block and an empty array of error messages.
164     *
165     * @param User $user See ::getUserBlock. Note this may not be the session user.
166     * @param string|null $ip See ::getUserBlock.
167     * @return array ['block' => DB row or null, 'error' => empty or message objects]. The
168     *    error message key is deprecated since 1.42.
169     * @phan-return array{block:stdClass|null,error:Message[]}
170     */
171    private function getUserBlockDetails( User $user, ?string $ip ) {
172        // Check first if the instance cache has the result.
173        $cachedResult = $this->getUserBlockDetailsCacheResult( $user );
174        if ( $cachedResult !== null ) {
175            return $cachedResult;
176        }
177
178        $this->statsdFactory->increment( 'global_blocking.get_user_block' );
179
180        // We have callers from different code paths which may leave $ip as null when providing an
181        // IP address as the $user where the IP address is not the session user. In this case, populate
182        // the $ip argument with the IP provided in $user to get all the blocks that apply to the IP.
183        $context = RequestContext::getMain();
184        $isSessionUser = $user->equals( $context->getUser() );
185        if ( $ip === null && !$isSessionUser && IPUtils::isIPAddress( $user->getName() ) ) {
186            // Populate the IP for checking blocks against non-session users.
187            $ip = $user->getName();
188        }
189
190        $flags = 0;
191        if ( $user->isAllowedAny( 'ipblock-exempt', 'globalblock-exempt' ) ) {
192            // User is exempt from IP blocks.
193            $flags |= self::SKIP_IP_BLOCKS;
194        }
195        if ( $user->isNamed() ) {
196            // User is a named account, so skip anon-only (soft) IP blocks.
197            $flags |= self::SKIP_SOFT_IP_BLOCKS;
198        }
199
200        $centralId = 0;
201        if ( $this->options->get( 'GlobalBlockingAllowGlobalAccountBlocks' ) && $user->isRegistered() ) {
202            $centralId = $this->centralIdLookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW );
203        }
204
205        $this->statsdFactory->increment( 'global_blocking.get_user_block_db_query' );
206
207        $lang = $context->getLanguage();
208        $block = $this->getGlobalBlockingBlock( $ip, $centralId, $flags );
209        if ( $block ) {
210            // The following code in this if block, except the returning of $block, is deprecated since 1.42 (T358776).
211            $blockTimestamp = $lang->timeanddate( wfTimestamp( TS_MW, $block->gb_timestamp ), true );
212            $blockExpiry = $lang->formatExpiry( $block->gb_expiry );
213            $display_wiki = WikiMap::getWikiName( $block->gb_by_wiki );
214            $blockingUser = $this->globalBlockingLinkBuilder->maybeLinkUserpage(
215                $block->gb_by_wiki,
216                $this->centralIdLookup->nameFromCentralId( $block->gb_by_central_id ) ?? ''
217            );
218
219            // The following Hooks are deprecated and the message it generates is not used anywhere.
220            // The hooks will be removed in the future through T358776.
221            // Allow site customization of blocked message.
222            if ( IPUtils::isValid( $block->gb_address ) ) {
223                $errorMsg = 'globalblocking-ipblocked';
224                $this->hookRunner->onGlobalBlockingBlockedIpMsg( $errorMsg );
225            } elseif ( IPUtils::isValidRange( $block->gb_address ) ) {
226                $errorMsg = 'globalblocking-ipblocked-range';
227                $this->hookRunner->onGlobalBlockingBlockedIpRangeMsg( $errorMsg );
228            } else {
229                $errorMsg = false;
230            }
231            $blockDetails = [
232                'block' => $block,
233                'error' => [],
234            ];
235            if ( $errorMsg ) {
236                $blockDetails['error'][] = wfMessage(
237                    $errorMsg,
238                    $blockingUser,
239                    $display_wiki,
240                    $this->globalBlockReasonFormatter->format( $block->gb_reason, $this->contentLanguage->getCode() ),
241                    $blockTimestamp,
242                    $blockExpiry,
243                    $ip,
244                    $block->gb_address
245                );
246            }
247
248            return $this->addToUserBlockDetailsCache( $blockDetails, $user );
249        }
250
251        $request = $context->getRequest();
252        // Checking non-session users are not applicable to the XFF block.
253        if ( $this->options->get( 'GlobalBlockingBlockXFF' ) && $isSessionUser ) {
254            $xffIps = $request->getHeader( 'X-Forwarded-For' );
255            if ( $xffIps ) {
256                $xffIps = array_map( 'trim', explode( ',', $xffIps ) );
257                // Always skip the allowed ranges check when checking the XFF IPs as the value of this header
258                // is easy to spoof.
259                $xffFlags = $flags | self::SKIP_ALLOWED_RANGES_CHECK;
260                $appliedBlock = $this->getAppliedBlock(
261                    $xffIps, $this->checkIpsForBlock( $xffIps, $xffFlags )
262                );
263                if ( $appliedBlock !== null ) {
264                    // The following code in this if block, except the returning of $block, is deprecated since 1.42.
265                    [ $blockIP, $block ] = $appliedBlock;
266                    $blockTimestamp = $lang->timeanddate(
267                        wfTimestamp( TS_MW, $block->gb_timestamp ),
268                        true
269                    );
270                    $blockExpiry = $lang->formatExpiry( $block->gb_expiry );
271                    $display_wiki = WikiMap::getWikiName( $block->gb_by_wiki );
272                    $blockingUser = $this->globalBlockingLinkBuilder->maybeLinkUserpage(
273                        $block->gb_by_wiki,
274                        $this->centralIdLookup->nameFromCentralId( $block->gb_by_central_id ) ?? ''
275                    );
276                    // Allow site customization of blocked message.
277                    $blockedIpXffMsg = 'globalblocking-ipblocked-xff';
278                    $this->hookRunner->onGlobalBlockingBlockedIpXffMsg( $blockedIpXffMsg );
279                    return $this->addToUserBlockDetailsCache( [
280                        'block' => $block,
281                        'error' => [
282                            wfMessage(
283                                $blockedIpXffMsg,
284                                $blockingUser,
285                                $display_wiki,
286                                $block->gb_reason,
287                                $blockTimestamp,
288                                $blockExpiry,
289                                $blockIP
290                            )
291                        ],
292                        'xff' => true,
293                    ], $user );
294                }
295            }
296        }
297
298        return $this->addToUserBlockDetailsCache( [ 'block' => null, 'error' => [] ], $user );
299    }
300
301    /**
302     * Returns the ::TYPE_* constant for the given target.
303     *
304     * @param string $target
305     * @return int
306     */
307    private function getTargetType( string $target ) {
308        if ( IPUtils::isValid( $target ) ) {
309            return self::TYPE_IP;
310        } elseif ( IPUtils::isValidRange( $target ) ) {
311            return self::TYPE_RANGE;
312        } else {
313            return self::TYPE_USER;
314        }
315    }
316
317    /**
318     * Choose the most specific block from some combination of user, IP and IP range
319     * blocks. Decreasing order of specificity: IP > narrower IP range > wider IP
320     * range. A range that encompasses one IP address is ranked equally to a single IP.
321     *
322     * Note that DatabaseBlock::chooseBlocks chooses blocks in a different way.
323     *
324     * This is based on DatabaseBlock::chooseMostSpecificBlock
325     *
326     * @param IResultWrapper $blocks These should not include autoblocks or ID blocks
327     * @param int $flags The $flags provided. This method only checks for BLOCK_FLAG_SKIP_LOCAL_DISABLE_CHECK,
328     *   and callers are in charge of checking for other relevant flags.
329     * @return stdClass|null The block with the most specific target
330     */
331    private function chooseMostSpecificBlock( IResultWrapper $blocks, int $flags ): ?stdClass {
332        // This result could contain a block on the user, a block on the IP, and a russian-doll
333        // set of rangeblocks.  We want to choose the most specific one, so keep a leader board.
334        $bestBlock = null;
335
336        // Lower will be better
337        $bestBlockScore = 100;
338        foreach ( $blocks as $block ) {
339            // Check for local whitelisting, unless the flag is set to skip the check.
340            if (
341                !( $flags & self::SKIP_LOCAL_DISABLE_CHECK ) &&
342                $this->globalBlockLocalStatusLookup->getLocalWhitelistInfo( $block->gb_id )
343            ) {
344                continue;
345            }
346            $target = $block->gb_address;
347            $type = $this->getTargetType( $target );
348            if ( $type == self::TYPE_RANGE ) {
349                // This is the number of bits that are allowed to vary in the block, give
350                // or take some floating point errors
351                $max = IPUtils::isIPv6( $target ) ? 128 : 32;
352                [ $network, $bits ] = IPUtils::parseCIDR( $target );
353                $size = $max - $bits;
354
355                // Rank a range block covering a single IP equally with a single-IP block
356                $score = self::TYPE_RANGE - 1 + ( $size / $max );
357            } else {
358                $score = $type;
359            }
360
361            if ( $score < $bestBlockScore ) {
362                $bestBlockScore = $score;
363                $bestBlock = $block;
364            }
365        }
366
367        return $bestBlock;
368    }
369
370    /**
371     * Get the most specific row from the `globalblocks` table that applies to the given IP address
372     * or the central user.
373     *
374     * This does not check if the user is exempt from IP blocks. As such it should not be used to determine
375     * if a block should be applied to a user. Use ::getUserBlock for that.
376     *
377     * @param string|null $ip The IP address used by the user. If null, then no IP blocks will be checked.
378     * @param int $centralId The central ID of the user. 0 if the user is anonymous. Setting this as
379     *   a boolean is soft deprecated and will be treated as 0.
380     * @param int $flags Flags to control the behavior of the block lookup
381     * @return stdClass|null The most specific row from the `globalblocks` table, or null if no row was found
382     */
383    public function getGlobalBlockingBlock( ?string $ip, int $centralId, int $flags = 0 ): ?stdClass {
384        $conds = $this->getGlobalBlockLookupConditions( $ip, $centralId, $flags );
385        if ( $conds === null ) {
386            // No conditions, so don't perform the query and assume the user is not targeted by any block
387            return null;
388        }
389
390        $blocks = $this->globalBlockingConnectionProvider
391            ->getReplicaGlobalBlockingDatabase()
392            ->newSelectQueryBuilder()
393            ->select( self::selectFields() )
394            ->from( 'globalblocks' )
395            ->where( $conds )
396            ->caller( __METHOD__ )
397            ->fetchResultSet();
398
399        // Get the most specific block for the global blocks that apply to the user.
400        return $this->chooseMostSpecificBlock( $blocks, $flags );
401    }
402
403    /**
404     * Get the SQL WHERE conditions that allow looking up all blocks from the
405     * `globalblocks` table that apply to the given IP address or range.
406     *
407     * @param string $ip The IP address or range
408     * @deprecated Since 1.42. Use ::getGlobalBlockLookupConditions.
409     * @return IExpression
410     */
411    public function getRangeCondition( string $ip ): IExpression {
412        // This method does not return null if an IP is provided and the allowed ranges check is skipped.
413        // @phan-suppress-next-line PhanTypeMismatchReturnNullable
414        return $this->getGlobalBlockLookupConditions( $ip, 0, self::SKIP_ALLOWED_RANGES_CHECK );
415    }
416
417    /**
418     * Get the SQL WHERE conditions that allow looking up all blocks from the `globalblocks` table.
419     *
420     * @param ?string $ip The IP address or range. If null, then no IP blocks will be checked.
421     * @param int $centralId The central ID of the user. 0 if the user is anonymous and 0 will skip
422     *   checking user specific blocks.
423     * @param int $flags Flags which control what conditions are returned. Ignores the
424     *   ::BLOCK_FLAG_SKIP_LOCAL_DISABLE_CHECK flag and callers are expected to check if the block is
425     *   locally disabled if this is needed.
426     * @return IExpression|null The conditions to be used in a SQL query to look up global blocks, or null if no valid
427     *   conditions could be generated.
428     */
429    public function getGlobalBlockLookupConditions( ?string $ip, int $centralId = 0, int $flags = 0 ): ?IExpression {
430        $dbr = $this->globalBlockingConnectionProvider->getReplicaGlobalBlockingDatabase();
431        $ipExpr = null;
432        $userExpr = null;
433
434        if ( $ip !== null && !IPUtils::isIPAddress( $ip ) ) {
435            // The provided IP is invalid, so throw.
436            throw new InvalidArgumentException(
437                "Invalid IP address or range provided to GlobalBlockLookup::getGlobalBlockLookupConditions."
438            );
439        }
440
441        if ( $ip !== null && !( $flags & self::SKIP_ALLOWED_RANGES_CHECK ) ) {
442            $ranges = $this->options->get( 'GlobalBlockingAllowedRanges' );
443            foreach ( $ranges as $range ) {
444                if ( IPUtils::isInRange( $ip, $range ) ) {
445                    // IP is in a range that is exempt from IP blocks, so treat the user as having
446                    // global IP block exemption for this specific IP address
447                    $flags |= self::SKIP_IP_BLOCKS;
448                    break;
449                }
450            }
451        }
452
453        if ( $ip !== null && !( $flags & self::SKIP_IP_BLOCKS ) ) {
454            // If we have been provided an IP address or range in $ip, then
455            // add conditions to the query to lookup blocks that apply to the IP address / range.
456            [ $start, $end ] = IPUtils::parseRange( $ip );
457            $chunk = $this->getIpFragment( $start );
458            $ipExpr = $dbr->expr( 'gb_range_start', IExpression::LIKE, new LikeValue( $chunk, $dbr->anyString() ) )
459                ->and( 'gb_range_start', '<=', $start )
460                ->and( 'gb_range_end', '>=', $end );
461
462            if ( $flags & self::SKIP_SOFT_IP_BLOCKS ) {
463                // If the flags say to skip soft IP blocks, then exclude blocks with gb_anon_only
464                // set to 1 (which should only be soft blocks on IP addresses or ranges).
465                $ipExpr = $ipExpr->and( 'gb_anon_only', '!=', 1 );
466            }
467        }
468
469        if ( $centralId !== 0 ) {
470            // If we have been provided a non-zero central ID, then also look for blocks that target the
471            // given central ID.
472            $userExpr = $dbr->expr( 'gb_target_central_id', '=', $centralId );
473        }
474
475        // Combine the IP conditions and user IExpressions
476        if ( $userExpr !== null && $ipExpr !== null ) {
477            // If we have conditions for both the IP and the user, then combine them with an OR
478            // to allow selecting blocks that apply to either the IP or the user.
479            $targetExpr = $userExpr->orExpr( $ipExpr );
480        } elseif ( $userExpr !== null ) {
481            // If we only have conditions for the user, then use that IExpression.
482            $targetExpr = $userExpr;
483        } elseif ( $ipExpr !== null ) {
484            // If we only have conditions for the IP, then use that IExpression.
485            $targetExpr = $ipExpr;
486        } else {
487            // No conditions, so don't perform the query otherwise we will select all blocks from the DB.
488            // In this case, we can assume the user or their IP is not affected by any global block.
489            return null;
490        }
491        // @todo expiry shouldn't be in this function
492        return $dbr->expr( 'gb_expiry', '>', $dbr->timestamp() )
493            ->andExpr( $targetExpr );
494    }
495
496    /**
497     * Get the component of an IP address which is certain to be the same between an IP
498     * address and a range block containing that IP address.
499     *
500     * This mostly duplicates the logic in DatabaseStoreBlock::getIpFragment, but with the
501     * CIDR limit config being the GlobalBlocking extension specific one.
502     *
503     * @param string $hex Hexadecimal IP representation
504     * @return string
505     */
506    private function getIpFragment( string $hex ): string {
507        $blockCIDRLimit = $this->options->get( 'GlobalBlockingCIDRLimit' );
508        if ( str_starts_with( $hex, 'v6-' ) ) {
509            return 'v6-' . substr( substr( $hex, 3 ), 0, (int)floor( $blockCIDRLimit['IPv6'] / 4 ) );
510        } else {
511            return substr( $hex, 0, (int)floor( $blockCIDRLimit['IPv4'] / 4 ) );
512        }
513    }
514
515    /**
516     * Find all rows from the `globalblocks` table that target at least one of
517     * the given IP addresses.
518     *
519     * This method filters out blocks that are locally disabled, but does not
520     * check whether the given session user can be exempt from the block.
521     *
522     * @param string[] $ips The array of IP addresses to be checked
523     * @param int $flags Flags which control what blocks are returned.
524     * @return stdClass[] Array of applicable blocks as rows from the `globalblocks` table
525     */
526    private function checkIpsForBlock( array $ips, int $flags = 0 ): array {
527        if ( $flags & self::SKIP_IP_BLOCKS ) {
528            // If the flags say to skip IP blocks, then don't even make the query.
529            return [];
530        }
531
532        $dbr = $this->globalBlockingConnectionProvider->getReplicaGlobalBlockingDatabase();
533        $conds = [];
534        foreach ( $ips as $ip ) {
535            if ( IPUtils::isValid( $ip ) ) {
536                $ipConds = $this->getGlobalBlockLookupConditions( $ip, 0, $flags );
537                if ( $ipConds !== null ) {
538                    $conds[] = $ipConds;
539                }
540            }
541        }
542
543        if ( !$conds ) {
544            // No valid IPs provided so don't even make the query. Bug 59705
545            return [];
546        }
547        $results = $dbr->newSelectQueryBuilder()
548            ->select( self::selectFields() )
549            ->from( 'globalblocks' )
550            ->where( new OrExpressionGroup( ...$conds ) )
551            ->caller( __METHOD__ )
552            ->fetchResultSet();
553
554        $blocks = [];
555        foreach ( $results as $block ) {
556            if (
557                ( $flags & self::SKIP_LOCAL_DISABLE_CHECK ) ||
558                !$this->globalBlockLocalStatusLookup->getLocalWhitelistInfo( $block->gb_id )
559            ) {
560                $blocks[] = $block;
561            }
562        }
563
564        return $blocks;
565    }
566
567    /**
568     * Using the result of ::checkIpsForBlock and the IPs provided to that method,
569     * choose the block that will be shown to the end user.
570     *
571     * For the time being, this will be the first block that applies.
572     *
573     * @param string[] $ips The array of IP addresses to be checked
574     * @param \stdClass[] $blocks The array returned by ::checkIpsForBlock
575     * @return array|null An array where the first element is the IP address
576     *   that the block applies to, and the second element is the block itself.
577     *   If no block applies, then this method returns null.
578     * @phan-return array{string,stdClass}|null
579     */
580    private function getAppliedBlock( array $ips, array $blocks ): ?array {
581        foreach ( $blocks as $block ) {
582            foreach ( $ips as $ip ) {
583                $ipHex = IPUtils::toHex( $ip );
584                if ( $block->gb_range_start <= $ipHex && $block->gb_range_end >= $ipHex ) {
585                    return [ $ip, $block ];
586                }
587            }
588        }
589
590        return null;
591    }
592
593    /**
594     * Given a specific target, find the ID for the global block that applies to it.
595     * If no global block targets this IP address specifically, then this method
596     * returns 0.
597     *
598     * @param string $target The specific target which can be a username, IP address or range. The target being
599     *   specific means that if you provide a single IP which is covered by a range block, the range block will
600     *   not be returned. Use ::getGlobalBlockingBlock to include these blocks.
601     * @param int $dbtype Either DB_REPLICA or DB_PRIMARY.
602     * @return int
603     */
604    public function getGlobalBlockId( string $target, int $dbtype = DB_REPLICA ): int {
605        if ( $dbtype === DB_PRIMARY ) {
606            $db = $this->globalBlockingConnectionProvider->getPrimaryGlobalBlockingDatabase();
607        } else {
608            $db = $this->globalBlockingConnectionProvider->getReplicaGlobalBlockingDatabase();
609        }
610
611        $queryBuilder = $db->newSelectQueryBuilder()
612            ->select( 'gb_id' )
613            ->from( 'globalblocks' );
614
615        if ( IPUtils::isIPAddress( $target ) ) {
616            $queryBuilder->where( [ 'gb_address' => $target ] );
617        } elseif ( $this->options->get( 'GlobalBlockingAllowGlobalAccountBlocks' ) ) {
618            $centralId = $this->centralIdLookup->centralIdFromName( $target, CentralIdLookup::AUDIENCE_RAW );
619            if ( !$centralId ) {
620                // If we are looking up a block by a central ID of a user, then the user must have a central ID
621                // for a block to apply to them.
622                return 0;
623            }
624            $queryBuilder->where( [ 'gb_target_central_id' => $centralId ] );
625        } else {
626            return 0;
627        }
628
629        return (int)$queryBuilder
630            ->caller( __METHOD__ )
631            ->fetchField();
632    }
633
634    /**
635     * @return string[] The fields needed to construct a GlobalBlock object
636     */
637    public static function selectFields(): array {
638        return [
639            'gb_id', 'gb_address', 'gb_target_central_id', 'gb_by', 'gb_by_central_id', 'gb_by_wiki', 'gb_reason',
640            'gb_timestamp', 'gb_anon_only', 'gb_expiry', 'gb_range_start', 'gb_range_end'
641        ];
642    }
643}