Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.73% |
215 / 220 |
|
73.33% |
11 / 15 |
CRAP | |
0.00% |
0 / 1 |
SpecialInvestigateBlock | |
97.73% |
215 / 220 |
|
73.33% |
11 / 15 |
38 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
userCanExecute | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
checkPermissions | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
getDisplayFormat | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getFormFields | |
100.00% |
100 / 100 |
|
100.00% |
1 / 1 |
4 | |||
showConfirmationCheckbox | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
checkForIPsAndUsersInTargetsParam | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getDescription | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMessagePrefix | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onSubmit | |
96.30% |
52 / 54 |
|
0.00% |
0 / 1 |
12 | |||
getTargetPage | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
addNoticeToPage | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
onSuccess | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\CheckUser\Investigate; |
4 | |
5 | use ApiMain; |
6 | use Exception; |
7 | use MediaWiki\Block\BlockPermissionCheckerFactory; |
8 | use MediaWiki\Block\BlockUserFactory; |
9 | use MediaWiki\CheckUser\Investigate\Utilities\EventLogger; |
10 | use MediaWiki\Linker\Linker; |
11 | use MediaWiki\MainConfigNames; |
12 | use MediaWiki\Permissions\PermissionManager; |
13 | use MediaWiki\Request\DerivativeRequest; |
14 | use MediaWiki\SpecialPage\FormSpecialPage; |
15 | use MediaWiki\Title\TitleFormatter; |
16 | use MediaWiki\Title\TitleValue; |
17 | use MediaWiki\User\User; |
18 | use MediaWiki\User\UserFactory; |
19 | use MediaWiki\User\UserNameUtils; |
20 | use PermissionsError; |
21 | use Wikimedia\IPUtils; |
22 | |
23 | class SpecialInvestigateBlock extends FormSpecialPage { |
24 | private BlockUserFactory $blockUserFactory; |
25 | private BlockPermissionCheckerFactory $blockPermissionCheckerFactory; |
26 | private PermissionManager $permissionManager; |
27 | private TitleFormatter $titleFormatter; |
28 | private UserFactory $userFactory; |
29 | private EventLogger $eventLogger; |
30 | |
31 | private array $blockedUsers = []; |
32 | |
33 | private bool $noticesFailed = false; |
34 | |
35 | /** |
36 | * @param BlockUserFactory $blockUserFactory |
37 | * @param BlockPermissionCheckerFactory $blockPermissionCheckerFactory |
38 | * @param PermissionManager $permissionManager |
39 | * @param TitleFormatter $titleFormatter |
40 | * @param UserFactory $userFactory |
41 | * @param EventLogger $eventLogger |
42 | */ |
43 | public function __construct( |
44 | BlockUserFactory $blockUserFactory, |
45 | BlockPermissionCheckerFactory $blockPermissionCheckerFactory, |
46 | PermissionManager $permissionManager, |
47 | TitleFormatter $titleFormatter, |
48 | UserFactory $userFactory, |
49 | EventLogger $eventLogger |
50 | ) { |
51 | parent::__construct( 'InvestigateBlock', 'checkuser' ); |
52 | |
53 | $this->blockUserFactory = $blockUserFactory; |
54 | $this->blockPermissionCheckerFactory = $blockPermissionCheckerFactory; |
55 | $this->permissionManager = $permissionManager; |
56 | $this->titleFormatter = $titleFormatter; |
57 | $this->userFactory = $userFactory; |
58 | $this->eventLogger = $eventLogger; |
59 | } |
60 | |
61 | /** |
62 | * @inheritDoc |
63 | */ |
64 | public function userCanExecute( User $user ) { |
65 | return parent::userCanExecute( $user ) && |
66 | $this->permissionManager->userHasRight( $user, 'block' ); |
67 | } |
68 | |
69 | /** |
70 | * @inheritDoc |
71 | */ |
72 | public function checkPermissions() { |
73 | $user = $this->getUser(); |
74 | if ( !parent::userCanExecute( $user ) ) { |
75 | $this->displayRestrictionError(); |
76 | } |
77 | |
78 | // User is a checkuser, but now to check for if they can block. |
79 | if ( !$this->permissionManager->userHasRight( $user, 'block' ) ) { |
80 | throw new PermissionsError( 'block' ); |
81 | } |
82 | } |
83 | |
84 | /** |
85 | * @inheritDoc |
86 | */ |
87 | protected function getDisplayFormat() { |
88 | return 'ooui'; |
89 | } |
90 | |
91 | /** |
92 | * @inheritDoc |
93 | */ |
94 | public function getFormFields() { |
95 | $this->getOutput()->addModules( [ |
96 | 'ext.checkUser' |
97 | ] ); |
98 | $this->getOutput()->addModuleStyles( [ |
99 | 'mediawiki.widgets.TagMultiselectWidget.styles', |
100 | 'ext.checkUser.styles', |
101 | ] ); |
102 | $this->getOutput()->enableOOUI(); |
103 | |
104 | $fields = []; |
105 | |
106 | $fields['Targets'] = [ |
107 | 'type' => 'usersmultiselect', |
108 | 'ipallowed' => true, |
109 | 'iprange' => true, |
110 | 'autofocus' => true, |
111 | 'required' => true, |
112 | 'exists' => true, |
113 | 'input' => [ |
114 | 'autocomplete' => false, |
115 | ], |
116 | // The following message key is generated: |
117 | // * checkuser-investigateblock-target |
118 | 'section' => 'target', |
119 | 'default' => '', |
120 | ]; |
121 | |
122 | if ( |
123 | $this->blockPermissionCheckerFactory |
124 | ->newBlockPermissionChecker( null, $this->getUser() ) |
125 | ->checkEmailPermissions() |
126 | ) { |
127 | $fields['DisableEmail'] = [ |
128 | 'type' => 'check', |
129 | 'label-message' => 'checkuser-investigateblock-email-label', |
130 | 'default' => false, |
131 | 'section' => 'actions', |
132 | ]; |
133 | } |
134 | |
135 | if ( $this->getConfig()->get( MainConfigNames::BlockAllowsUTEdit ) ) { |
136 | $fields['DisableUTEdit'] = [ |
137 | 'type' => 'check', |
138 | 'label-message' => 'checkuser-investigateblock-usertalk-label', |
139 | 'default' => false, |
140 | 'section' => 'actions', |
141 | ]; |
142 | } |
143 | |
144 | $fields['Reblock'] = [ |
145 | 'type' => 'check', |
146 | 'label-message' => 'checkuser-investigateblock-reblock-label', |
147 | 'default' => false, |
148 | // The following message key is generated: |
149 | // * checkuser-investigateblock-actions |
150 | 'section' => 'actions', |
151 | ]; |
152 | |
153 | $fields['Reason'] = [ |
154 | 'type' => 'selectandother', |
155 | 'options-message' => 'checkuser-block-reason-dropdown', |
156 | 'maxlength' => 150, |
157 | 'required' => true, |
158 | 'autocomplete' => false, |
159 | // The following message key is generated: |
160 | // * checkuser-investigateblock-reason |
161 | 'section' => 'reason', |
162 | ]; |
163 | |
164 | $pageNoticeClass = 'ext-checkuser-investigate-block-notice'; |
165 | $pageNoticePosition = [ |
166 | 'type' => 'select', |
167 | 'cssclass' => $pageNoticeClass, |
168 | 'label-message' => 'checkuser-investigateblock-notice-position-label', |
169 | 'options-messages' => [ |
170 | 'checkuser-investigateblock-notice-prepend' => 'prependtext', |
171 | 'checkuser-investigateblock-notice-replace' => 'text', |
172 | 'checkuser-investigateblock-notice-append' => 'appendtext', |
173 | ], |
174 | // The following message key is generated: |
175 | // * checkuser-investigateblock-options |
176 | 'section' => 'options', |
177 | ]; |
178 | $pageNoticeText = [ |
179 | 'type' => 'text', |
180 | 'cssclass' => $pageNoticeClass, |
181 | 'label-message' => 'checkuser-investigateblock-notice-text-label', |
182 | 'default' => '', |
183 | 'section' => 'options', |
184 | ]; |
185 | |
186 | $fields['UserPageNotice'] = [ |
187 | 'type' => 'check', |
188 | 'label-message' => 'checkuser-investigateblock-notice-user-page-label', |
189 | 'default' => false, |
190 | 'section' => 'options', |
191 | ]; |
192 | $fields['UserPageNoticePosition'] = array_merge( |
193 | $pageNoticePosition, |
194 | [ 'default' => 'prependtext' ] |
195 | ); |
196 | $fields['UserPageNoticeText'] = $pageNoticeText; |
197 | |
198 | $fields['TalkPageNotice'] = [ |
199 | 'type' => 'check', |
200 | 'label-message' => 'checkuser-investigateblock-notice-talk-page-label', |
201 | 'default' => false, |
202 | 'section' => 'options', |
203 | ]; |
204 | $fields['TalkPageNoticePosition'] = array_merge( |
205 | $pageNoticePosition, |
206 | [ 'default' => 'appendtext' ] |
207 | ); |
208 | $fields['TalkPageNoticeText'] = $pageNoticeText; |
209 | |
210 | $fields['Confirm'] = [ |
211 | 'type' => $this->showConfirmationCheckbox() ? 'check' : 'hidden', |
212 | 'default' => '', |
213 | 'label-message' => 'checkuser-investigateblock-confirm-blocks-label', |
214 | 'cssclass' => 'ext-checkuser-investigateblock-block-confirm', |
215 | ]; |
216 | |
217 | return $fields; |
218 | } |
219 | |
220 | /** |
221 | * Should the 'Confirm blocks' checkbox be shown? |
222 | * |
223 | * @return bool True if the form was submitted and the targets input has both IPs and users. Otherwise false. |
224 | */ |
225 | private function showConfirmationCheckbox(): bool { |
226 | // We cannot access HTMLForm->mWasSubmitted directly to work out if the form was submitted, as this has not |
227 | // been generated yet. However, we can approximate this by checking if the request was POSTed and if the |
228 | // wpEditToken is set. |
229 | return $this->getRequest()->wasPosted() && |
230 | $this->getRequest()->getVal( 'wpEditToken' ) && |
231 | $this->checkForIPsAndUsersInTargetsParam( $this->getRequest()->getText( 'wpTargets' ) ); |
232 | } |
233 | |
234 | /** |
235 | * Returns whether the 'Targets' parameter contains both IPs and usernames. |
236 | * |
237 | * @param string $targets The value of the 'Targets' parameter, either from the request via ::getText or (if in |
238 | * ::onSubmit) from the data array. |
239 | * @return bool True if the 'Targets' parameter contains both IPs and usernames, false otherwise. |
240 | */ |
241 | private function checkForIPsAndUsersInTargetsParam( string $targets ): bool { |
242 | // The 'usersmultiselect' field data is formatted by each username being seperated by a newline (\n). |
243 | $targets = explode( "\n", $targets ); |
244 | // Get an array of booleans indicating whether each target is an IP address. If the array contains both true and |
245 | // false, then the 'Targets' parameter contains both IPs and usernames. Otherwise it does not. |
246 | $areTargetsIPs = array_map( [ IPUtils::class, 'isIPAddress' ], $targets ); |
247 | return in_array( true, $areTargetsIPs, true ) && in_array( false, $areTargetsIPs, true ); |
248 | } |
249 | |
250 | /** |
251 | * @inheritDoc |
252 | */ |
253 | public function getDescription() { |
254 | return $this->msg( 'checkuser-investigateblock' ); |
255 | } |
256 | |
257 | /** |
258 | * @inheritDoc |
259 | */ |
260 | protected function getMessagePrefix() { |
261 | return 'checkuser-' . strtolower( $this->getName() ); |
262 | } |
263 | |
264 | /** |
265 | * @inheritDoc |
266 | */ |
267 | protected function getGroupName() { |
268 | return 'users'; |
269 | } |
270 | |
271 | /** |
272 | * @inheritDoc |
273 | */ |
274 | public function onSubmit( array $data ) { |
275 | $this->blockedUsers = []; |
276 | |
277 | // This might have been a hidden field or a checkbox, so interesting data can come from it. This handling is |
278 | // copied from SpecialBlock::processFormInternal. |
279 | $data['Confirm'] = !in_array( $data['Confirm'], [ '', '0', null, false ], true ); |
280 | |
281 | // If the targets are both IPs and usernames, we should warn the CheckUser before allowing them to proceed to |
282 | // avoid inadvertently violating any privacy policies. |
283 | if ( $this->checkForIPsAndUsersInTargetsParam( $data['Targets'] ) && !$data['Confirm'] ) { |
284 | return [ |
285 | 'checkuser-investigateblock-warning-ips-and-users-in-targets', |
286 | 'checkuser-investigateblock-warning-confirmaction' |
287 | ]; |
288 | } |
289 | |
290 | $targets = explode( "\n", $data['Targets'] ); |
291 | // Format of $data['Reason'] is an array with items as documented in |
292 | // HTMLSelectAndOtherField::loadDataFromRequest. The value in this should not be empty, as the field is marked |
293 | // as required and as such the validation will be done by HTMLForm. |
294 | $reason = $data['Reason'][0]; |
295 | |
296 | foreach ( $targets as $target ) { |
297 | $isIP = IPUtils::isIPAddress( $target ); |
298 | |
299 | if ( !$isIP ) { |
300 | $user = $this->userFactory->newFromName( $target ); |
301 | if ( !$user || !$user->getId() ) { |
302 | continue; |
303 | } |
304 | } |
305 | |
306 | $expiry = $isIP ? '1 week' : 'indefinite'; |
307 | |
308 | $status = $this->blockUserFactory->newBlockUser( |
309 | $target, |
310 | $this->getUser(), |
311 | $expiry, |
312 | $reason, |
313 | [ |
314 | 'isHardBlock' => !$isIP, |
315 | 'isCreateAccountBlocked' => true, |
316 | 'isAutoblocking' => true, |
317 | 'isEmailBlocked' => $data['DisableEmail'] ?? false, |
318 | 'isUserTalkEditBlocked' => $data['DisableUTEdit'] ?? false, |
319 | ] |
320 | )->placeBlock( $data['Reblock'] ); |
321 | |
322 | if ( $status->isOK() ) { |
323 | $this->blockedUsers[] = $target; |
324 | |
325 | if ( $data['UserPageNotice'] ) { |
326 | $this->addNoticeToPage( |
327 | $this->getTargetPage( NS_USER, $target ), |
328 | $data['UserPageNoticeText'], |
329 | $data['UserPageNoticePosition'], |
330 | $reason |
331 | ); |
332 | } |
333 | |
334 | if ( $data['TalkPageNotice'] ) { |
335 | $this->addNoticeToPage( |
336 | $this->getTargetPage( NS_USER_TALK, $target ), |
337 | $data['TalkPageNoticeText'], |
338 | $data['TalkPageNoticePosition'], |
339 | $reason |
340 | ); |
341 | } |
342 | } |
343 | } |
344 | |
345 | $blockedUsersCount = count( $this->blockedUsers ); |
346 | |
347 | $this->eventLogger->logEvent( [ |
348 | 'action' => 'block', |
349 | 'targetsCount' => count( $targets ), |
350 | 'relevantTargetsCount' => $blockedUsersCount, |
351 | ] ); |
352 | |
353 | if ( $blockedUsersCount === 0 ) { |
354 | return [ 'checkuser-investigateblock-failure' ]; |
355 | } |
356 | |
357 | return true; |
358 | } |
359 | |
360 | /** |
361 | * @param int $namespace |
362 | * @param string $target Must be a valid IP address or a valid user name |
363 | * @return string |
364 | */ |
365 | private function getTargetPage( int $namespace, string $target ): string { |
366 | if ( IPUtils::isValidRange( $target ) ) { |
367 | $target = IPUtils::sanitizeRange( $target ); |
368 | } |
369 | |
370 | return $this->titleFormatter->getPrefixedText( |
371 | new TitleValue( $namespace, $target ) |
372 | ); |
373 | } |
374 | |
375 | /** |
376 | * Add a notice to a given page. The notice may be prepended or appended, |
377 | * or it may replace the page. |
378 | * |
379 | * @param string $title Page to which to add the notice |
380 | * @param string $notice The notice, as wikitext |
381 | * @param string $position One of 'prependtext', 'appendtext' or 'text' |
382 | * @param string $summary Edit summary |
383 | */ |
384 | private function addNoticeToPage( |
385 | string $title, |
386 | string $notice, |
387 | string $position, |
388 | string $summary |
389 | ): void { |
390 | $apiParams = [ |
391 | 'action' => 'edit', |
392 | 'title' => $title, |
393 | $position => $notice, |
394 | 'summary' => $summary, |
395 | 'token' => $this->getContext()->getCsrfTokenSet()->getToken(), |
396 | ]; |
397 | |
398 | $api = new ApiMain( |
399 | new DerivativeRequest( |
400 | $this->getRequest(), |
401 | $apiParams, |
402 | // was posted |
403 | true |
404 | ), |
405 | // enable write |
406 | true |
407 | ); |
408 | |
409 | try { |
410 | $api->execute(); |
411 | } catch ( Exception $e ) { |
412 | $this->noticesFailed = true; |
413 | } |
414 | } |
415 | |
416 | /** |
417 | * @inheritDoc |
418 | */ |
419 | public function onSuccess() { |
420 | $blockedUsers = array_map( function ( $userName ) { |
421 | $user = $this->userFactory->newFromName( |
422 | $userName, |
423 | UserNameUtils::RIGOR_NONE |
424 | ); |
425 | return Linker::userLink( $user->getId(), $userName ); |
426 | }, $this->blockedUsers ); |
427 | |
428 | $language = $this->getLanguage(); |
429 | |
430 | $blockedMessage = $this->msg( 'checkuser-investigateblock-success' ) |
431 | ->rawParams( $language->listToText( $blockedUsers ) ) |
432 | ->params( $language->formatNum( count( $blockedUsers ) ) ) |
433 | ->parseAsBlock(); |
434 | |
435 | $out = $this->getOutput(); |
436 | $out->setPageTitleMsg( $this->msg( 'blockipsuccesssub' ) ); |
437 | $out->addHtml( $blockedMessage ); |
438 | |
439 | if ( $this->noticesFailed ) { |
440 | $failedNoticesMessage = $this->msg( 'checkuser-investigateblock-notices-failed' ); |
441 | $out->addHtml( $failedNoticesMessage ); |
442 | } |
443 | } |
444 | |
445 | /** |
446 | * InvestigateBlock writes to the DB when the form is submitted. |
447 | * |
448 | * @return true |
449 | */ |
450 | public function doesWrites() { |
451 | return true; |
452 | } |
453 | } |