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