Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
133 / 133 |
|
100.00% |
5 / 5 |
CRAP | |
100.00% |
1 / 1 |
GlobalBlockManager | |
100.00% |
133 / 133 |
|
100.00% |
5 / 5 |
29 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
insertBlock | |
100.00% |
51 / 51 |
|
100.00% |
1 / 1 |
12 | |||
block | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
4 | |||
unblock | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
4 | |||
validateInput | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
8 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\GlobalBlocking\Services; |
4 | |
5 | use ManualLogEntry; |
6 | use MediaWiki\Block\BlockUser; |
7 | use MediaWiki\Config\ServiceOptions; |
8 | use MediaWiki\Title\Title; |
9 | use MediaWiki\User\CentralId\CentralIdLookup; |
10 | use MediaWiki\User\UserFactory; |
11 | use MediaWiki\User\UserIdentity; |
12 | use MediaWiki\WikiMap\WikiMap; |
13 | use StatusValue; |
14 | use Wikimedia\IPUtils; |
15 | |
16 | /** |
17 | * A service for creating, updating, and removing global blocks. |
18 | * |
19 | * @since 1.42 |
20 | */ |
21 | class 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 | } |