Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.56% |
478 / 485 |
|
87.10% |
27 / 31 |
CRAP | |
0.00% |
0 / 1 |
SpecialInvestigate | |
98.56% |
478 / 485 |
|
87.10% |
27 / 31 |
87 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
preHtml | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
5 | |||
getLayout | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
addTabs | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
2 | |||
getTokenWithoutPaginationData | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
addHtml | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
addParserOutput | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
addTabContent | |
97.73% |
86 / 88 |
|
0.00% |
0 / 1 |
17 | |||
logQuery | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
getTabParam | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getTabMessage | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDescription | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMessagePrefix | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addBlockForm | |
100.00% |
70 / 70 |
|
100.00% |
1 / 1 |
3 | |||
addIndicators | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
5 | |||
getDisplayFormat | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getForm | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getFormFields | |
100.00% |
67 / 67 |
|
100.00% |
1 / 1 |
4 | |||
alterForm | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
getTokenData | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
onSubmit | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
8 | |||
addLogEntries | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUpdatedToken | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getRedirectUrl | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
getArrayFromField | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
4.37 | |||
usingFilters | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getDuration | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
launchTour | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
5.09 | |||
addSubtitle | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | |
3 | namespace MediaWiki\CheckUser\Investigate; |
4 | |
5 | use HTMLForm; |
6 | use Language; |
7 | use MediaWiki\CheckUser\GuidedTour\TourLauncher; |
8 | use MediaWiki\CheckUser\Hook\CheckUserSubtitleLinksHook; |
9 | use MediaWiki\CheckUser\HookHandler\Preferences; |
10 | use MediaWiki\CheckUser\Investigate\Pagers\ComparePager; |
11 | use MediaWiki\CheckUser\Investigate\Pagers\PagerFactory; |
12 | use MediaWiki\CheckUser\Investigate\Pagers\PreliminaryCheckPager; |
13 | use MediaWiki\CheckUser\Investigate\Pagers\TimelinePager; |
14 | use MediaWiki\CheckUser\Investigate\Pagers\TimelinePagerFactory; |
15 | use MediaWiki\CheckUser\Investigate\Utilities\DurationManager; |
16 | use MediaWiki\CheckUser\Investigate\Utilities\EventLogger; |
17 | use MediaWiki\CheckUser\Services\CheckUserLogService; |
18 | use MediaWiki\CheckUser\Services\TokenQueryManager; |
19 | use MediaWiki\Html\Html; |
20 | use MediaWiki\Linker\LinkRenderer; |
21 | use MediaWiki\Permissions\PermissionManager; |
22 | use MediaWiki\SpecialPage\FormSpecialPage; |
23 | use MediaWiki\Status\Status; |
24 | use MediaWiki\User\Options\UserOptionsManager; |
25 | use MediaWiki\User\UserFactory; |
26 | use MediaWiki\User\UserIdentityLookup; |
27 | use Message; |
28 | use OOUI\ButtonGroupWidget; |
29 | use OOUI\ButtonWidget; |
30 | use OOUI\Element; |
31 | use OOUI\FieldLayout; |
32 | use OOUI\FieldsetLayout; |
33 | use OOUI\HorizontalLayout; |
34 | use OOUI\HtmlSnippet; |
35 | use OOUI\IndexLayout; |
36 | use OOUI\MessageWidget; |
37 | use OOUI\TabOptionWidget; |
38 | use OOUI\Tag; |
39 | use OOUI\Widget; |
40 | use ParserOutput; |
41 | use Wikimedia\IPUtils; |
42 | |
43 | class 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 | } |