Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.34% covered (warning)
61.34%
192 / 313
41.67% covered (danger)
41.67%
10 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractCheckUserPager
61.34% covered (warning)
61.34%
192 / 313
41.67% covered (danger)
41.67%
10 / 24
362.23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
4
 setPeriodCondition
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getTimeRangeString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getBlockFlag
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 getSelfLink
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getTitleValue
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getPageTitle
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getFormattedTimestamp
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getEmptyBody
75.86% covered (warning)
75.86%
22 / 29
0.00% covered (danger)
0.00%
0 / 1
3.13
 userBlockFlags
37.50% covered (danger)
37.50%
18 / 48
0.00% covered (danger)
0.00%
0 / 1
69.93
 buildGroupLink
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 userWasBlocked
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 isValidRange
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getIpConds
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getIndexField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTimestampField
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getStartBody
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEndBody
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNavigationBuilder
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 getCheckUserHelperFieldset
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 getCheckUserHelperFieldsetHTML
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
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% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 reallyDoQuery
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 buildQueryInfo
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
11
1<?php
2
3namespace MediaWiki\CheckUser\CheckUser\Pagers;
4
5use ExtensionRegistry;
6use HtmlArmor;
7use IContextSource;
8use LogicException;
9use MediaWiki\Block\AbstractBlock;
10use MediaWiki\Block\DatabaseBlock;
11use MediaWiki\CheckUser\CheckUser\CheckUserPagerNavigationBuilder;
12use MediaWiki\CheckUser\CheckUser\Widgets\HTMLFieldsetCheckUser;
13use MediaWiki\CheckUser\CheckUserQueryInterface;
14use MediaWiki\CheckUser\Services\CheckUserLogService;
15use MediaWiki\CheckUser\Services\CheckUserLookupUtils;
16use MediaWiki\CheckUser\Services\TokenQueryManager;
17use MediaWiki\Extension\GlobalBlocking\GlobalBlocking;
18use MediaWiki\Extension\TorBlock\TorExitNodes;
19use MediaWiki\Html\FormOptions;
20use MediaWiki\Html\Html;
21use MediaWiki\Html\TemplateParser;
22use MediaWiki\Linker\LinkRenderer;
23use MediaWiki\MediaWikiServices;
24use MediaWiki\Navigation\PagerNavigationBuilder;
25use MediaWiki\Pager\RangeChronologicalPager;
26use MediaWiki\SpecialPage\SpecialPage;
27use MediaWiki\SpecialPage\SpecialPageFactory;
28use MediaWiki\Title\Title;
29use MediaWiki\Title\TitleValue;
30use MediaWiki\User\CentralId\CentralIdLookup;
31use MediaWiki\User\UserFactory;
32use MediaWiki\User\UserGroupManager;
33use MediaWiki\User\UserGroupMembership;
34use MediaWiki\User\UserIdentity;
35use MediaWiki\User\UserIdentityLookup;
36use MediaWiki\Utils\MWTimestamp;
37use RequestContext;
38use stdClass;
39use Wikimedia\Rdbms\Database\DbQuoter;
40use Wikimedia\Rdbms\FakeResultWrapper;
41use Wikimedia\Rdbms\IConnectionProvider;
42use Wikimedia\Rdbms\IResultWrapper;
43use Wikimedia\Rdbms\SelectQueryBuilder;
44
45abstract 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}