Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
61.34% |
192 / 313 |
|
41.67% |
10 / 24 |
CRAP | |
0.00% |
0 / 1 |
AbstractCheckUserPager | |
61.34% |
192 / 313 |
|
41.67% |
10 / 24 |
362.23 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
46 / 46 |
|
100.00% |
1 / 1 |
4 | |||
setPeriodCondition | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
getTimeRangeString | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
getBlockFlag | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
12 | |||
getSelfLink | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getTitleValue | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getPageTitle | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getFormattedTimestamp | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getEmptyBody | |
75.86% |
22 / 29 |
|
0.00% |
0 / 1 |
3.13 | |||
userBlockFlags | |
37.50% |
18 / 48 |
|
0.00% |
0 / 1 |
69.93 | |||
buildGroupLink | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
userWasBlocked | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
isValidRange | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getIpConds | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
getIndexField | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTimestampField | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getStartBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEndBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getNavigationBuilder | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
30 | |||
getCheckUserHelperFieldset | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
getCheckUserHelperFieldsetHTML | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getQueryInfo | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getQueryInfoForCuChanges | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getQueryInfoForCuLogEvent | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getQueryInfoForCuPrivateEvent | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
groupResultsByIndexField | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
reallyDoQuery | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
buildQueryInfo | |
100.00% |
41 / 41 |
|
100.00% |
1 / 1 |
11 |
1 | <?php |
2 | |
3 | namespace MediaWiki\CheckUser\CheckUser\Pagers; |
4 | |
5 | use ExtensionRegistry; |
6 | use HtmlArmor; |
7 | use IContextSource; |
8 | use LogicException; |
9 | use MediaWiki\Block\AbstractBlock; |
10 | use MediaWiki\Block\DatabaseBlock; |
11 | use MediaWiki\CheckUser\CheckUser\CheckUserPagerNavigationBuilder; |
12 | use MediaWiki\CheckUser\CheckUser\Widgets\HTMLFieldsetCheckUser; |
13 | use MediaWiki\CheckUser\CheckUserQueryInterface; |
14 | use MediaWiki\CheckUser\Services\CheckUserLogService; |
15 | use MediaWiki\CheckUser\Services\CheckUserLookupUtils; |
16 | use MediaWiki\CheckUser\Services\TokenQueryManager; |
17 | use MediaWiki\Extension\GlobalBlocking\GlobalBlocking; |
18 | use MediaWiki\Extension\TorBlock\TorExitNodes; |
19 | use MediaWiki\Html\FormOptions; |
20 | use MediaWiki\Html\Html; |
21 | use MediaWiki\Html\TemplateParser; |
22 | use MediaWiki\Linker\LinkRenderer; |
23 | use MediaWiki\MediaWikiServices; |
24 | use MediaWiki\Navigation\PagerNavigationBuilder; |
25 | use MediaWiki\Pager\RangeChronologicalPager; |
26 | use MediaWiki\SpecialPage\SpecialPage; |
27 | use MediaWiki\SpecialPage\SpecialPageFactory; |
28 | use MediaWiki\Title\Title; |
29 | use MediaWiki\Title\TitleValue; |
30 | use MediaWiki\User\CentralId\CentralIdLookup; |
31 | use MediaWiki\User\UserFactory; |
32 | use MediaWiki\User\UserGroupManager; |
33 | use MediaWiki\User\UserGroupMembership; |
34 | use MediaWiki\User\UserIdentity; |
35 | use MediaWiki\User\UserIdentityLookup; |
36 | use MediaWiki\Utils\MWTimestamp; |
37 | use RequestContext; |
38 | use stdClass; |
39 | use Wikimedia\Rdbms\Database\DbQuoter; |
40 | use Wikimedia\Rdbms\FakeResultWrapper; |
41 | use Wikimedia\Rdbms\IConnectionProvider; |
42 | use Wikimedia\Rdbms\IResultWrapper; |
43 | use Wikimedia\Rdbms\SelectQueryBuilder; |
44 | |
45 | abstract class AbstractCheckUserPager extends RangeChronologicalPager implements CheckUserQueryInterface { |
46 | |
47 | /** |
48 | * Form fields that when paging should be set and managed |
49 | * by the token. Used so the client cannot generate results |
50 | * that do not match the original request which generated |
51 | * the associated CheckUserLog entry. |
52 | */ |
53 | public const TOKEN_MANAGED_FIELDS = [ |
54 | 'reason', |
55 | 'checktype', |
56 | 'period', |
57 | 'dir', |
58 | 'limit', |
59 | 'offset', |
60 | ]; |
61 | |
62 | /** |
63 | * Null if $target is a user. |
64 | * Boolean is $target is a IP / range. |
65 | * - False if XFF is not appended |
66 | * - True if XFF is appended |
67 | * |
68 | * @var null|bool |
69 | */ |
70 | protected ?bool $xfor = null; |
71 | |
72 | /** @var string The type of CheckUserLog entry this check should generate. */ |
73 | private string $logType; |
74 | |
75 | /** @var FormOptions The submitted form data in a helper class */ |
76 | protected FormOptions $opts; |
77 | |
78 | protected UserIdentity $target; |
79 | |
80 | /** @var bool Should Special:CheckUser read from the new event tables. */ |
81 | protected bool $eventTableReadNew; |
82 | |
83 | /** @var bool Should Special:CheckUser display Client Hints data. */ |
84 | protected bool $displayClientHints; |
85 | |
86 | /** |
87 | * @var string one of the SpecialCheckUser::SUBTYPE_... constants used by this abstract pager |
88 | * to know what the current checktype is. |
89 | */ |
90 | protected string $checkType; |
91 | |
92 | protected UserGroupManager $userGroupManager; |
93 | protected CentralIdLookup $centralIdLookup; |
94 | private TokenQueryManager $tokenQueryManager; |
95 | private SpecialPageFactory $specialPageFactory; |
96 | private UserIdentityLookup $userIdentityLookup; |
97 | private CheckUserLogService $checkUserLogService; |
98 | protected TemplateParser $templateParser; |
99 | protected UserFactory $userFactory; |
100 | protected CheckUserLookupUtils $checkUserLookupUtils; |
101 | |
102 | /** |
103 | * @param FormOptions $opts |
104 | * @param UserIdentity $target |
105 | * @param string $logType |
106 | * @param TokenQueryManager $tokenQueryManager |
107 | * @param UserGroupManager $userGroupManager |
108 | * @param CentralIdLookup $centralIdLookup |
109 | * @param IConnectionProvider $dbProvider |
110 | * @param SpecialPageFactory $specialPageFactory |
111 | * @param UserIdentityLookup $userIdentityLookup |
112 | * @param CheckUserLogService $checkUserLogService |
113 | * @param UserFactory $userFactory |
114 | * @param CheckUserLookupUtils $checkUserLookupUtils |
115 | * @param IContextSource|null $context |
116 | * @param LinkRenderer|null $linkRenderer |
117 | * @param ?int $limit |
118 | */ |
119 | public function __construct( |
120 | FormOptions $opts, |
121 | UserIdentity $target, |
122 | string $logType, |
123 | TokenQueryManager $tokenQueryManager, |
124 | UserGroupManager $userGroupManager, |
125 | CentralIdLookup $centralIdLookup, |
126 | IConnectionProvider $dbProvider, |
127 | SpecialPageFactory $specialPageFactory, |
128 | UserIdentityLookup $userIdentityLookup, |
129 | CheckUserLogService $checkUserLogService, |
130 | UserFactory $userFactory, |
131 | CheckUserLookupUtils $checkUserLookupUtils, |
132 | IContextSource $context = null, |
133 | LinkRenderer $linkRenderer = null, |
134 | ?int $limit = null |
135 | ) { |
136 | $this->opts = $opts; |
137 | $this->target = $target; |
138 | $this->logType = $logType; |
139 | |
140 | $this->mDb = $dbProvider->getReplicaDatabase(); |
141 | |
142 | parent::__construct( $context, $linkRenderer ); |
143 | |
144 | $maximumRowCount = $this->getConfig()->get( 'CheckUserMaximumRowCount' ); |
145 | $this->mDefaultLimit = $limit ?? $maximumRowCount; |
146 | if ( $this->opts->getValue( 'limit' ) ) { |
147 | $this->mLimit = min( |
148 | $this->opts->getValue( 'limit' ), |
149 | $this->getConfig()->get( 'CheckUserMaximumRowCount' ) |
150 | ); |
151 | } else { |
152 | $this->mLimit = $maximumRowCount; |
153 | } |
154 | |
155 | $this->mLimitsShown = [ |
156 | $maximumRowCount / 25, |
157 | $maximumRowCount / 10, |
158 | $maximumRowCount / 5, |
159 | $maximumRowCount / 2, |
160 | $maximumRowCount, |
161 | ]; |
162 | |
163 | $this->mLimitsShown = array_map( 'ceil', $this->mLimitsShown ); |
164 | $this->mLimitsShown = array_unique( $this->mLimitsShown ); |
165 | $this->eventTableReadNew = boolval( |
166 | $this->getConfig()->get( 'CheckUserEventTablesMigrationStage' ) & SCHEMA_COMPAT_READ_NEW |
167 | ); |
168 | $this->displayClientHints = $this->getConfig()->get( 'CheckUserDisplayClientHints' ); |
169 | |
170 | $this->userGroupManager = $userGroupManager; |
171 | $this->centralIdLookup = $centralIdLookup; |
172 | $this->tokenQueryManager = $tokenQueryManager; |
173 | $this->specialPageFactory = $specialPageFactory; |
174 | $this->userIdentityLookup = $userIdentityLookup; |
175 | $this->checkUserLogService = $checkUserLogService; |
176 | $this->userFactory = $userFactory; |
177 | $this->checkUserLookupUtils = $checkUserLookupUtils; |
178 | |
179 | $this->templateParser = new TemplateParser( __DIR__ . '/../../../templates' ); |
180 | |
181 | // Get any set token data. Used for paging without adding extra logs |
182 | $tokenData = $this->tokenQueryManager->getDataFromRequest( $this->getRequest() ); |
183 | if ( !$tokenData ) { |
184 | // Log if the token data is not set. A token will only be generated by |
185 | // the server for CheckUser for paging links after running a check. |
186 | // It will also only be valid if not tampered with as it's encrypted. |
187 | // Paging through the entries won't need an extra log entry. |
188 | $this->checkUserLogService->addLogEntry( |
189 | $this->getUser(), |
190 | $this->logType, |
191 | $target->getId() ? 'user' : 'ip', |
192 | $target->getName(), |
193 | $this->opts->getValue( 'reason' ), |
194 | $target->getId() |
195 | ); |
196 | } |
197 | |
198 | $this->setPeriodCondition(); |
199 | } |
200 | |
201 | /** |
202 | * Generate the cutoff timestamp condition for the query |
203 | */ |
204 | protected function setPeriodCondition(): void { |
205 | $period = $this->opts->getValue( 'period' ); |
206 | if ( $period ) { |
207 | $cutoffTime = MWTimestamp::getInstance(); |
208 | $cutoffTime->timestamp->setTime( 0, 0 )->modify( "-$period day" ); |
209 | // Call to RangeChronologicalPager::getDateRangeCond sets |
210 | // $this->startOffset to the $cutoffTime. The call does |
211 | // not set $this->endOffset as the empty string is provided. |
212 | $this->getDateRangeCond( $cutoffTime->getTimestamp(), '' ); |
213 | } |
214 | } |
215 | |
216 | /** |
217 | * Get formatted timestamp(s) to show the time of first and last change. |
218 | * If both timestamps are the same, it will be shown only once. |
219 | * |
220 | * @param string $first Timestamp of the first change |
221 | * @param string $last Timestamp of the last change |
222 | * @return string |
223 | */ |
224 | protected function getTimeRangeString( string $first, string $last ): string { |
225 | if ( $first === $last ) { |
226 | return $this->getFormattedTimestamp( $first ); |
227 | } else { |
228 | return $this->msg( 'checkuser-time-range' ) |
229 | ->dateTimeParams( $first, $last ) |
230 | ->escaped(); |
231 | } |
232 | } |
233 | |
234 | /** |
235 | * Get a link to block information about the passed block for displaying to the user. |
236 | * |
237 | * @param DatabaseBlock $block |
238 | * @return string |
239 | */ |
240 | protected function getBlockFlag( DatabaseBlock $block ): string { |
241 | if ( $block->getType() === DatabaseBlock::TYPE_AUTO ) { |
242 | $ret = $this->getLinkRenderer()->makeKnownLink( |
243 | SpecialPage::getTitleFor( 'BlockList' ), |
244 | $this->msg( 'checkuser-blocked' )->text(), |
245 | [], |
246 | [ 'wpTarget' => "#{$block->getId()}" ] |
247 | ); |
248 | } else { |
249 | $userPage = Title::makeTitle( NS_USER, $block->getTargetName() ); |
250 | $ret = $this->getLinkRenderer()->makeKnownLink( |
251 | SpecialPage::getTitleFor( 'Log' ), |
252 | $this->msg( 'checkuser-blocked' )->text(), |
253 | [], |
254 | [ |
255 | 'type' => 'block', |
256 | 'page' => $userPage->getPrefixedText() |
257 | ] |
258 | ); |
259 | |
260 | // Add the blocked range if the block is on a range |
261 | if ( $block->getType() === DatabaseBlock::TYPE_RANGE ) { |
262 | $ret .= ' - ' . htmlspecialchars( $block->getTargetName() ); |
263 | } |
264 | } |
265 | |
266 | return Html::rawElement( |
267 | 'strong', |
268 | [ 'class' => 'mw-changeslist-links' ], |
269 | $ret |
270 | ); |
271 | } |
272 | |
273 | /** |
274 | * Get an HTML link (<a> element) to Special:CheckUser |
275 | * |
276 | * @param string $text content to use within <a> tag |
277 | * @param array $params query parameters to use in the URL |
278 | * @return string |
279 | */ |
280 | protected function getSelfLink( string $text, array $params ): string { |
281 | $title = $this->getTitleValue(); |
282 | return $this->getLinkRenderer()->makeKnownLink( |
283 | $title, |
284 | new HtmlArmor( '<bdi>' . htmlspecialchars( $text ) . '</bdi>' ), |
285 | [], |
286 | $params |
287 | ); |
288 | } |
289 | |
290 | /** |
291 | * @param string $page the string title get the TitleValue for. |
292 | * @return TitleValue the associated TitleValue object |
293 | */ |
294 | protected function getTitleValue( string $page = 'CheckUser' ): TitleValue { |
295 | return new TitleValue( |
296 | NS_SPECIAL, |
297 | $this->specialPageFactory->getLocalNameFor( $page ) |
298 | ); |
299 | } |
300 | |
301 | /** |
302 | * @param string $page the string title get the Title for. |
303 | * @return Title the associated Title object |
304 | */ |
305 | protected function getPageTitle( string $page = 'CheckUser' ): Title { |
306 | return Title::newFromLinkTarget( |
307 | $this->getTitleValue( $page ) |
308 | ); |
309 | } |
310 | |
311 | /** |
312 | * Get a formatted timestamp string in the current language |
313 | * for displaying to the user. |
314 | * |
315 | * @param string $timestamp |
316 | * @return string |
317 | */ |
318 | protected function getFormattedTimestamp( string $timestamp ): string { |
319 | return $this->getLanguage()->userTimeAndDate( |
320 | wfTimestamp( TS_MW, $timestamp ), $this->getUser() |
321 | ); |
322 | } |
323 | |
324 | /** |
325 | * Generates the "no matches for X" message. |
326 | * Unless the target was an xff also try |
327 | * to display the time of the last edit. |
328 | * |
329 | * @inheritDoc |
330 | */ |
331 | protected function getEmptyBody(): string { |
332 | if ( !$this->xfor ) { |
333 | // Only attempt to find the last edit or logged action timestamp if the |
334 | // target is a user or an IP. If the target is a XFF then skip this. |
335 | $user = $this->userIdentityLookup->getUserIdentityByName( $this->target->getName() ); |
336 | |
337 | $lastEdit = max( |
338 | $this->mDb->newSelectQueryBuilder() |
339 | ->select( 'rev_timestamp' ) |
340 | ->from( 'revision' ) |
341 | ->where( [ 'actor_name' => $this->target->getName() ] ) |
342 | ->join( 'actor', null, 'actor_id = rev_actor' ) |
343 | ->orderBy( 'rev_timestamp', SelectQueryBuilder::SORT_DESC ) |
344 | ->caller( __METHOD__ ) |
345 | ->fetchField(), |
346 | $this->mDb->newSelectQueryBuilder() |
347 | ->table( 'logging' ) |
348 | ->field( 'log_timestamp' ) |
349 | ->orderBy( 'log_timestamp', SelectQueryBuilder::SORT_DESC ) |
350 | ->join( 'actor', null, 'actor_id=log_actor' ) |
351 | ->where( [ 'actor_name' => $this->target->getName() ] ) |
352 | ->caller( __METHOD__ ) |
353 | ->fetchField() |
354 | ); |
355 | |
356 | if ( $lastEdit ) { |
357 | $lastEditTime = wfTimestamp( TS_MW, $lastEdit ); |
358 | $lang = $this->getLanguage(); |
359 | $contextUser = $this->getUser(); |
360 | // FIXME: don't pass around parsed messages |
361 | return $this->msg( 'checkuser-nomatch-edits', |
362 | $lang->userDate( $lastEditTime, $contextUser ), |
363 | $lang->userTime( $lastEditTime, $contextUser ) |
364 | )->parseAsBlock() . "\n"; |
365 | } |
366 | } |
367 | return $this->msg( 'checkuser-nomatch' )->parseAsBlock() . "\n"; |
368 | } |
369 | |
370 | /** |
371 | * @param string $ip |
372 | * @param UserIdentity $user |
373 | * @return string[] |
374 | */ |
375 | protected function userBlockFlags( string $ip, UserIdentity $user ): array { |
376 | $flags = []; |
377 | |
378 | $block = DatabaseBlock::newFromTarget( $user, $ip ); |
379 | if ( $block instanceof DatabaseBlock ) { |
380 | // Locally blocked |
381 | $flags[] = $this->getBlockFlag( $block ); |
382 | } elseif ( |
383 | $ip == $user->getName() && |
384 | ExtensionRegistry::getInstance()->isLoaded( 'GlobalBlocking' ) && |
385 | GlobalBlocking::getUserBlock( |
386 | $this->userFactory->newFromUserIdentity( $user ), |
387 | $ip |
388 | ) instanceof AbstractBlock |
389 | ) { |
390 | // Globally blocked IP |
391 | $flags[] = '<strong>(' . $this->msg( 'checkuser-gblocked' )->escaped() . ')</strong>'; |
392 | } elseif ( |
393 | $ip == $user->getName() && |
394 | ExtensionRegistry::getInstance()->isLoaded( 'TorBlock' ) && |
395 | TorExitNodes::isExitNode( $ip ) |
396 | ) { |
397 | // Tor exit node |
398 | $flags[] = Html::rawElement( 'strong', [], '(' . $this->msg( 'checkuser-torexitnode' )->escaped() . ')' ); |
399 | } elseif ( $this->userWasBlocked( $user->getName() ) ) { |
400 | // Previously blocked |
401 | $blocklog = $this->getLinkRenderer()->makeKnownLink( |
402 | SpecialPage::getTitleFor( 'Log' ), |
403 | $this->msg( 'checkuser-wasblocked' )->text(), |
404 | [], |
405 | [ |
406 | 'type' => 'block', |
407 | // @todo Use TitleFormatter and PageReference to avoid the global state |
408 | 'page' => Title::makeTitle( NS_USER, $user->getName() )->getPrefixedText() |
409 | ] |
410 | ); |
411 | $flags[] = Html::rawElement( 'strong', [ 'class' => 'mw-changeslist-links' ], $blocklog ); |
412 | } |
413 | |
414 | // Show if account is local only |
415 | if ( $user->getId() && |
416 | $this->centralIdLookup |
417 | ->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW ) === 0 |
418 | ) { |
419 | $flags[] = Html::rawElement( |
420 | 'strong', |
421 | [ 'class' => 'mw-changeslist-links' ], |
422 | $this->msg( 'checkuser-localonly' )->escaped() |
423 | ); |
424 | } |
425 | // Check for extra user rights... |
426 | if ( $user->getId() ) { |
427 | if ( $this->userFactory->newFromUserIdentity( $user )->isLocked() ) { |
428 | $flags[] = Html::rawElement( |
429 | 'strong', |
430 | [ 'class' => 'mw-changeslist-links' ], |
431 | $this->msg( 'checkuser-locked' )->escaped() |
432 | ); |
433 | } |
434 | $list = []; |
435 | foreach ( $this->userGroupManager->getUserGroups( $user ) as $group ) { |
436 | $list[] = self::buildGroupLink( $group ); |
437 | } |
438 | $groups = $this->getLanguage()->commaList( $list ); |
439 | if ( $groups ) { |
440 | $flags[] = Html::rawElement( 'i', [ 'class' => 'mw-changeslist-links' ], $groups ); |
441 | } |
442 | } |
443 | |
444 | return $flags; |
445 | } |
446 | |
447 | /** |
448 | * Format a link to a group description page |
449 | * |
450 | * @param string $group |
451 | * @return string |
452 | */ |
453 | protected static function buildGroupLink( string $group ): string { |
454 | static $cache = []; |
455 | if ( !isset( $cache[$group] ) ) { |
456 | $cache[$group] = UserGroupMembership::getLinkHTML( $group, RequestContext::getMain() ); |
457 | } |
458 | return $cache[$group]; |
459 | } |
460 | |
461 | /** |
462 | * Get whether the user has ever been blocked. |
463 | * |
464 | * @param string $name the username |
465 | * @return bool whether the user with that username has ever been blocked |
466 | */ |
467 | protected function userWasBlocked( string $name ): bool { |
468 | $userpage = Title::makeTitle( NS_USER, $name ); |
469 | |
470 | return (bool)$this->mDb->newSelectQueryBuilder() |
471 | ->table( 'logging' ) |
472 | ->field( '1' ) |
473 | ->conds( [ |
474 | 'log_type' => [ 'block', 'suppress' ], |
475 | 'log_action' => 'block', |
476 | 'log_namespace' => $userpage->getNamespace(), |
477 | 'log_title' => $userpage->getDBkey() |
478 | ] ) |
479 | ->useIndex( 'log_page_time' ) |
480 | ->caller( __METHOD__ ) |
481 | ->fetchField(); |
482 | } |
483 | |
484 | /** |
485 | * @param string $target an IP address or CIDR range |
486 | * @return bool |
487 | * @deprecated Since 1.42. Use CheckUserLookupUtils::isValidIPOrRange instead. |
488 | */ |
489 | public static function isValidRange( string $target ): bool { |
490 | wfDeprecated( __METHOD__, '1.42' ); |
491 | return MediaWikiServices::getInstance()->get( 'CheckUserLookupUtils' )->isValidIPOrRange( $target ); |
492 | } |
493 | |
494 | /** |
495 | * Get the WHERE conditions for an IP address / range, optionally as a XFF. |
496 | * |
497 | * @param DbQuoter $quoter A DB quoter, which can be a IReadableDatabase instance if convenient. |
498 | * @param string $target an IP address or CIDR range |
499 | * @param bool $xfor True if searching on XFF IPs by IP address / range |
500 | * @param string $table The table which will be used in the query these WHERE conditions |
501 | * are used (array of valid options in self::RESULT_TABLES). |
502 | * @return array|false array for valid conditions, false if invalid |
503 | * @deprecated Since 1.42. Use CheckUserLookupUtils::getIpConds instead. |
504 | */ |
505 | public static function getIpConds( |
506 | DbQuoter $quoter, string $target, bool $xfor = false, string $table = self::CHANGES_TABLE |
507 | ) { |
508 | wfDeprecated( __METHOD__, '1.42' ); |
509 | $expr = MediaWikiServices::getInstance()->get( 'CheckUserLookupUtils' ) |
510 | ->getIPTargetExpr( $target, $xfor, $table ); |
511 | if ( $expr === null ) { |
512 | return false; |
513 | } |
514 | return [ $expr ]; |
515 | } |
516 | |
517 | /** @inheritDoc */ |
518 | public function getIndexField(): string { |
519 | return 'timestamp'; |
520 | } |
521 | |
522 | /** |
523 | * @inheritDoc |
524 | * |
525 | * If the timestamp field is null, then the caller is either from outside |
526 | * CheckUser code or is not aware of what table a row comes from when |
527 | * reading results. In both cases returning "timestamp" is best as the |
528 | * queries alias the timestamp field to this name. |
529 | * |
530 | * If a caller uses the result of this method when $table is null as |
531 | * a column name in the WHERE conditions, the query will fail. |
532 | * |
533 | * @param string|null $table The table name. If null, returns the string "timestamp". |
534 | * @return string The timestamp field. |
535 | */ |
536 | public function getTimestampField( ?string $table = null ): string { |
537 | if ( $table === null ) { |
538 | return 'timestamp'; |
539 | } |
540 | return self::RESULT_TABLE_TO_PREFIX[$table] . 'timestamp'; |
541 | } |
542 | |
543 | /** @inheritDoc */ |
544 | protected function getStartBody(): string { |
545 | return $this->getNavigationBar() . '<div id="checkuserresults">'; |
546 | } |
547 | |
548 | /** @inheritDoc */ |
549 | protected function getEndBody(): string { |
550 | return '</div>' . $this->getNavigationBar(); |
551 | } |
552 | |
553 | /** @inheritDoc */ |
554 | public function getNavigationBuilder(): PagerNavigationBuilder { |
555 | $pagingQueries = $this->getPagingQueries(); |
556 | $baseQuery = array_merge( $this->getDefaultQuery(), [ |
557 | // These query parameters are all defined here, even though some are null, |
558 | // to ensure consistent order of parameters when they're used. |
559 | 'dir' => null, |
560 | 'offset' => $this->getOffsetQuery(), |
561 | 'limit' => null, |
562 | ] ); |
563 | |
564 | $navBuilder = new CheckUserPagerNavigationBuilder( |
565 | $this->getContext(), |
566 | $this->tokenQueryManager, |
567 | $this->getCsrfTokenSet(), |
568 | $this->getRequest(), |
569 | $this->opts, |
570 | $this->target |
571 | ); |
572 | $navBuilder |
573 | ->setPage( $this->getTitle() ) |
574 | ->setLinkQuery( $baseQuery ) |
575 | ->setLimits( $this->mLimitsShown ) |
576 | ->setLimitLinkQueryParam( 'limit' ) |
577 | ->setCurrentLimit( $this->mLimit ) |
578 | ->setPrevLinkQuery( $pagingQueries['prev'] ?: null ) |
579 | ->setNextLinkQuery( $pagingQueries['next'] ?: null ) |
580 | ->setFirstLinkQuery( $pagingQueries['first'] ?: null ) |
581 | ->setLastLinkQuery( $pagingQueries['last'] ?: null ); |
582 | |
583 | return $navBuilder; |
584 | } |
585 | |
586 | /** |
587 | * Used by getCheckUserHelperFieldsetHTML to get the fieldset. |
588 | * Seperated to allow testing of this method. |
589 | * |
590 | * @return ?HTMLFieldsetCheckUser |
591 | */ |
592 | private function getCheckUserHelperFieldset() { |
593 | if ( !$this->mResult->numRows() ) { |
594 | return null; |
595 | } |
596 | $collapseByDefault = $this->getConfig()->get( 'CheckUserCollapseCheckUserHelperByDefault' ); |
597 | if ( is_int( $collapseByDefault ) ) { |
598 | $collapseByDefault = $this->mResult->numRows() > $collapseByDefault; |
599 | } |
600 | $fieldset = new HTMLFieldsetCheckUser( [], $this->getContext() ); |
601 | $fieldset->outerClass = 'mw-checkuser-helper-fieldset'; |
602 | $fieldset |
603 | ->setCollapsibleOptions( $collapseByDefault ) |
604 | ->setWrapperLegendMsg( 'checkuser-helper-label' ) |
605 | ->prepareForm() |
606 | ->suppressDefaultSubmit( true ); |
607 | return $fieldset; |
608 | } |
609 | |
610 | /** |
611 | * |
612 | * Returns a empty HTML OOUI fieldset which is collapsible. |
613 | * Used by checkUserHelper.js and it's where the wikitext |
614 | * table is added into the results page. |
615 | * |
616 | * @return string The HTML of the fieldset. |
617 | */ |
618 | protected function getCheckUserHelperFieldsetHTML() { |
619 | $fieldsetHTML = $this->getCheckUserHelperFieldset(); |
620 | if ( $fieldsetHTML ) { |
621 | return $this->getCheckUserHelperFieldset()->getHTML( false ); |
622 | } else { |
623 | return ''; |
624 | } |
625 | } |
626 | |
627 | /** |
628 | * @inheritDoc |
629 | * |
630 | * @param string|null $table One of the tables in CheckUserQueryInterface::RESULT_TABLES. |
631 | * If set to null, this will throw a LogicException. |
632 | * @throws LogicException if $table is null a LogicException is thrown as ::getQueryInfo |
633 | * must have this information to return the correct query info. |
634 | */ |
635 | abstract public function getQueryInfo( ?string $table = null ); |
636 | |
637 | /** |
638 | * Get the query info specific to cu_changes that will |
639 | * be extended with table independent query information |
640 | * (such as a actor_name WHERE condition). This |
641 | * method should only be called by the ::getQueryInfo |
642 | * implementation. |
643 | * |
644 | * @return array The query info specific to cu_changes |
645 | */ |
646 | abstract protected function getQueryInfoForCuChanges(): array; |
647 | |
648 | /** |
649 | * Get the query info specific to cu_log_event that will |
650 | * be extended with table independent query information |
651 | * (such as a actor_name WHERE condition). This |
652 | * method should only be called by the ::getQueryInfo |
653 | * implementation. |
654 | * |
655 | * @return array The query info specific to cu_log_event |
656 | */ |
657 | abstract protected function getQueryInfoForCuLogEvent(): array; |
658 | |
659 | /** |
660 | * Get the query info specific to cu_private_event that will |
661 | * be extended with table independent query information |
662 | * (such as a actor_name WHERE condition). This |
663 | * method should only be called by the ::getQueryInfo |
664 | * implementation. |
665 | * |
666 | * @return array The query info specific to cu_private_event |
667 | */ |
668 | abstract protected function getQueryInfoForCuPrivateEvent(): array; |
669 | |
670 | /** |
671 | * Take the raw results list and turn it into an array where the |
672 | * keys are the index field value and the values are the rows with |
673 | * this index field. |
674 | * |
675 | * Can be used by implementations to combine rows as necessary. |
676 | * |
677 | * @stable to override |
678 | * |
679 | * @param stdClass[] $results The results from all three tables as an array |
680 | * where the rows are values, keys are numeric, and the order is |
681 | * defined as the order returned by the queries and then the query |
682 | * results ordered in the same order as self::RESULT_TABLES. |
683 | * @return stdClass[] The results grouped by the index field where the |
684 | * key is the index field and the value is an array of the rows |
685 | * with this index field. |
686 | */ |
687 | protected function groupResultsByIndexField( array $results ): array { |
688 | // Expand the result set into an array, with the key as the timestamp and |
689 | // value as an array of rows that have this timestamp. |
690 | $groupedResults = []; |
691 | $indexField = $this->getIndexField(); |
692 | foreach ( $results as $row ) { |
693 | if ( array_key_exists( $row->$indexField, $groupedResults ) ) { |
694 | // Use an array of rows as two given rows could have the same |
695 | // timestamp value. |
696 | $groupedResults[$row->$indexField][] = $row; |
697 | } else { |
698 | $groupedResults[$row->$indexField] = [ $row ]; |
699 | } |
700 | } |
701 | return $groupedResults; |
702 | } |
703 | |
704 | /** @inheritDoc */ |
705 | public function reallyDoQuery( $offset, $limit, $order ): IResultWrapper { |
706 | $results = []; |
707 | // Run the three SQL queries for each results table. |
708 | foreach ( $this->buildQueryInfo( $offset, $limit, $order ) as $queryInfo ) { |
709 | [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $queryInfo; |
710 | |
711 | $results = array_merge( |
712 | $results, |
713 | iterator_to_array( $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds ) ) |
714 | ); |
715 | } |
716 | $results = $this->groupResultsByIndexField( $results ); |
717 | // Make a new result wrapper that combines the results by ordering all |
718 | // by their timestamp and then returning the first $limit of the items. |
719 | if ( $order === self::QUERY_DESCENDING ) { |
720 | krsort( $results ); |
721 | } else { |
722 | ksort( $results ); |
723 | } |
724 | // Can remove the keys now that the results have been sorted. |
725 | $results = array_values( $results ); |
726 | // Flatten the array. If for a given timestamp more than row is present, |
727 | // from the same table then this method will keep the order in which |
728 | // they were returned by IDatabase::select. If the rows come from |
729 | // different tables, the rows will be ordered by table in the order |
730 | // the tables are defined in self::RESULT_TABLES. |
731 | $flattened_results = []; |
732 | array_walk_recursive( $results, static function ( $value ) use ( &$flattened_results ) { |
733 | $flattened_results[] = $value; |
734 | } ); |
735 | // Apply the limit to the results. |
736 | $flattened_results = array_slice( $flattened_results, 0, $limit ); |
737 | |
738 | // Return the generated data as a FakeResultWrapper. |
739 | return new FakeResultWrapper( $flattened_results ); |
740 | } |
741 | |
742 | /** |
743 | * Builds the query information. This is the same code as written in |
744 | * IndexPager::buildQueryInfo, ReverseChronologicalPager::buildQueryInfo |
745 | * and RangeChronologicalPager::buildQueryInfo, but modifies it to |
746 | * provide ::getQueryInfo with the arguments passed to this method. |
747 | * |
748 | * @inheritDoc |
749 | */ |
750 | public function buildQueryInfo( $offset, $limit, $order ): array { |
751 | // Copied, with modification, from IndexPager::buildQueryInfo |
752 | $fname = __METHOD__ . ' (' . $this->getSqlComment() . ')'; |
753 | $queryInfo = []; |
754 | // Select data from all three tables when reading new, and only cu_changes when reading old. |
755 | $resultTables = self::RESULT_TABLES; |
756 | if ( $this->getConfig()->get( 'CheckUserEventTablesMigrationStage' ) & SCHEMA_COMPAT_READ_OLD ) { |
757 | $resultTables = [ self::CHANGES_TABLE ]; |
758 | } |
759 | foreach ( $resultTables as $table ) { |
760 | $info = $this->getQueryInfo( $table ); |
761 | $tables = $info['tables']; |
762 | $fields = $info['fields']; |
763 | $conds = $info['conds'] ?? []; |
764 | $options = $info['options'] ?? []; |
765 | $join_conds = $info['join_conds'] ?? []; |
766 | $indexColumns = (array)$this->mIndexField; |
767 | $sortColumns = array_merge( $indexColumns, $this->mExtraSortFields ); |
768 | |
769 | if ( $order === self::QUERY_ASCENDING ) { |
770 | $options['ORDER BY'] = $sortColumns; |
771 | $operator = $this->mIncludeOffset ? '>=' : '>'; |
772 | } else { |
773 | $orderBy = []; |
774 | foreach ( $sortColumns as $col ) { |
775 | $orderBy[] = $col . ' DESC'; |
776 | } |
777 | $options['ORDER BY'] = $orderBy; |
778 | $operator = $this->mIncludeOffset ? '<=' : '<'; |
779 | } |
780 | if ( $offset ) { |
781 | // From IndexPager |
782 | $offsets = explode( '|', $offset, count( $indexColumns ) ); |
783 | $indexColumns = array_slice( $indexColumns, 0, count( $offsets ) ); |
784 | // Replace 'timestamp' with the timestamp column name for the given table. |
785 | $timestampField = $this->getTimestampField( $table ); |
786 | $indexColumns = array_map( static function ( $value ) use ( $timestampField ) { |
787 | return $value === 'timestamp' ? $timestampField : $value; |
788 | }, $indexColumns ); |
789 | // From IndexPager |
790 | $conds[] = $this->mDb->buildComparison( $operator, array_combine( $indexColumns, $offsets ) ); |
791 | } |
792 | $options['LIMIT'] = intval( $limit ); |
793 | |
794 | // Copied from ReverseChronologicalPager::buildQueryInfo |
795 | if ( $this->endOffset ) { |
796 | $conds[] = $this->mDb->buildComparison( |
797 | '<', [ $this->getTimestampField( $table ) => $this->endOffset ] |
798 | ); |
799 | } |
800 | |
801 | // Copied from RangeChronologicalPager::buildQueryInfo |
802 | if ( $this->startOffset ) { |
803 | $conds[] = $this->mDb->buildComparison( |
804 | '>=', [ $this->getTimestampField( $table ) => $this->startOffset ] |
805 | ); |
806 | } |
807 | // Add the data that would normally be returned by this method to an array |
808 | // so that it can be returned for all three tables at once. |
809 | $queryInfo[$table] = [ $tables, $fields, $conds, $fname, $options, $join_conds ]; |
810 | } |
811 | return $queryInfo; |
812 | } |
813 | } |