summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'CheckUser/src/SpecialInvestigate.php')
-rw-r--r--CheckUser/src/SpecialInvestigate.php1020
1 files changed, 1020 insertions, 0 deletions
diff --git a/CheckUser/src/SpecialInvestigate.php b/CheckUser/src/SpecialInvestigate.php
new file mode 100644
index 00000000..e9ad4c4a
--- /dev/null
+++ b/CheckUser/src/SpecialInvestigate.php
@@ -0,0 +1,1020 @@
+<?php
+
+namespace MediaWiki\CheckUser;
+
+use Html;
+use HTMLForm;
+use Language;
+use MediaWiki\CheckUser\GuidedTour\TourLauncher;
+use MediaWiki\CheckUser\Hook\CheckUserSubtitleLinksHook;
+use MediaWiki\CheckUser\HookHandler\Preferences;
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\User\UserOptionsManager;
+use Message;
+use OOUI\ButtonGroupWidget;
+use OOUI\ButtonWidget;
+use OOUI\Element;
+use OOUI\FieldLayout;
+use OOUI\FieldsetLayout;
+use OOUI\HorizontalLayout;
+use OOUI\HtmlSnippet;
+use OOUI\IndexLayout;
+use OOUI\MessageWidget;
+use OOUI\TabOptionWidget;
+use OOUI\Tag;
+use OOUI\Widget;
+use SpecialCheckUser;
+use User;
+use Wikimedia\IPUtils;
+
+class SpecialInvestigate extends \FormSpecialPage {
+ /** @var Language */
+ private $contentLanguage;
+
+ /** @var UserOptionsManager */
+ private $userOptionsManager;
+
+ /** @var PagerFactory */
+ private $preliminaryCheckPagerFactory;
+
+ /** @var PagerFactory */
+ private $comparePagerFactory;
+
+ /** @var TimelinePagerFactory */
+ private $timelinePagerFactory;
+
+ /** @var TokenQueryManager */
+ private $tokenQueryManager;
+
+ /** @var DurationManager */
+ private $durationManager;
+
+ /** @var EventLogger */
+ private $eventLogger;
+
+ /** @var TourLauncher */
+ private $tourLauncher;
+
+ /** @var CheckUserSubtitleLinksHook */
+ private $subtitleLinksHookRunner;
+
+ /** @var IndexLayout|null */
+ private $layout;
+
+ /** @var array|null */
+ private $tokenData;
+
+ /** @var HTMLForm|null */
+ private $form;
+
+ /** @var string|null */
+ private $tokenWithoutPaginationData;
+
+ /** @var int */
+ private const MAX_TARGETS = 10;
+
+ /** @var string */
+ private const TOUR_INVESTIGATE = 'checkuserinvestigate';
+
+ /** @var string */
+ private const TOUR_INVESTIGATE_FORM = 'checkuserinvestigateform';
+
+ /** @var string[] */
+ private const TOURS = [
+ self::TOUR_INVESTIGATE,
+ self::TOUR_INVESTIGATE_FORM,
+ ];
+
+ /**
+ * @param LinkRenderer $linkRenderer
+ * @param Language $contentLanguage
+ * @param UserOptionsManager $userOptionsManager
+ * @param PagerFactory $preliminaryCheckPagerFactory
+ * @param PagerFactory $comparePagerFactory
+ * @param PagerFactory $timelinePagerFactory
+ * @param TokenQueryManager $tokenQueryManager
+ * @param DurationManager $durationManager
+ * @param EventLogger $eventLogger
+ * @param TourLauncher $tourLauncher
+ * @param CheckUserSubtitleLinksHook $subtitleLinksHookRunner
+ */
+ public function __construct(
+ LinkRenderer $linkRenderer,
+ Language $contentLanguage,
+ UserOptionsManager $userOptionsManager,
+ PagerFactory $preliminaryCheckPagerFactory,
+ PagerFactory $comparePagerFactory,
+ PagerFactory $timelinePagerFactory,
+ TokenQueryManager $tokenQueryManager,
+ DurationManager $durationManager,
+ EventLogger $eventLogger,
+ TourLauncher $tourLauncher,
+ CheckUserSubtitleLinksHook $subtitleLinksHookRunner
+ ) {
+ parent::__construct( 'Investigate', 'checkuser' );
+ $this->setLinkRenderer( $linkRenderer );
+ $this->contentLanguage = $contentLanguage;
+ $this->userOptionsManager = $userOptionsManager;
+ $this->preliminaryCheckPagerFactory = $preliminaryCheckPagerFactory;
+ $this->comparePagerFactory = $comparePagerFactory;
+ $this->timelinePagerFactory = $timelinePagerFactory;
+ $this->tokenQueryManager = $tokenQueryManager;
+ $this->durationManager = $durationManager;
+ $this->eventLogger = $eventLogger;
+ $this->tourLauncher = $tourLauncher;
+ $this->subtitleLinksHookRunner = $subtitleLinksHookRunner;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function preText() {
+ // Add necessary styles
+ $this->getOutput()->addModuleStyles( [
+ 'mediawiki.widgets.TagMultiselectWidget.styles',
+ ] );
+ // Add button link to the log page on the main form.
+ // Open in the current tab.
+ $this->addIndicators( /** $newTab */ false, /** $logOnly */ true );
+
+ return '';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute( $par ) {
+ // Always call the parent method in order to check execute permissions.
+ parent::execute( $par );
+
+ // If the form submission results in a redirect, there is no need to
+ // generate content for the page.
+ if ( $this->getOutput()->getRedirect() !== '' ) {
+ return;
+ }
+
+ // The tour is being explicitly launched by the user, reset their preferences.
+ if ( $this->reLaunchTour() ) {
+ return;
+ }
+
+ $this->getOutput()->addModuleStyles( 'ext.checkUser.investigate.styles' );
+ $this->getOutput()->addModules( [ 'ext.checkUser.investigate' ] );
+
+ // Show the tabs if there is any request data.
+ // The tabs should also be shown even if the form was a POST request because
+ // the filters could have failed validation.
+ if ( $this->getTokenData() !== [] ) {
+ // Remove the filters, unless a valid tab that supports filters is selected.
+ if ( !in_array( $par, [
+ $this->getTabParam( 'compare' ),
+ $this->getTabParam( 'timeline' ),
+ ] ) ) {
+ $this->getOutput()->clearHTML();
+ }
+
+ $this->addIndicators();
+ $this->addBlockForm();
+ $this->addTabs( $par )->addTabContent( $par );
+ $this->getOutput()->addHTML( $this->getLayout() );
+ } else {
+ $this->launchTour( self::TOUR_INVESTIGATE_FORM );
+ }
+
+ // Add the links after any previous HTML has been cleared.
+ $this->addSubtitle();
+ $this->addHelpLink(
+ 'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Special_Investigate',
+ true
+ );
+ }
+
+ /**
+ * Returns the OOUI Index Layout and adds the module dependencies for OOUI.
+ *
+ * @return IndexLayout
+ */
+ private function getLayout() : IndexLayout {
+ if ( $this->layout === null ) {
+ $this->getOutput()->enableOOUI();
+ $this->getOutput()->addModuleStyles( [
+ 'oojs-ui-widgets.styles',
+ ] );
+
+ $this->layout = new IndexLayout( [
+ 'framed' => false,
+ 'expanded' => false,
+ 'classes' => [ 'ext-checkuser-investigate-tabs-indexLayout' ],
+ ] );
+ }
+
+ return $this->layout;
+ }
+
+ /**
+ * Add tabs to the layout. Provide the current tab so that tab can be highlighted.
+ *
+ * @param string $par
+ * @return self
+ */
+ private function addTabs( string $par ) : self {
+ $config = [];
+ [
+ 'tabSelectWidget' => $tabSelectWidget,
+ ] = $this->getLayout()->getConfig( $config );
+
+ $token = $this->getTokenWithoutPaginationData();
+
+ $tabs = array_map( function ( $tab ) use ( $par, $token ) {
+ $label = $this->getTabMessage( $tab )->text();
+ $param = $this->getTabParam( $tab );
+ return new TabOptionWidget( [
+ 'label' => $label,
+ 'labelElement' => ( new Tag( 'a' ) )->setAttributes( [
+ 'href' => $this->getPageTitle( $param )->getLocalURL( [
+ 'token' => $token,
+ 'duration' => $this->getDuration() ?: null,
+ ] ),
+ ] ),
+ 'selected' => ( $par === $param ),
+ ] );
+ }, [
+ 'preliminary-check',
+ 'compare',
+ 'timeline',
+ ] );
+
+ $tabSelectWidget->addItems( $tabs );
+
+ return $this;
+ }
+
+ /**
+ * @return string|null
+ */
+ private function getTokenWithoutPaginationData() {
+ if ( $this->tokenWithoutPaginationData === null ) {
+ $this->tokenWithoutPaginationData = $this->getUpdatedToken( [
+ 'offset' => null,
+ ] );
+ }
+ return $this->tokenWithoutPaginationData;
+ }
+
+ /**
+ * Add HTML to Layout.
+ *
+ * @param string $html
+ * @return self
+ */
+ private function addHtml( string $html ) : self {
+ $config = [];
+ [
+ 'contentPanel' => $contentPanel
+ ] = $this->getLayout()->getConfig( $config );
+
+ $contentPanel->addItems( [
+ new Element( [
+ 'content' => new HtmlSnippet( $html ),
+ ] ),
+ ] );
+
+ return $this;
+ }
+
+ /**
+ * Add Pager Output to Layout.
+ *
+ * @param \ParserOutput $parserOutput
+ * @return self
+ */
+ private function addParserOutput( \ParserOutput $parserOutput ) : self {
+ $this->getOutput()->addParserOutputMetadata( $parserOutput );
+ $this->addHTML( $parserOutput->getText() );
+
+ return $this;
+ }
+
+ /**
+ * Add Tab content to Layout
+ *
+ * @param string $par
+ * @return self
+ */
+ private function addTabContent( string $par ) : self {
+ $startTime = $this->eventLogger->getTime();
+
+ switch ( $par ) {
+ case $this->getTabParam( 'preliminary-check' ):
+ $pager = $this->preliminaryCheckPagerFactory->createPager( $this->getContext() );
+ $hasIpTargets = (bool)array_filter(
+ $this->getTokenData()['targets'] ?? [],
+ function ( $target ) {
+ return IPUtils::isIPAddress( $target );
+ }
+ );
+
+ if ( $pager->getNumRows() ) {
+ $this->addParserOutput( $pager->getFullOutput() );
+ } elseif ( !$hasIpTargets ) {
+ $this->addHTML(
+ $this->msg( 'checkuser-investigate-notice-no-results' )->parse()
+ );
+ }
+
+ if ( $hasIpTargets ) {
+ $compareParam = $this->getTabParam( 'compare' );
+ // getFullURL handles the query params:
+ // https://www.mediawiki.org/wiki/Help:Links#External_links_to_internal_pages
+ $link = $this->getPageTitle( $compareParam )->getFullURL( [
+ 'token' => $this->getTokenWithoutPaginationData(),
+ ] );
+ $message = $this->msg( 'checkuser-investigate-preliminary-notice-ip-targets', $link )->parse();
+ $this->addHTML( new MessageWidget( [
+ 'type' => 'notice',
+ 'label' => new HtmlSnippet( $message )
+ ] ) );
+ }
+
+ $this->logQuery( [
+ 'tab' => 'preliminary-check',
+ 'resultsCount' => $pager->getNumRows(),
+ 'resultsIncomplete' => false,
+ 'queryTime' => $this->eventLogger->getTime() - $startTime,
+ ] );
+
+ break;
+
+ case $this->getTabParam( 'compare' ):
+ $pager = $this->comparePagerFactory->createPager( $this->getContext() );
+ $numRows = $pager->getNumRows();
+
+ if ( $numRows ) {
+ $targetsOverLimit = $pager->getTargetsOverLimit();
+ if ( $targetsOverLimit ) {
+ $message = $this->msg(
+ 'checkuser-investigate-compare-notice-exceeded-limit',
+ $this->getLanguage()->commaList( $targetsOverLimit )
+ )->parse();
+ $this->addHTML( new MessageWidget( [
+ 'type' => 'warning',
+ 'label' => new HtmlSnippet( $message )
+ ] ) );
+ }
+
+ // Only start the tour if there are results on the page.
+ $this->launchTour( self::TOUR_INVESTIGATE );
+
+ $this->addParserOutput( $pager->getFullOutput() );
+ } else {
+ $messageKey = $this->usingFilters() ?
+ 'checkuser-investigate-compare-notice-no-results-filters' :
+ 'checkuser-investigate-compare-notice-no-results';
+ $message = $this->msg( $messageKey )->parse();
+ $this->addHTML( new MessageWidget( [
+ 'type' => 'warning',
+ 'label' => new HtmlSnippet( $message )
+ ] ) );
+ }
+
+ $this->logQuery( [
+ 'tab' => 'compare',
+ 'resultsCount' => $numRows,
+ 'resultsIncomplete' => $numRows && $targetsOverLimit,
+ 'queryTime' => $this->eventLogger->getTime() - $startTime,
+ ] );
+
+ break;
+
+ case $this->getTabParam( 'timeline' ):
+ $pager = $this->timelinePagerFactory->createPager( $this->getContext() );
+ $numRows = $pager->getNumRows();
+
+ if ( $numRows ) {
+ $this->addParserOutput( $pager->getFullOutput() );
+ } else {
+ $messageKey = $this->usingFilters() ?
+ 'checkuser-investigate-timeline-notice-no-results-filters' :
+ 'checkuser-investigate-timeline-notice-no-results';
+ $message = $this->msg( $messageKey )->parse();
+ $this->addHTML( new MessageWidget( [
+ 'type' => 'warning',
+ 'label' => new HtmlSnippet( $message )
+ ] ) );
+ }
+
+ $this->logQuery( [
+ 'tab' => 'timeline',
+ 'resultsCount' => $pager->getNumRows(),
+ 'resultsIncomplete' => false,
+ 'queryTime' => $this->eventLogger->getTime() - $startTime,
+ ] );
+
+ break;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param array $logData
+ */
+ private function logQuery( array $logData ) : void {
+ $relevantTargetsCount = count( array_diff(
+ $this->getTokenData()['targets'] ?? [],
+ $this->getTokenData()['exclude-targets'] ?? []
+ ) );
+
+ $this->eventLogger->logEvent( array_merge(
+ [
+ 'action' => 'query',
+ 'relevantTargetsCount' => $relevantTargetsCount,
+ ],
+ $logData
+ ) );
+ }
+
+ /**
+ * Given a tab name, return the subpage $par.
+ *
+ * Since the page title is always in the content language, the subpage should be also.
+ *
+ * @param string $tab
+ *
+ * @return string
+ */
+ private function getTabParam( string $tab ) : string {
+ $name = $this->getTabMessage( $tab )->inLanguage( $this->contentLanguage )->text();
+ return str_replace( ' ', '_', $name );
+ }
+
+ /**
+ * Given a tab name, return the subpage tab message.
+ *
+ * @param string $tab
+ *
+ * @return Message
+ */
+ private function getTabMessage( string $tab ) : Message {
+ return $this->msg( 'checkuser-investigate-tab-' . $tab );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDescription() {
+ return $this->msg( $this->getMessagePrefix() )->text();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getMessagePrefix() {
+ return 'checkuser-' . strtolower( $this->getName() );
+ }
+
+ /**
+ * Add page subtitle including the name of the targets in the investigation,
+ * and a block form. Add the block form elements that are visible initially,
+ * to avoid a flicker on page load.
+ */
+ private function addBlockForm() {
+ $targets = $this->getTokenData()['targets'] ?? [];
+ if ( $targets ) {
+ $excludeTargets = $this->getTokenData()['exclude-targets'] ?? [];
+
+ $this->getOutput()->addJsConfigVars( [
+ 'wgCheckUserInvestigateTargets' => $targets,
+ 'wgCheckUserInvestigateExcludeTargets' => $excludeTargets,
+ ] );
+
+ $targetsText = $this->getLanguage()->listToText( array_map( function ( $target ) {
+ return Html::rawElement( 'strong', [], htmlspecialchars( $target ) );
+ }, $targets ) );
+ $subtitle = $this->msg( 'checkuser-investigate-page-subtitle', $targetsText );
+
+ // Placeholder, to allow the FieldLayout label to be shown before the
+ // JavaScript loads. This will be replaced by a TagMultiselect (which
+ // has not yet been implemented in PHP).
+ $placeholderWidget = new Widget( [
+ 'classes' => [ 'ext-checkuser-investigate-subtitle-placeholder-widget' ],
+ ] );
+ $targetsLayout = new FieldLayout(
+ $placeholderWidget,
+ [
+ 'label' => new HtmlSnippet( $subtitle->parse() ),
+ 'align' => 'top',
+ 'infusable' => true,
+ 'classes' => [
+ 'ext-checkuser-investigate-subtitle-targets-layout'
+ ]
+ ]
+ );
+
+ $blockButton = new ButtonWidget( [
+ 'infusable' => true,
+ 'label' => $this->msg( 'checkuser-investigate-subtitle-block-button-label' )->text(),
+ 'flags' => [ 'primary', 'progressive' ],
+ 'classes' => [
+ 'ext-checkuser-investigate-subtitle-block-button',
+ ],
+ ] );
+ $buttonsLayout = new FieldLayout(
+ new Widget( [
+ 'content' => new HorizontalLayout( [
+ 'items' => [
+ $blockButton,
+ ]
+ ] )
+ ] ),
+ [
+ 'align' => 'top',
+ 'infusable' => true,
+ ]
+ );
+
+ $blockFieldset = new FieldsetLayout( [
+ 'classes' => [
+ 'ext-checkuser-investigate-subtitle-fieldset'
+ ],
+ 'items' => [
+ $targetsLayout,
+ $buttonsLayout,
+ ]
+ ] );
+
+ $this->getOutput()->prependHTML(
+ $blockFieldset
+ );
+ }
+ }
+
+ /**
+ * Add buttons to start a new investigation and linking
+ * to InvestigateLog page
+ *
+ * @param bool $newTab Whether to open the link in a new tab
+ * @param bool $logOnly Whether to show only the log button
+ */
+ private function addIndicators( $newTab = true, $logOnly = false ) {
+ $log = new ButtonWidget( [
+ 'label' => $this->msg( 'checkuser-investigate-indicator-logs' )->text(),
+ 'href' => self::getTitleFor( 'InvestigateLog' )->getLinkURL(),
+ 'target' => $newTab ? '_blank' : '',
+ ] );
+
+ $newForm = new ButtonWidget( [
+ 'label' => $this->msg( 'checkuser-investigate-indicator-new-investigation' )->text(),
+ 'href' => $this->getPageTitle()->getLinkURL(),
+ 'target' => $newTab ? '_blank' : '',
+ ] );
+
+ if ( $logOnly ) {
+ $this->getOutput()->setIndicators( [
+ 'ext-checkuser-investigation-btns' => $log,
+ ] );
+ } else {
+ $this->getOutput()->setIndicators( [
+ 'ext-checkuser-investigation-btns' => new ButtonGroupWidget( [
+ 'classes' => [ 'ext-checkuser-investigate-indicators' ],
+ 'items' => [ $newForm, $log ],
+ ] ),
+ ] );
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getForm() {
+ if ( $this->form === null ) {
+ $this->form = parent::getForm();
+ }
+
+ return $this->form;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getFormFields() {
+ $data = $this->getTokenData();
+ $prefix = $this->getMessagePrefix();
+
+ $duration = [
+ 'type' => 'select',
+ 'name' => 'duration',
+ 'label-message' => $prefix . '-duration-label',
+ 'options-messages' => [
+ $prefix . '-duration-option-all' => '',
+ $prefix . '-duration-option-1w' => 'P1W',
+ $prefix . '-duration-option-2w' => 'P2W',
+ $prefix . '-duration-option-30d' => 'P30D',
+ ],
+ // If this duration in the URL is not in the list, "all" is displayed.
+ 'default' => $this->getDuration(),
+ 'validation-callback' => function ( $value ) {
+ if ( !$this->durationManager->isValid( $value ) ) {
+ return $this->getMessagePrefix() . '-duration-invalid';
+ }
+
+ return true;
+ }
+ ];
+
+ if ( $data === [] ) {
+ $this->getOutput()->addJsConfigVars( 'wgCheckUserInvestigateMaxTargets', self::MAX_TARGETS );
+
+ return [
+ 'Targets' => [
+ 'type' => 'usersmultiselect',
+ 'name' => 'targets',
+ 'label-message' => $prefix . '-targets-label',
+ 'placeholder' => $this->msg( $prefix . '-targets-placeholder' )->text(),
+ 'id' => 'targets',
+ 'required' => true,
+ 'max' => self::MAX_TARGETS,
+ 'exists' => true,
+ 'ipallowed' => true,
+ 'iprange' => true,
+ 'default' => implode( "\n", $data['targets'] ?? [] ),
+ 'input' => [
+ 'autocomplete' => false,
+ ],
+ ],
+ 'Duration' => $duration,
+ 'Reason' => [
+ 'type' => 'text',
+ 'name' => 'reason',
+ 'label-message' => $prefix . '-reason-label',
+ 'required' => true,
+ 'autocomplete' => false,
+ ],
+ ];
+ }
+
+ $fields = [];
+
+ // Filters for both Compare & Timeline
+ $compareTab = $this->getTabParam( 'compare' );
+ $timelineTab = $this->getTabParam( 'timeline' );
+
+ // Filters for both Compare & Timeline
+ if ( in_array( $this->par, [ $compareTab, $timelineTab ], true ) ) {
+ $fields['ExcludeTargets'] = [
+ 'type' => 'usersmultiselect',
+ 'name' => 'exclude-targets',
+ 'label-message' => $prefix . '-filters-exclude-targets-label',
+ 'exists' => true,
+ 'required' => false,
+ 'ipallowed' => true,
+ 'iprange' => false,
+ 'default' => implode( "\n", $data['exclude-targets'] ?? [] ),
+ 'input' => [
+ 'autocomplete' => false,
+ ],
+ ];
+ $fields['Duration'] = $duration;
+ }
+
+ if ( $this->par === $compareTab ) {
+ $fields['Targets'] = [
+ 'type' => 'hidden',
+ 'name' => 'targets',
+ ];
+ }
+
+ if ( $this->par === $timelineTab ) {
+ // @TODO Add filters specific to the timeline tab.
+ }
+
+ return $fields;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function alterForm( HTMLForm $form ) {
+ // Not done by default in OOUI forms, but done here to match
+ // intended design in T237034. See FormSpecialPage::getForm
+ if ( $this->getTokenData() === [] ) {
+ $form->setWrapperLegendMsg( $this->getMessagePrefix() . '-legend' );
+ } else {
+ $tabs = [ $this->getTabParam( 'compare' ), $this->getTabParam( 'timeline' ) ];
+ if ( in_array( $this->par, $tabs ) ) {
+ $form->setAction( $this->getRequest()->getRequestURL() );
+ $form->setWrapperLegendMsg( $this->getMessagePrefix() . '-filters-legend' );
+ // If the page is a result of a POST then validation failed, and the form should be open.
+ // If the page is a result of a GET then validation succeeded and the form should be closed.
+ $form->setCollapsibleOptions( !$this->getRequest()->wasPosted() );
+ }
+ }
+ }
+
+ /**
+ * Get data from the request token.
+ *
+ * @return array
+ */
+ private function getTokenData() : array {
+ if ( $this->tokenData === null ) {
+ $this->tokenData = $this->tokenQueryManager->getDataFromRequest( $this->getRequest() );
+ }
+
+ return $this->tokenData;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function onSubmit( array $data ) {
+ $update = [
+ 'offset' => null,
+ ];
+
+ if ( isset( $data['Reason'] ) ) {
+ $update['reason'] = $data['Reason'];
+ }
+ if ( isset( $data['ExcludeTargets' ] ) ) {
+ $submittedExcludeTargets = $this->getArrayFromField( $data, 'ExcludeTargets' );
+ $update['exclude-targets'] = $submittedExcludeTargets;
+ }
+ if ( isset( $data['Targets' ] ) ) {
+ $tokenData = $this->getTokenData();
+
+ $submittedTargets = $this->getArrayFromField( $data, 'Targets' );
+ $update['targets'] = $submittedTargets;
+
+ $this->addLogEntries(
+ $update['targets'],
+ $update['reason'] ?? $tokenData['reason']
+ );
+
+ $update['targets'] = array_unique( array_merge(
+ $update['targets'],
+ $tokenData['targets'] ?? []
+ ) );
+ }
+
+ $token = $this->getUpdatedToken( $update );
+
+ if ( isset( $this->par ) && $this->par !== '' ) {
+ // Redirect to the same subpage with an updated token.
+ $url = $this->getRedirectUrl( [
+ 'token' => $token,
+ 'duration' => $data['Duration'] ?: null,
+ ] );
+ } else {
+ // Redirect to compare tab
+ $url = $this->getPageTitle( $this->getTabParam( 'compare' ) )->getFullUrlForRedirect( [
+ 'token' => $token,
+ 'duration' => $data['Duration'] ?: null,
+ ] );
+ }
+ $this->getOutput()->redirect( $url );
+
+ $this->eventLogger->logEvent( [
+ 'action' => 'submit',
+ 'targetsCount' => count( $submittedTargets ?? [] ),
+ 'excludeTargetsCount' => count( $submittedExcludeTargets ?? [] ),
+ ] );
+
+ return \Status::newGood();
+ }
+
+ /**
+ * Add a log entry for each target under investigation.
+ *
+ * @param string[] $targets
+ * @param string $reason
+ */
+ protected function addLogEntries( array $targets, string $reason ) {
+ $logType = 'investigate';
+
+ foreach ( $targets as $target ) {
+ if ( IPUtils::isIPAddress( $target ) ) {
+ $targetType = 'ip';
+ $targetId = 0;
+ } else {
+ // The form validated that the user exists on this wiki
+ $targetType = 'user';
+ $targetId = User::idFromName( $target );
+ }
+
+ SpecialCheckUser::addLogEntry(
+ $logType,
+ $targetType,
+ $target,
+ $reason,
+ $targetId
+ );
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getGroupName() {
+ return 'users';
+ }
+
+ /**
+ * Get an updated token.
+ *
+ * Preforms an array merge on the updates with what is in the current token.
+ * Setting a value to null will remove it.
+ *
+ * @param array $update
+ * @return string
+ */
+ private function getUpdatedToken( array $update ) : string {
+ return $this->tokenQueryManager->updateToken(
+ $this->getRequest(),
+ $update
+ );
+ }
+
+ /**
+ * Get a redirect URL with a new query string.
+ *
+ * @param array $update
+ * @return string
+ */
+ private function getRedirectUrl( array $update ) : string {
+ $parts = wfParseURL( $this->getRequest()->getFullRequestURL() );
+ $query = isset( $parts['query'] ) ? wfCgiToArray( $parts['query'] ) : [];
+ $data = array_filter( array_merge( $query, $update ), function ( $value ) {
+ return $value !== null;
+ } );
+ $parts['query'] = wfArrayToCgi( $data );
+ return wfAssembleUrl( $parts );
+ }
+
+ /**
+ * Get an array of values from a new line seperated field.
+ *
+ * @param array $data
+ * @param string $field
+ * @return string[]
+ */
+ private function getArrayFromField( array $data, string $field ) : array {
+ if ( !isset( $data[$field] ) ) {
+ return [];
+ }
+
+ if ( !is_string( $data[$field] ) ) {
+ return [];
+ }
+
+ if ( $data[$field] === '' ) {
+ return [];
+ }
+
+ return explode( "\n", $data[$field] );
+ }
+
+ /**
+ * Determine if the filters are in use by the current request.
+ *
+ * @return bool
+ */
+ private function usingFilters() : bool {
+ return count( $this->getTokenData()['exclude-targets'] ?? [] ) > 0
+ || $this->getDuration() !== '';
+ }
+
+ /**
+ * Get the duration from the request.
+ *
+ * @return string
+ */
+ private function getDuration() : string {
+ return $this->durationManager->getFromRequest( $this->getRequest() );
+ }
+
+ /**
+ * Relaunch Tour Intercept
+ *
+ * Intercepts a request and relaunches the tour by updating the user preferences and setting
+ * a redirect.
+ *
+ * @return bool If the tour is being relaunched and a redirect was set.
+ */
+ public function reLaunchTour() : bool {
+ if ( $this->getRequest()->getMethod() !== 'GET' ) {
+ return false;
+ }
+
+ if ( !in_array( $this->getRequest()->getVal( 'tour' ), self::TOURS, true ) ) {
+ return false;
+ }
+
+ $user = $this->getUser();
+
+ $options = [];
+ switch ( $this->getRequest()->getVal( 'tour' ) ) {
+ case self::TOUR_INVESTIGATE_FORM:
+ $options = [
+ Preferences::INVESTIGATE_FORM_TOUR_SEEN,
+ Preferences::INVESTIGATE_TOUR_SEEN,
+ ];
+ break;
+ case self::TOUR_INVESTIGATE:
+ $options = [
+ Preferences::INVESTIGATE_TOUR_SEEN,
+ ];
+ break;
+ }
+
+ foreach ( $options as $option ) {
+ $this->userOptionsManager->setOption( $user, $option, null );
+ }
+
+ $this->userOptionsManager->saveOptions( $user );
+
+ $parts = wfParseUrl( $this->getRequest()->getFullRequestURL() );
+ $query = wfCgiToArray( $parts['query'] ?? '' );
+ $query['tour'] = null;
+ $parts['query'] = wfArrayToCgi( $query );
+
+ $this->getOutput()->redirect( wfAssembleUrl( $parts ) );
+
+ return true;
+ }
+
+ /**
+ * Launches the tour unless the user has already completed or canceled it.
+ *
+ * @param string $tour
+ * @return void
+ */
+ private function launchTour( string $tour ) : void {
+ $user = $this->getUser();
+
+ $preference = '';
+ $step = '';
+
+ switch ( $tour ) {
+ case self::TOUR_INVESTIGATE_FORM:
+ $preference = Preferences::INVESTIGATE_FORM_TOUR_SEEN;
+ $step = 'targets';
+ break;
+ case self::TOUR_INVESTIGATE:
+ $preference = Preferences::INVESTIGATE_TOUR_SEEN;
+ $step = 'useragents';
+ break;
+ default:
+ return;
+ }
+
+ if ( $this->userOptionsManager->getOption( $user, $preference ) ) {
+ return;
+ }
+
+ $this->tourLauncher->launchTour( $tour, $step );
+ }
+
+ /**
+ * Add the subtitle to the page.
+ */
+ private function addSubtitle() : void {
+ $subpage = false;
+ $token = null;
+ $tour = self::TOUR_INVESTIGATE_FORM;
+
+ if ( $this->getTokenData() !== [] ) {
+ $token = $this->getTokenWithoutPaginationData();
+ $subpage = $this->getTabParam( 'compare' );
+ $tour = self::TOUR_INVESTIGATE;
+ }
+
+ $links = [
+ $this->getLinkRenderer()->makeLink( self::getTitleValueFor( 'CheckUser' ) ),
+ $this->tourLauncher->makeTourLink(
+ $tour,
+ $this->getPageTitle( $subpage ),
+ $this->msg( 'checkuser-investigate-subtitle-link-restart-tour' )->text(),
+ [],
+ [
+ 'token' => $token,
+ 'duration' => $this->getDuration() ?: null,
+ ]
+ ),
+ ];
+
+ $this->subtitleLinksHookRunner->onCheckUserSubtitleLinks( $this->getContext(), $links );
+
+ $subtitle = implode( ' | ', array_filter( $links, function ( $link ) {
+ return (bool)$link;
+ } ) );
+
+ $this->getOutput()->setSubtitle( $subtitle );
+ }
+}