Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.73% covered (success)
97.73%
215 / 220
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialInvestigateBlock
97.73% covered (success)
97.73%
215 / 220
73.33% covered (warning)
73.33%
11 / 15
38
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%
100 / 100
100.00% covered (success)
100.00%
1 / 1
4
 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
96.30% covered (success)
96.30%
52 / 54
0.00% covered (danger)
0.00%
0 / 1
12
 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 ApiMain;
6use Exception;
7use MediaWiki\Block\BlockPermissionCheckerFactory;
8use MediaWiki\Block\BlockUserFactory;
9use MediaWiki\CheckUser\Investigate\Utilities\EventLogger;
10use MediaWiki\Linker\Linker;
11use MediaWiki\MainConfigNames;
12use MediaWiki\Permissions\PermissionManager;
13use MediaWiki\Request\DerivativeRequest;
14use MediaWiki\SpecialPage\FormSpecialPage;
15use MediaWiki\Title\TitleFormatter;
16use MediaWiki\Title\TitleValue;
17use MediaWiki\User\User;
18use MediaWiki\User\UserFactory;
19use MediaWiki\User\UserNameUtils;
20use PermissionsError;
21use Wikimedia\IPUtils;
22
23class 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}