Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.56% covered (success)
98.56%
478 / 485
87.10% covered (warning)
87.10%
27 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialInvestigate
98.56% covered (success)
98.56%
478 / 485
87.10% covered (warning)
87.10%
27 / 31
87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 preHtml
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 getLayout
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 addTabs
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 getTokenWithoutPaginationData
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 addHtml
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 addParserOutput
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addTabContent
97.73% covered (success)
97.73%
86 / 88
0.00% covered (danger)
0.00%
0 / 1
17
 logQuery
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getTabParam
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTabMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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
 addBlockForm
100.00% covered (success)
100.00%
70 / 70
100.00% covered (success)
100.00%
1 / 1
3
 addIndicators
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 getDisplayFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getForm
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getFormFields
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
1 / 1
4
 alterForm
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getTokenData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 onSubmit
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
8
 addLogEntries
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUpdatedToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRedirectUrl
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getArrayFromField
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 usingFilters
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getDuration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 launchTour
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
 addSubtitle
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\CheckUser\Investigate;
4
5use HTMLForm;
6use Language;
7use MediaWiki\CheckUser\GuidedTour\TourLauncher;
8use MediaWiki\CheckUser\Hook\CheckUserSubtitleLinksHook;
9use MediaWiki\CheckUser\HookHandler\Preferences;
10use MediaWiki\CheckUser\Investigate\Pagers\ComparePager;
11use MediaWiki\CheckUser\Investigate\Pagers\PagerFactory;
12use MediaWiki\CheckUser\Investigate\Pagers\PreliminaryCheckPager;
13use MediaWiki\CheckUser\Investigate\Pagers\TimelinePager;
14use MediaWiki\CheckUser\Investigate\Pagers\TimelinePagerFactory;
15use MediaWiki\CheckUser\Investigate\Utilities\DurationManager;
16use MediaWiki\CheckUser\Investigate\Utilities\EventLogger;
17use MediaWiki\CheckUser\Services\CheckUserLogService;
18use MediaWiki\CheckUser\Services\TokenQueryManager;
19use MediaWiki\Html\Html;
20use MediaWiki\Linker\LinkRenderer;
21use MediaWiki\Permissions\PermissionManager;
22use MediaWiki\SpecialPage\FormSpecialPage;
23use MediaWiki\Status\Status;
24use MediaWiki\User\Options\UserOptionsManager;
25use MediaWiki\User\UserFactory;
26use MediaWiki\User\UserIdentityLookup;
27use Message;
28use OOUI\ButtonGroupWidget;
29use OOUI\ButtonWidget;
30use OOUI\Element;
31use OOUI\FieldLayout;
32use OOUI\FieldsetLayout;
33use OOUI\HorizontalLayout;
34use OOUI\HtmlSnippet;
35use OOUI\IndexLayout;
36use OOUI\MessageWidget;
37use OOUI\TabOptionWidget;
38use OOUI\Tag;
39use OOUI\Widget;
40use ParserOutput;
41use Wikimedia\IPUtils;
42
43class SpecialInvestigate extends FormSpecialPage {
44    private Language $contentLanguage;
45    private UserOptionsManager $userOptionsManager;
46    private PagerFactory $preliminaryCheckPagerFactory;
47    private PagerFactory $comparePagerFactory;
48    private TimelinePagerFactory $timelinePagerFactory;
49    private TokenQueryManager $tokenQueryManager;
50    private DurationManager $durationManager;
51    private EventLogger $eventLogger;
52    private TourLauncher $tourLauncher;
53    private CheckUserSubtitleLinksHook $subtitleLinksHookRunner;
54    private PermissionManager $permissionManager;
55    private CheckUserLogService $checkUserLogService;
56    private UserIdentityLookup $userIdentityLookup;
57    private UserFactory $userFactory;
58
59    /** @var IndexLayout|null */
60    private $layout;
61
62    /** @var array|null */
63    private $tokenData;
64
65    /** @var HTMLForm|null */
66    private $form;
67
68    /** @var string|null */
69    private $tokenWithoutPaginationData;
70
71    /** @var int */
72    private const MAX_TARGETS = 10;
73
74    /** @var string */
75    public const TOUR_INVESTIGATE = 'checkuserinvestigate';
76
77    /** @var string */
78    public const TOUR_INVESTIGATE_FORM = 'checkuserinvestigateform';
79
80    /**
81     * @param LinkRenderer $linkRenderer
82     * @param Language $contentLanguage
83     * @param UserOptionsManager $userOptionsManager
84     * @param PagerFactory $preliminaryCheckPagerFactory
85     * @param PagerFactory $comparePagerFactory
86     * @param PagerFactory $timelinePagerFactory
87     * @param TokenQueryManager $tokenQueryManager
88     * @param DurationManager $durationManager
89     * @param EventLogger $eventLogger
90     * @param TourLauncher $tourLauncher
91     * @param CheckUserSubtitleLinksHook $subtitleLinksHookRunner
92     * @param PermissionManager $permissionManager
93     * @param CheckUserLogService $checkUserLogService
94     * @param UserIdentityLookup $userIdentityLookup
95     * @param UserFactory $userFactory
96     */
97    public function __construct(
98        LinkRenderer $linkRenderer,
99        Language $contentLanguage,
100        UserOptionsManager $userOptionsManager,
101        PagerFactory $preliminaryCheckPagerFactory,
102        PagerFactory $comparePagerFactory,
103        PagerFactory $timelinePagerFactory,
104        TokenQueryManager $tokenQueryManager,
105        DurationManager $durationManager,
106        EventLogger $eventLogger,
107        TourLauncher $tourLauncher,
108        CheckUserSubtitleLinksHook $subtitleLinksHookRunner,
109        PermissionManager $permissionManager,
110        CheckUserLogService $checkUserLogService,
111        UserIdentityLookup $userIdentityLookup,
112        UserFactory $userFactory
113    ) {
114        parent::__construct( 'Investigate', 'checkuser' );
115        $this->setLinkRenderer( $linkRenderer );
116        $this->contentLanguage = $contentLanguage;
117        $this->userOptionsManager = $userOptionsManager;
118        $this->preliminaryCheckPagerFactory = $preliminaryCheckPagerFactory;
119        $this->comparePagerFactory = $comparePagerFactory;
120        $this->timelinePagerFactory = $timelinePagerFactory;
121        $this->tokenQueryManager = $tokenQueryManager;
122        $this->durationManager = $durationManager;
123        $this->eventLogger = $eventLogger;
124        $this->tourLauncher = $tourLauncher;
125        $this->subtitleLinksHookRunner = $subtitleLinksHookRunner;
126        $this->permissionManager = $permissionManager;
127        $this->checkUserLogService = $checkUserLogService;
128        $this->userIdentityLookup = $userIdentityLookup;
129        $this->userFactory = $userFactory;
130    }
131
132    /**
133     * @inheritDoc
134     */
135    protected function preHtml() {
136        // Add necessary styles
137        $this->getOutput()->addModuleStyles( [
138            'mediawiki.widgets.TagMultiselectWidget.styles',
139            'ext.checkUser.styles',
140        ] );
141        // Add button link to the log page on the main form.
142        // Open in the current tab.
143        $this->addIndicators( false );
144
145        return '';
146    }
147
148    /**
149     * @inheritDoc
150     */
151    public function execute( $par ) {
152        // Always call the parent method in order to check execute permissions.
153        parent::execute( $par );
154
155        // If the form submission results in a redirect, there is no need to
156        // generate content for the page.
157        if ( $this->getOutput()->getRedirect() !== '' ) {
158            return;
159        }
160
161        $this->getOutput()->addModules( [ 'ext.checkUser' ] );
162
163        // Show the tabs if there is any request data.
164        // The tabs should also be shown even if the form was a POST request because
165        // the filters could have failed validation.
166        if ( $par && $this->getTokenData() !== [] ) {
167            // Remove the filters, unless a valid tab that supports filters is selected.
168            if ( !in_array( $par, [
169                $this->getTabParam( 'compare' ),
170                $this->getTabParam( 'timeline' ),
171            ] ) ) {
172                $this->getOutput()->clearHTML();
173            }
174
175            $this->addIndicators( true );
176            $this->addBlockForm();
177            $this->addTabs( $par )->addTabContent( $par );
178            $this->getOutput()->addHTML( $this->getLayout() );
179        } else {
180            $this->launchTour( self::TOUR_INVESTIGATE_FORM );
181        }
182
183        // Add the links after any previous HTML has been cleared.
184        $this->addSubtitle();
185        $this->addHelpLink(
186            'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Special_Investigate',
187            true
188        );
189    }
190
191    /**
192     * Returns the OOUI Index Layout and adds the module dependencies for OOUI.
193     *
194     * @return IndexLayout
195     */
196    private function getLayout(): IndexLayout {
197        if ( $this->layout === null ) {
198            $this->getOutput()->enableOOUI();
199            $this->getOutput()->addModuleStyles( [
200                'oojs-ui-widgets.styles',
201            ] );
202
203            $this->layout = new IndexLayout( [
204                'framed' => false,
205                'expanded' => false,
206                'classes' => [ 'ext-checkuser-investigate-tabs-indexLayout' ],
207            ] );
208        }
209
210        return $this->layout;
211    }
212
213    /**
214     * Add tabs to the layout. Provide the current tab so that tab can be highlighted.
215     *
216     * @param string $par
217     * @return self
218     */
219    private function addTabs( string $par ): self {
220        $config = $this->getLayout()->getConfig( $config );
221
222        /* @var TabSelectWidget $tabSelectWidget */
223        $tabSelectWidget = $config['tabSelectWidget'];
224
225        $token = $this->getTokenWithoutPaginationData();
226
227        $tabs = array_map( function ( $tab ) use ( $par, $token ) {
228            $label = $this->getTabMessage( $tab )->text();
229            $param = $this->getTabParam( $tab );
230            return new TabOptionWidget( [
231                'label' => $label,
232                'labelElement' => ( new Tag( 'a' ) )->setAttributes( [
233                    'href' => $this->getPageTitle( $param )->getLocalURL( [
234                        'token' => $token,
235                        'duration' => $this->getDuration() ?: null,
236                    ] ),
237                ] ),
238                'selected' => ( $par === $param ),
239            ] );
240        }, [
241            'preliminary-check',
242            'compare',
243            'timeline',
244        ] );
245
246        $tabSelectWidget->addItems( $tabs );
247
248        return $this;
249    }
250
251    /**
252     * @return string|null
253     */
254    private function getTokenWithoutPaginationData() {
255        if ( $this->tokenWithoutPaginationData === null ) {
256            $this->tokenWithoutPaginationData = $this->getUpdatedToken( [
257                'offset' => null,
258            ] );
259        }
260        return $this->tokenWithoutPaginationData;
261    }
262
263    /**
264     * Add HTML to Layout.
265     *
266     * @param string $html
267     * @return self
268     */
269    private function addHtml( string $html ): self {
270        $config = $this->getLayout()->getConfig( $config );
271
272        /* @var StackLayout $contentPanel */
273        $contentPanel = $config['contentPanel'];
274
275        $contentPanel->addItems( [
276            new Element( [
277                'content' => new HtmlSnippet( $html ),
278            ] ),
279        ] );
280
281        return $this;
282    }
283
284    /**
285     * Add Pager Output to Layout.
286     *
287     * @param ParserOutput $parserOutput
288     * @return self
289     */
290    private function addParserOutput( ParserOutput $parserOutput ): self {
291        $this->getOutput()->addParserOutputMetadata( $parserOutput );
292        $this->addHTML( $parserOutput->getText() );
293
294        return $this;
295    }
296
297    /**
298     * Add Tab content to Layout
299     *
300     * @param string $par
301     * @return self
302     */
303    private function addTabContent( string $par ): self {
304        $startTime = $this->eventLogger->getTime();
305
306        switch ( $par ) {
307            case $this->getTabParam( 'preliminary-check' ):
308                /** @var PreliminaryCheckPager $pager */
309                $pager = $this->preliminaryCheckPagerFactory->createPager( $this->getContext() );
310                $hasIpTargets = (bool)array_filter(
311                    $this->getTokenData()['targets'] ?? [],
312                    [ IPUtils::class, 'isIPAddress' ]
313                );
314
315                if ( $pager->getNumRows() ) {
316                    $this->addParserOutput( $pager->getFullOutput() );
317                } elseif ( !$hasIpTargets ) {
318                    $this->addHTML(
319                        $this->msg( 'checkuser-investigate-notice-no-results' )->parse()
320                    );
321                }
322
323                if ( $hasIpTargets ) {
324                    $compareParam = $this->getTabParam( 'compare' );
325                    // getFullURL handles the query params:
326                    // https://www.mediawiki.org/wiki/Help:Links#External_links_to_internal_pages
327                    $link = $this->getPageTitle( $compareParam )->getFullURL( [
328                        'token' => $this->getTokenWithoutPaginationData(),
329                    ] );
330                    $message = $this->msg( 'checkuser-investigate-preliminary-notice-ip-targets', $link )->parse();
331                    $this->addHTML( new MessageWidget( [
332                        'type' => 'notice',
333                        'label' => new HtmlSnippet( $message )
334                    ] ) );
335                }
336
337                $this->logQuery( [
338                    'tab' => 'preliminary-check',
339                    'resultsCount' => $pager->getNumRows(),
340                    'resultsIncomplete' => false,
341                    'queryTime' => $this->eventLogger->getTime() - $startTime,
342                ] );
343
344                break;
345
346            case $this->getTabParam( 'compare' ):
347                /** @var ComparePager $pager */
348                $pager = $this->comparePagerFactory->createPager( $this->getContext() );
349                $numRows = $pager->getNumRows();
350
351                if ( $numRows ) {
352                    $targetsOverLimit = $pager->getTargetsOverLimit();
353                    if ( $targetsOverLimit ) {
354                        // Hide target usernames which the current authority cannot see.
355                        foreach ( $targetsOverLimit as &$target ) {
356                            $user = $this->userFactory->newFromName( $target );
357                            if (
358                                $user !== null &&
359                                $user->isHidden() &&
360                                !$this->getUser()->isAllowed( 'hideuser' )
361                            ) {
362                                $target = $this->msg( 'rev-deleted-user' )->text();
363                            }
364                        }
365                        $message = $this->msg(
366                            'checkuser-investigate-compare-notice-exceeded-limit',
367                            $this->getLanguage()->commaList( $targetsOverLimit )
368                        )->parse();
369                        $this->addHTML( new MessageWidget( [
370                            'type' => 'warning',
371                            'label' => new HtmlSnippet( $message )
372                        ] ) );
373                    }
374
375                    // Only start the tour if there are results on the page.
376                    $this->launchTour( self::TOUR_INVESTIGATE );
377
378                    $this->addParserOutput( $pager->getFullOutput() );
379                } else {
380                    $messageKey = $this->usingFilters() ?
381                        'checkuser-investigate-compare-notice-no-results-filters' :
382                        'checkuser-investigate-compare-notice-no-results';
383                    $message = $this->msg( $messageKey )->parse();
384                    $this->addHTML( new MessageWidget( [
385                        'type' => 'warning',
386                        'label' => new HtmlSnippet( $message )
387                    ] ) );
388                }
389
390                $this->logQuery( [
391                    'tab' => 'compare',
392                    'resultsCount' => $numRows,
393                    'resultsIncomplete' => $numRows && $targetsOverLimit,
394                    'queryTime' => $this->eventLogger->getTime() - $startTime,
395                ] );
396
397                break;
398
399            case $this->getTabParam( 'timeline' ):
400                /** @var TimelinePager $pager */
401                $pager = $this->timelinePagerFactory->createPager( $this->getContext() );
402                $numRows = $pager->getNumRows();
403
404                if ( $numRows ) {
405                    $this->addParserOutput( $pager->getFullOutput() );
406                } else {
407                    $messageKey = $this->usingFilters() ?
408                        'checkuser-investigate-timeline-notice-no-results-filters' :
409                        'checkuser-investigate-timeline-notice-no-results';
410                    $message = $this->msg( $messageKey )->parse();
411                    $this->addHTML( new MessageWidget( [
412                        'type' => 'warning',
413                        'label' => new HtmlSnippet( $message )
414                    ] ) );
415                }
416
417                $this->logQuery( [
418                    'tab' => 'timeline',
419                    'resultsCount' => $pager->getNumRows(),
420                    'resultsIncomplete' => false,
421                    'queryTime' => $this->eventLogger->getTime() - $startTime,
422                ] );
423
424                break;
425        }
426
427        return $this;
428    }
429
430    /**
431     * @param array $logData
432     */
433    private function logQuery( array $logData ): void {
434        $relevantTargetsCount = count( array_diff(
435            $this->getTokenData()['targets'] ?? [],
436            $this->getTokenData()['exclude-targets'] ?? []
437        ) );
438
439        $this->eventLogger->logEvent( array_merge(
440            [
441                'action' => 'query',
442                'relevantTargetsCount' => $relevantTargetsCount,
443            ],
444            $logData
445        ) );
446    }
447
448    /**
449     * Given a tab name, return the subpage $par.
450     *
451     * Since the page title is always in the content language, the subpage should be also.
452     *
453     * @param string $tab
454     *
455     * @return string
456     */
457    private function getTabParam( string $tab ): string {
458        $name = $this->getTabMessage( $tab )->inLanguage( $this->contentLanguage )->text();
459        return str_replace( ' ', '_', $name );
460    }
461
462    /**
463     * Given a tab name, return the subpage tab message.
464     *
465     * @param string $tab
466     *
467     * @return Message
468     */
469    private function getTabMessage( string $tab ): Message {
470        // The following messages are generated here:
471        // * checkuser-investigate-tab-preliminary-check
472        // * checkuser-investigate-tab-compare
473        // * checkuser-investigate-tab-timeline
474        return $this->msg( 'checkuser-investigate-tab-' . $tab );
475    }
476
477    /**
478     * @inheritDoc
479     */
480    public function getDescription() {
481        return $this->msg( 'checkuser-investigate' );
482    }
483
484    /**
485     * @inheritDoc
486     */
487    protected function getMessagePrefix() {
488        return 'checkuser-' . strtolower( $this->getName() );
489    }
490
491    /**
492     * Add page subtitle including the name of the targets in the investigation,
493     * and a block form. Add the block form elements that are visible initially,
494     * to avoid a flicker on page load.
495     */
496    private function addBlockForm() {
497        $targets = $this->getTokenData()['targets'] ?? [];
498        if ( $targets ) {
499            $userCanBlock = $this->permissionManager->userHasRight( $this->getUser(), 'block' );
500            $excludeTargets = $this->getTokenData()['exclude-targets'] ?? [];
501
502            $this->getOutput()->addJsConfigVars( [
503                'wgCheckUserInvestigateTargets' => $targets,
504                'wgCheckUserInvestigateExcludeTargets' => $excludeTargets,
505                'wgCheckUserInvestigateCanBlock' => $userCanBlock,
506            ] );
507
508            $targetsText = $this->getLanguage()->listToText( array_map( static function ( $target ) {
509                return Html::rawElement( 'strong', [], Html::rawElement( 'bdi', [], htmlspecialchars( $target ) ) );
510            }, $targets ) );
511            $subtitle = $this->msg( 'checkuser-investigate-page-subtitle', $targetsText );
512
513            // Placeholder, to allow the FieldLayout label to be shown before the
514            // JavaScript loads. This will be replaced by a TagMultiselect (which
515            // has not yet been implemented in PHP).
516            $placeholderWidget = new Widget( [
517                'classes' => [ 'ext-checkuser-investigate-subtitle-placeholder-widget' ],
518            ] );
519            $items = [];
520            $items[] = new FieldLayout(
521                $placeholderWidget,
522                [
523                    'label' => new HtmlSnippet( $subtitle->parse() ),
524                    'align' => 'top',
525                    'infusable' => true,
526                    'classes' => [
527                        'ext-checkuser-investigate-subtitle-targets-layout'
528                    ]
529                ]
530            );
531            if ( $userCanBlock ) {
532                $blockAccountsButton = new ButtonWidget( [
533                    'infusable' => true,
534                    'label' => $this->msg( 'checkuser-investigate-subtitle-block-accounts-button-label' )->text(),
535                    'flags' => [ 'primary', 'progressive' ],
536                    'classes' => [
537                        'ext-checkuser-investigate-subtitle-block-button',
538                        'ext-checkuser-investigate-subtitle-block-accounts-button',
539                    ],
540                ] );
541                $blockIpsButton = new ButtonWidget( [
542                    'infusable' => true,
543                    'label' => $this->msg( 'checkuser-investigate-subtitle-block-ips-button-label' )->text(),
544                    'flags' => [ 'primary', 'progressive' ],
545                    'classes' => [
546                        'ext-checkuser-investigate-subtitle-block-button',
547                        'ext-checkuser-investigate-subtitle-block-ips-button',
548                    ],
549                ] );
550                $items[] = new FieldLayout(
551                    new Widget( [
552                        'content' => new HorizontalLayout( [
553                            'items' => [
554                                $blockAccountsButton,
555                                $blockIpsButton,
556                            ]
557                        ] )
558                    ] ),
559                    [
560                        'align' => 'top',
561                        'infusable' => true,
562                    ]
563                );
564            }
565
566            $blockFieldset = new FieldsetLayout( [
567                'classes' => [
568                    'ext-checkuser-investigate-subtitle-fieldset'
569                ],
570                'items' => $items
571            ] );
572
573            $this->getOutput()->prependHTML(
574                $blockFieldset
575            );
576        }
577    }
578
579    /**
580     * Add buttons to start a new investigation and linking to log page
581     *
582     * @param bool $onSubpage whether the current page is a subpage of Special:Investigate
583     *         (i.e. whether an investigation is currently happening).
584     */
585    private function addIndicators( bool $onSubpage ) {
586        $canViewLogs = $this->permissionManager->userHasRight( $this->getUser(), 'checkuser-log' );
587        $buttons = [];
588        if ( $canViewLogs ) {
589            $buttons[] = new ButtonWidget( [
590                'label' => $this->msg( 'checkuser-investigate-indicator-logs' )->text(),
591                'href' => self::getTitleFor( 'CheckUserLog' )->getLinkURL(),
592                'target' => $onSubpage ? '_blank' : '',
593            ] );
594        }
595
596        if ( $onSubpage ) {
597            $buttons[] = new ButtonWidget( [
598                'label' => $this->msg( 'checkuser-investigate-indicator-new-investigation' )->text(),
599                'href' => $this->getPageTitle()->getLinkURL(),
600                'target' => '_blank',
601            ] );
602        }
603
604        if ( count( $buttons ) > 0 ) {
605            $this->getOutput()->setIndicators( [
606                'ext-checkuser-investigation-btns' => new ButtonGroupWidget( [
607                    'classes' => [ 'ext-checkuser-investigate-indicators' ],
608                    'items' => $buttons,
609                ] ),
610            ] );
611        }
612    }
613
614    /**
615     * @inheritDoc
616     */
617    protected function getDisplayFormat() {
618        return 'ooui';
619    }
620
621    /**
622     * @inheritDoc
623     */
624    protected function getForm() {
625        if ( $this->form === null ) {
626            $this->form = parent::getForm();
627        }
628
629        return $this->form;
630    }
631
632    /**
633     * @inheritDoc
634     */
635    protected function getFormFields() {
636        $data = $this->getTokenData();
637
638        $duration = [
639            'type' => 'select',
640            'name' => 'duration',
641            'id' => 'investigate-duration',
642            'label-message' => 'checkuser-investigate-duration-label',
643            'options-messages' => [
644                'checkuser-investigate-duration-option-all' => '',
645                'checkuser-investigate-duration-option-1w' => 'P1W',
646                'checkuser-investigate-duration-option-2w' => 'P2W',
647                'checkuser-investigate-duration-option-30d' => 'P30D',
648            ],
649            // If this duration in the URL is not in the list, "all" is displayed.
650            'default' => $this->getDuration(),
651        ];
652
653        if ( $data === [] ) {
654            $this->getOutput()->addJsConfigVars( 'wgCheckUserInvestigateMaxTargets', self::MAX_TARGETS );
655
656            return [
657                'Targets' => [
658                    'type' => 'usersmultiselect',
659                    'name' => 'targets',
660                    'label-message' => 'checkuser-investigate-targets-label',
661                    'placeholder' => $this->msg( 'checkuser-investigate-targets-placeholder' )->text(),
662                    'id' => 'targets',
663                    'required' => true,
664                    'max' => self::MAX_TARGETS,
665                    'exists' => true,
666                    'ipallowed' => true,
667                    'iprange' => true,
668                    'default' => '',
669                    'input' => [
670                        'autocomplete' => false,
671                    ],
672                ],
673                'Duration' => $duration,
674                'Reason' => [
675                    'type' => 'text',
676                    'id' => 'investigate-reason',
677                    'name' => 'reason',
678                    'label-message' => 'checkuser-investigate-reason-label',
679                    'required' => true,
680                    'autocomplete' => false,
681                ],
682            ];
683        }
684
685        $fields = [];
686
687        // Filters for both Compare & Timeline
688        $compareTab = $this->getTabParam( 'compare' );
689        $timelineTab = $this->getTabParam( 'timeline' );
690
691        // Filters for both Compare & Timeline
692        if ( in_array( $this->par, [ $compareTab, $timelineTab ], true ) ) {
693            $fields['ExcludeTargets'] = [
694                'type' => 'usersmultiselect',
695                'name' => 'exclude-targets',
696                'label-message' => 'checkuser-investigate-filters-exclude-targets-label',
697                'exists' => true,
698                'required' => false,
699                'ipallowed' => true,
700                'iprange' => false,
701                'default' => implode( "\n", $data['exclude-targets'] ?? [] ),
702                'input' => [
703                    'autocomplete' => false,
704                ],
705            ];
706            $fields['Duration'] = $duration;
707        }
708
709        if ( $this->par === $compareTab ) {
710            $fields['Targets'] = [
711                'type' => 'hidden',
712                'name' => 'targets',
713            ];
714        }
715
716        // if ( $this->par === $timelineTab ) {
717            // @TODO Add filters specific to the timeline tab.
718        // }
719
720        return $fields;
721    }
722
723    /**
724     * @inheritDoc
725     */
726    protected function alterForm( HTMLForm $form ) {
727        // Not done by default in OOUI forms, but done here to match
728        // intended design in T237034. See FormSpecialPage::getForm
729        if ( $this->getTokenData() === [] ) {
730            $form->setWrapperLegendMsg( 'checkuser-investigate-legend' );
731        } else {
732            $tabs = [ $this->getTabParam( 'compare' ), $this->getTabParam( 'timeline' ) ];
733            if ( in_array( $this->par, $tabs ) ) {
734                $form->setAction( $this->getRequest()->getRequestURL() );
735                $form->setWrapperLegendMsg( 'checkuser-investigate-filters-legend' );
736                // If the page is a result of a POST then validation failed, and the form should be open.
737                // If the page is a result of a GET then validation succeeded and the form should be closed.
738                $form->setCollapsibleOptions( !$this->getRequest()->wasPosted() );
739            }
740        }
741    }
742
743    /**
744     * Get data from the request token.
745     *
746     * @return array
747     */
748    private function getTokenData(): array {
749        if ( $this->tokenData === null ) {
750            $this->tokenData = $this->tokenQueryManager->getDataFromRequest( $this->getRequest() );
751        }
752
753        return $this->tokenData;
754    }
755
756    /**
757     * @inheritDoc
758     */
759    public function onSubmit( array $data ) {
760        $update = [
761            'offset' => null,
762        ];
763
764        if ( isset( $data['Reason'] ) ) {
765            $update['reason'] = $data['Reason'];
766        }
767        if ( isset( $data['ExcludeTargets' ] ) ) {
768            $submittedExcludeTargets = $this->getArrayFromField( $data, 'ExcludeTargets' );
769            $update['exclude-targets'] = $submittedExcludeTargets;
770        }
771        if ( isset( $data['Targets' ] ) ) {
772            $tokenData = $this->getTokenData();
773
774            $submittedTargets = $this->getArrayFromField( $data, 'Targets' );
775            $update['targets'] = $submittedTargets;
776
777            $this->addLogEntries(
778                $update['targets'],
779                $update['reason'] ?? $tokenData['reason']
780            );
781
782            $update['targets'] = array_unique( array_merge(
783                $update['targets'],
784                $tokenData['targets'] ?? []
785            ) );
786        }
787
788        $token = $this->getUpdatedToken( $update );
789
790        if ( isset( $this->par ) && $this->par !== '' ) {
791            // Redirect to the same subpage with an updated token.
792            $url = $this->getRedirectUrl( [
793                'token' => $token,
794                'duration' => $data['Duration'] ?: null,
795            ] );
796        } else {
797            // Redirect to compare tab
798            $url = $this->getPageTitle( $this->getTabParam( 'compare' ) )->getFullUrlForRedirect( [
799                'token' => $token,
800                'duration' => $data['Duration'] ?: null,
801            ] );
802        }
803        $this->getOutput()->redirect( $url );
804
805        $this->eventLogger->logEvent( [
806            'action' => 'submit',
807            'targetsCount' => count( $submittedTargets ?? [] ),
808            'excludeTargetsCount' => count( $submittedExcludeTargets ?? [] ),
809        ] );
810
811        return Status::newGood();
812    }
813
814    /**
815     * Add a log entry for each target under investigation.
816     *
817     * @param string[] $targets
818     * @param string $reason
819     */
820    protected function addLogEntries( array $targets, string $reason ) {
821        $logType = 'investigate';
822        $user = $this->getUser();
823
824        foreach ( $targets as $target ) {
825            if ( IPUtils::isIPAddress( $target ) ) {
826                $targetType = 'ip';
827                $targetId = 0;
828            } else {
829                // The form validated that the user exists on this wiki
830                $targetType = 'user';
831                $userIdentity = $this->userIdentityLookup->getUserIdentityByName( $target );
832                $targetId = $userIdentity ? $userIdentity->getId() : 0;
833            }
834
835            $this->checkUserLogService->addLogEntry(
836                $user,
837                $logType,
838                $targetType,
839                $target,
840                $reason,
841                $targetId
842            );
843        }
844    }
845
846    /**
847     * @inheritDoc
848     */
849    protected function getGroupName() {
850        return 'users';
851    }
852
853    /**
854     * Get an updated token.
855     *
856     * Preforms an array merge on the updates with what is in the current token.
857     * Setting a value to null will remove it.
858     *
859     * @param array $update
860     * @return string
861     */
862    private function getUpdatedToken( array $update ): string {
863        return $this->tokenQueryManager->updateToken(
864            $this->getRequest(),
865            $update
866        );
867    }
868
869    /**
870     * Get a redirect URL with a new query string.
871     *
872     * @param array $update
873     * @return string
874     */
875    private function getRedirectUrl( array $update ): string {
876        $parts = wfParseURL( $this->getRequest()->getFullRequestURL() );
877        $query = wfCgiToArray( $parts['query'] ?? '' );
878        $data = array_filter( array_merge( $query, $update ), static function ( $value ) {
879            return $value !== null;
880        } );
881        $parts['query'] = wfArrayToCgi( $data );
882        return wfAssembleUrl( $parts );
883    }
884
885    /**
886     * Get an array of values from a new line separated field.
887     *
888     * @param array $data
889     * @param string $field
890     * @return string[]
891     */
892    private function getArrayFromField( array $data, string $field ): array {
893        if ( !isset( $data[$field] ) ) {
894            return [];
895        }
896
897        if ( !is_string( $data[$field] ) ) {
898            return [];
899        }
900
901        if ( $data[$field] === '' ) {
902            return [];
903        }
904
905        return explode( "\n", $data[$field] );
906    }
907
908    /**
909     * Determine if the filters are in use by the current request.
910     *
911     * @return bool
912     */
913    private function usingFilters(): bool {
914        return count( $this->getTokenData()['exclude-targets'] ?? [] ) > 0
915            || $this->getDuration() !== '';
916    }
917
918    /**
919     * Get the duration from the request.
920     *
921     * @return string
922     */
923    private function getDuration(): string {
924        return $this->durationManager->getFromRequest( $this->getRequest() );
925    }
926
927    /**
928     * Launches the tour unless the user has already completed or canceled it.
929     *
930     * @param string $tour
931     * @return void
932     */
933    private function launchTour( string $tour ): void {
934        $user = $this->getUser();
935
936        switch ( $tour ) {
937            case self::TOUR_INVESTIGATE_FORM:
938                $preference = Preferences::INVESTIGATE_FORM_TOUR_SEEN;
939                $step = 'targets';
940                break;
941            case self::TOUR_INVESTIGATE:
942                $preference = Preferences::INVESTIGATE_TOUR_SEEN;
943                $step = 'useragents';
944                break;
945            default:
946                return;
947        }
948
949        if ( $this->userOptionsManager->getOption( $user, $preference ) ) {
950            return;
951        }
952
953        $this->tourLauncher->launchTour( $tour, $step );
954    }
955
956    /**
957     * Add the subtitle to the page.
958     */
959    private function addSubtitle(): void {
960        $subpage = false;
961        $token = null;
962        $tour = self::TOUR_INVESTIGATE_FORM;
963
964        if ( $this->getTokenData() !== [] ) {
965            $token = $this->getTokenWithoutPaginationData();
966            $subpage = $this->getTabParam( 'compare' );
967            $tour = self::TOUR_INVESTIGATE;
968        }
969
970        $links = [
971            $this->getLinkRenderer()->makeLink(
972                self::getTitleValueFor( 'CheckUser' ),
973                $this->msg( 'checkuser-showmain' )->text()
974            ),
975            $this->tourLauncher->makeTourLink(
976                $tour,
977                $this->getPageTitle( $subpage ),
978                $this->msg( 'checkuser-investigate-subtitle-link-restart-tour' )->text(),
979                [],
980                [
981                    'token' => $token,
982                    'duration' => $this->getDuration() ?: null,
983                ]
984            ),
985        ];
986
987        $this->subtitleLinksHookRunner->onCheckUserSubtitleLinks( $this->getContext(), $links );
988
989        $subtitle = implode( ' | ', array_filter( $links, static function ( $link ) {
990            return (bool)$link;
991        } ) );
992
993        $this->getOutput()->setSubtitle( $subtitle );
994    }
995}