Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
133 / 133
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
GlobalBlockManager
100.00% covered (success)
100.00%
133 / 133
100.00% covered (success)
100.00%
5 / 5
29
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 insertBlock
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
12
 block
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 unblock
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 validateInput
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3namespace MediaWiki\Extension\GlobalBlocking\Services;
4
5use ManualLogEntry;
6use MediaWiki\Block\BlockUser;
7use MediaWiki\Config\ServiceOptions;
8use MediaWiki\Title\Title;
9use MediaWiki\User\CentralId\CentralIdLookup;
10use MediaWiki\User\UserFactory;
11use MediaWiki\User\UserIdentity;
12use MediaWiki\WikiMap\WikiMap;
13use StatusValue;
14use Wikimedia\IPUtils;
15
16/**
17 * A service for creating, updating, and removing global blocks.
18 *
19 * @since 1.42
20 */
21class GlobalBlockManager {
22
23    public const CONSTRUCTOR_OPTIONS = [
24        'GlobalBlockingCIDRLimit',
25        'GlobalBlockingAllowGlobalAccountBlocks',
26    ];
27
28    private ServiceOptions $options;
29    private GlobalBlockingBlockPurger $globalBlockingBlockPurger;
30    private GlobalBlockLookup $globalBlockLookup;
31    private GlobalBlockingConnectionProvider $globalBlockingConnectionProvider;
32    private CentralIdLookup $centralIdLookup;
33    private UserFactory $userFactory;
34
35    public function __construct(
36        ServiceOptions $options,
37        GlobalBlockingBlockPurger $globalBlockingBlockPurger,
38        GlobalBlockLookup $globalBlockLookup,
39        GlobalBlockingConnectionProvider $globalBlockingConnectionProvider,
40        CentralIdLookup $centralIdLookup,
41        UserFactory $userFactory
42    ) {
43        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
44        $this->options = $options;
45        $this->globalBlockingBlockPurger = $globalBlockingBlockPurger;
46        $this->globalBlockLookup = $globalBlockLookup;
47        $this->globalBlockingConnectionProvider = $globalBlockingConnectionProvider;
48        $this->centralIdLookup = $centralIdLookup;
49        $this->userFactory = $userFactory;
50    }
51
52    /**
53     * @param string $target See ::block for details.
54     * @param string $reason See ::block for details.
55     * @param string|false $expiry See ::block for details.
56     * @param UserIdentity $blocker See ::block for details.
57     * @param array $options See ::block for details.
58     * @return StatusValue
59     * @internal Use ::block instead. This is public to allow the deprecated static method in
60     *   GlobalBlocking to call this method. This will be made private once the deprecated method
61     *   is removed.
62     */
63    public function insertBlock(
64        string $target, string $reason, $expiry, UserIdentity $blocker, array $options = []
65    ): StatusValue {
66        // As we are inserting a block and therefore will be using a primary DB connection,
67        // we can purge expired blocks from the primary DB.
68        $this->globalBlockingBlockPurger->purgeExpiredBlocks();
69
70        if ( $expiry === false ) {
71            return StatusValue::newFatal( 'globalblocking-block-expiryinvalid' );
72        }
73
74        $status = $this->validateInput( $target, $blocker );
75
76        if ( !$status->isOK() ) {
77            return $status;
78        }
79
80        $data = $status->getValue();
81
82        $modify = in_array( 'modify', $options );
83        $anonOnly = in_array( 'anon-only', $options );
84
85        if ( $anonOnly && !IPUtils::isIPAddress( $target ) ) {
86            // Anon-only blocks on an account does not make any sense, so reject them.
87            return StatusValue::newFatal( 'globalblocking-block-anononly-on-account', $target );
88        }
89
90        // Check for an existing block in the primary database database
91        $existingBlock = $this->globalBlockLookup->getGlobalBlockId( $data[ 'target' ], DB_PRIMARY );
92        if ( !$modify && $existingBlock ) {
93            return StatusValue::newFatal( 'globalblocking-block-alreadyblocked', $data[ 'target' ] );
94        }
95
96        // At this point, we have validated that a block can be inserted or updated.
97
98        $dbw = $this->globalBlockingConnectionProvider->getPrimaryGlobalBlockingDatabase();
99        $row = [
100            'gb_address' => $data['target'],
101            'gb_target_central_id' => $data['centralId'],
102            'gb_by' => $blocker->getName(),
103            'gb_by_central_id' => $this->centralIdLookup->centralIdFromLocalUser( $blocker ),
104            'gb_by_wiki' => WikiMap::getCurrentWikiId(),
105            'gb_reason' => $reason,
106            'gb_timestamp' => $dbw->timestamp( wfTimestampNow() ),
107            'gb_anon_only' => $anonOnly,
108            'gb_expiry' => $dbw->encodeExpiry( $expiry ),
109            'gb_range_start' => $data['rangeStart'],
110            'gb_range_end' => $data['rangeEnd'],
111        ];
112
113        $blockId = 0;
114        if ( $modify && $existingBlock ) {
115            $dbw->newUpdateQueryBuilder()
116                ->update( 'globalblocks' )
117                ->set( $row )
118                ->where( [ 'gb_id' => $existingBlock ] )
119                ->caller( __METHOD__ )
120                ->execute();
121            if ( $dbw->affectedRows() ) {
122                $blockId = $existingBlock;
123            }
124        } else {
125            $dbw->newInsertQueryBuilder()
126                ->insertInto( 'globalblocks' )
127                ->ignore()
128                ->row( $row )
129                ->caller( __METHOD__ )
130                ->execute();
131            if ( $dbw->affectedRows() ) {
132                $blockId = $dbw->insertId();
133            }
134        }
135
136        if ( !$blockId ) {
137            // Race condition?
138            return StatusValue::newFatal( 'globalblocking-block-failure', $data[ 'target' ] );
139        }
140
141        return StatusValue::newGood( [
142            'id' => $blockId,
143        ] );
144    }
145
146    /**
147     * Block an IP address or range.
148     *
149     * @param string $target The IP address, IP range, or username to block
150     * @param string $reason The public reason to be shown in the global block log,
151     *   on the global block list, and potentially to the blocked user when they try to edit.
152     * @param string $expiry Any expiry that can be parsed by BlockUser::parseExpiryInput, including infinite.
153     * @param UserIdentity $blocker The user performing the block. The caller of this method is
154     *    responsible for determining if the performer has the necessary rights to perform the block.
155     * @param array $options An array of options provided as values with numeric keys. This accepts:
156     *   - 'anon-only': If set, only anonymous users will be affected by the block
157     *   - 'modify': If set, the block will be modified if it already exists. If not set,
158     *       the block will fail if it already exists.
159     * @return StatusValue A status object, with errors if the block failed.
160     */
161    public function block(
162        string $target, string $reason, string $expiry, UserIdentity $blocker, array $options = []
163    ): StatusValue {
164        $expiry = BlockUser::parseExpiryInput( $expiry );
165        $status = $this->insertBlock( $target, $reason, $expiry, $blocker, $options );
166
167        if ( !$status->isOK() ) {
168            return $status;
169        }
170
171        $blockId = $status->getValue()['id'];
172        $anonOnly = in_array( 'anon-only', $options );
173        $modify = in_array( 'modify', $options );
174
175        // Log it.
176        $logAction = $modify ? 'modify' : 'gblock';
177
178        $logEntry = new ManualLogEntry( 'gblblock', $logAction );
179        $logEntry->setPerformer( $blocker );
180        $logEntry->setTarget( Title::makeTitleSafe( NS_USER, $target ) );
181        $logEntry->setComment( $reason );
182
183        $flags = [];
184        if ( $anonOnly ) {
185            $flags[] = 'anon-only';
186        }
187
188        // The 4th parameter is the target as plaintext used for GENDER support and is added by the log formatter.
189        $logEntry->setParameters( [
190            '5::expiry' => $expiry,
191            // List of flags which are then converted to a comma separated localised list by the log formatter
192            '6::flags' => $flags,
193        ] );
194        $logEntry->setRelations( [ 'gb_id' => $blockId ] );
195        $logId = $logEntry->insert();
196        $logEntry->publish( $logId );
197
198        return $status;
199    }
200
201    /**
202     * Remove a global block from a given IP address or range.
203     *
204     * @param string $target The target of the block to be removed, which can be an IP address, IP range, or username.
205     * @param string $reason The reason for removing the block which will be shown publicly in a log entry
206     *   for the unblock.
207     * @param UserIdentity $performer The user who is performing the unblock. The caller of this method is
208     *   responsible for determining if the performer has the necessary rights to perform the unblock.
209     * @return StatusValue An empty or fatal status
210     */
211    public function unblock( string $target, string $reason, UserIdentity $performer ): StatusValue {
212        $status = $this->validateInput( $target, $performer );
213
214        if ( !$status->isOK() ) {
215            return $status;
216        }
217
218        $data = $status->getValue();
219
220        $id = $this->globalBlockLookup->getGlobalBlockId( $data[ 'target' ], DB_PRIMARY );
221        if ( $id === 0 ) {
222            if ( $this->options->get( 'GlobalBlockingAllowGlobalAccountBlocks' ) ) {
223                $errorMessageKey = 'globalblocking-notblocked-new';
224            } else {
225                $errorMessageKey = 'globalblocking-notblocked';
226            }
227            return StatusValue::newFatal( $errorMessageKey, $data['target'] );
228        }
229
230        $this->globalBlockingConnectionProvider->getPrimaryGlobalBlockingDatabase()
231            ->newDeleteQueryBuilder()
232            ->deleteFrom( 'globalblocks' )
233            ->where( [ 'gb_id' => $id ] )
234            ->caller( __METHOD__ )
235            ->execute();
236
237        $logEntry = new ManualLogEntry( 'gblblock', 'gunblock' );
238        $logEntry->setPerformer( $performer );
239        $logEntry->setTarget( Title::makeTitleSafe( NS_USER, $data['target'] ) );
240        $logEntry->setComment( $reason );
241        $logEntry->setRelations( [ 'gb_id' => $id ] );
242        $logId = $logEntry->insert();
243        $logEntry->publish( $logId );
244
245        return StatusValue::newGood();
246    }
247
248    /**
249     * Validates that:
250     * * If the target is an IP address range, it does not exceed range limits.
251     * * If the target is a user, the username has a valid central ID.
252     *
253     * @param string $target An IP address, IP range, or a username
254     * @return StatusValue Fatal if errors, Good if no errors
255     */
256    private function validateInput( string $target, UserIdentity $performer ): StatusValue {
257        if ( !IPUtils::isIPAddress( $target ) ) {
258            if ( $this->options->get( 'GlobalBlockingAllowGlobalAccountBlocks' ) ) {
259                $centralIdForTarget = $this->centralIdLookup->centralIdFromName(
260                    $target,
261                    $this->userFactory->newFromUserIdentity( $performer )
262                );
263                if ( $centralIdForTarget === 0 ) {
264                    return StatusValue::newFatal( 'globalblocking-block-target-invalid', $target );
265                }
266                return StatusValue::newGood( [
267                    'target' => $target,
268                    'centralId' => $centralIdForTarget,
269                    // 'rangeStart' and 'rangeEnd' have to be strings and not null
270                    // due to the type of the DB columns.
271                    'rangeStart' => '',
272                    'rangeEnd' => '',
273                ] );
274            } else {
275                return StatusValue::newFatal( 'globalblocking-block-ipinvalid', $target );
276            }
277        }
278
279        // Begin validation only performed if the target is an IP address or range.
280        $target = IPUtils::sanitizeIP( $target );
281
282        // Validate that the IP address is not a range that is too large.
283        if ( IPUtils::isValidRange( $target ) ) {
284            [ $prefix, $range ] = explode( '/', $target, 2 );
285            $limit = $this->options->get( 'GlobalBlockingCIDRLimit' );
286            $ipVersion = IPUtils::isIPv4( $prefix ) ? 'IPv4' : 'IPv6';
287            if ( (int)$range < $limit[ $ipVersion ] ) {
288                return StatusValue::newFatal( 'globalblocking-bigrange', $target, $ipVersion, $limit[ $ipVersion ] );
289            }
290        }
291
292        // The IP address target is valid, so return the sanitized target along with
293        // the start and the end of the range in hexadecimal (for a single IP address
294        // this is hexadecimal representation of the single IP address).
295        $data = [ 'centralId' => 0 ];
296
297        [ $data[ 'rangeStart' ], $data[ 'rangeEnd' ] ] = IPUtils::parseRange( $target );
298
299        if ( $data[ 'rangeStart' ] !== $data[ 'rangeEnd' ] ) {
300            $data[ 'target' ] = IPUtils::sanitizeRange( $target );
301        } else {
302            $data[ 'target' ] = $target;
303        }
304
305        return StatusValue::newGood( $data );
306    }
307}