Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.47% covered (warning)
83.47%
313 / 375
16.67% covered (danger)
16.67%
4 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContribsPager
83.69% covered (warning)
83.69%
313 / 374
16.67% covered (danger)
16.67%
4 / 24
147.14
0.00% covered (danger)
0.00%
0 / 1
 __construct
95.65% covered (success)
95.65%
44 / 46
0.00% covered (danger)
0.00%
0 / 1
8
 getDefaultQuery
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 reallyDoQuery
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 getTargetTable
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getQueryInfo
91.84% covered (success)
91.84%
45 / 49
0.00% covered (danger)
0.00%
0 / 1
10.05
 getNamespaceCond
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 getIpRangeConds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isQueryableRange
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 getIndexField
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 getTagFilter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTarget
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isNewOnly
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNamespace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExtraSortFields
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 doBatchLookups
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
6.02
 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
 tryCreatingRevisionRecord
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 formatRow
82.27% covered (warning)
82.27%
116 / 141
0.00% covered (danger)
0.00%
0 / 1
27.21
 getSqlComment
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 preventClickjacking
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setPreventClickjacking
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPreventClickjacking
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processDateFilter
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
6.20
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @ingroup Pager
20 */
21
22namespace MediaWiki\Pager;
23
24use ChangesList;
25use ChangeTags;
26use DateTime;
27use HtmlArmor;
28use InvalidArgumentException;
29use MapCacheLRU;
30use MediaWiki\Cache\LinkBatchFactory;
31use MediaWiki\CommentFormatter\CommentFormatter;
32use MediaWiki\Config\Config;
33use MediaWiki\Context\IContextSource;
34use MediaWiki\HookContainer\HookContainer;
35use MediaWiki\HookContainer\HookRunner;
36use MediaWiki\Html\Html;
37use MediaWiki\Html\TemplateParser;
38use MediaWiki\Linker\Linker;
39use MediaWiki\Linker\LinkRenderer;
40use MediaWiki\MainConfigNames;
41use MediaWiki\MediaWikiServices;
42use MediaWiki\Parser\Sanitizer;
43use MediaWiki\Revision\RevisionRecord;
44use MediaWiki\Revision\RevisionStore;
45use MediaWiki\Title\NamespaceInfo;
46use MediaWiki\Title\Title;
47use MediaWiki\User\UserIdentity;
48use MediaWiki\User\UserRigorOptions;
49use stdClass;
50use Wikimedia\IPUtils;
51use Wikimedia\Rdbms\FakeResultWrapper;
52use Wikimedia\Rdbms\IConnectionProvider;
53use Wikimedia\Rdbms\IReadableDatabase;
54use Wikimedia\Rdbms\IResultWrapper;
55
56/**
57 * Pager for Special:Contributions
58 * @ingroup Pager
59 */
60class ContribsPager extends RangeChronologicalPager {
61
62    public $mGroupByDate = true;
63
64    /**
65     * @var string[] Local cache for escaped messages
66     */
67    private $messages;
68
69    /**
70     * @var string User name, or a string describing an IP address range
71     */
72    private $target;
73
74    /**
75     * @var string|int A single namespace number, or an empty string for all namespaces
76     */
77    private $namespace;
78
79    /**
80     * @var string[]|false Name of tag to filter, or false to ignore tags
81     */
82    private $tagFilter;
83
84    /**
85     * @var bool Set to true to invert the tag selection
86     */
87    private $tagInvert;
88
89    /**
90     * @var bool Set to true to invert the namespace selection
91     */
92    private $nsInvert;
93
94    /**
95     * @var bool Set to true to show both the subject and talk namespace, no matter which got
96     *  selected
97     */
98    private $associated;
99
100    /**
101     * @var bool Set to true to show only deleted revisions
102     */
103    private $deletedOnly;
104
105    /**
106     * @var bool Set to true to show only latest (a.k.a. current) revisions
107     */
108    private $topOnly;
109
110    /**
111     * @var bool Set to true to show only new pages
112     */
113    private $newOnly;
114
115    /**
116     * @var bool Set to true to hide edits marked as minor by the user
117     */
118    private $hideMinor;
119
120    /**
121     * @var bool Set to true to only include mediawiki revisions.
122     * (restricts extensions from executing additional queries to include their own contributions)
123     */
124    private $revisionsOnly;
125
126    private $preventClickjacking = false;
127
128    /**
129     * @var array
130     */
131    private $mParentLens;
132
133    /** @var UserIdentity */
134    private $targetUser;
135
136    private TemplateParser $templateParser;
137    private CommentFormatter $commentFormatter;
138    private HookRunner $hookRunner;
139    private LinkBatchFactory $linkBatchFactory;
140    private NamespaceInfo $namespaceInfo;
141    private RevisionStore $revisionStore;
142
143    /** @var string[] */
144    private $formattedComments = [];
145
146    /** @var RevisionRecord[] Cached revisions by ID */
147    private $revisions = [];
148
149    /** @var MapCacheLRU */
150    private $tagsCache;
151
152    /**
153     * FIXME List services first T266484 / T290405
154     * @param IContextSource $context
155     * @param array $options
156     * @param LinkRenderer|null $linkRenderer
157     * @param LinkBatchFactory|null $linkBatchFactory
158     * @param HookContainer|null $hookContainer
159     * @param IConnectionProvider|null $dbProvider
160     * @param RevisionStore|null $revisionStore
161     * @param NamespaceInfo|null $namespaceInfo
162     * @param UserIdentity|null $targetUser
163     * @param CommentFormatter|null $commentFormatter
164     */
165    public function __construct(
166        IContextSource $context,
167        array $options,
168        LinkRenderer $linkRenderer = null,
169        LinkBatchFactory $linkBatchFactory = null,
170        HookContainer $hookContainer = null,
171        IConnectionProvider $dbProvider = null,
172        RevisionStore $revisionStore = null,
173        NamespaceInfo $namespaceInfo = null,
174        UserIdentity $targetUser = null,
175        CommentFormatter $commentFormatter = null
176    ) {
177        // Class is used directly in extensions - T266484
178        $services = MediaWikiServices::getInstance();
179        $dbProvider ??= $services->getConnectionProvider();
180
181        // Set ->target before calling parent::__construct() so
182        // parent can call $this->getIndexField() and get the right result. Set
183        // the rest too just to keep things simple.
184        if ( $targetUser ) {
185            $this->target = $options['target'] ?? $targetUser->getName();
186            $this->targetUser = $targetUser;
187        } else {
188            // Use target option
189            // It's possible for the target to be empty. This is used by
190            // ContribsPagerTest and does not cause newFromName() to return
191            // false. It's probably not used by any production code.
192            $this->target = $options['target'] ?? '';
193            // @phan-suppress-next-line PhanPossiblyNullTypeMismatchProperty RIGOR_NONE never returns null
194            $this->targetUser = $services->getUserFactory()->newFromName(
195                $this->target, UserRigorOptions::RIGOR_NONE
196            );
197            if ( !$this->targetUser ) {
198                // This can happen if the target contained "#". Callers
199                // typically pass user input through title normalization to
200                // avoid it.
201                throw new InvalidArgumentException( __METHOD__ . ': the user name is too ' .
202                    'broken to use even with validation disabled.' );
203            }
204        }
205
206        $this->namespace = $options['namespace'] ?? '';
207        $this->tagFilter = $options['tagfilter'] ?? false;
208        $this->tagInvert = $options['tagInvert'] ?? false;
209        $this->nsInvert = $options['nsInvert'] ?? false;
210        $this->associated = $options['associated'] ?? false;
211
212        $this->deletedOnly = !empty( $options['deletedOnly'] );
213        $this->topOnly = !empty( $options['topOnly'] );
214        $this->newOnly = !empty( $options['newOnly'] );
215        $this->hideMinor = !empty( $options['hideMinor'] );
216        $this->revisionsOnly = !empty( $options['revisionsOnly'] );
217
218        parent::__construct( $context, $linkRenderer ?? $services->getLinkRenderer() );
219
220        $msgs = [
221            'diff',
222            'hist',
223            'pipe-separator',
224            'uctop',
225            'changeslist-nocomment',
226        ];
227
228        foreach ( $msgs as $msg ) {
229            $this->messages[$msg] = $this->msg( $msg )->escaped();
230        }
231
232        // Date filtering: use timestamp if available
233        $startTimestamp = '';
234        $endTimestamp = '';
235        if ( isset( $options['start'] ) && $options['start'] ) {
236            $startTimestamp = $options['start'] . ' 00:00:00';
237        }
238        if ( isset( $options['end'] ) && $options['end'] ) {
239            $endTimestamp = $options['end'] . ' 23:59:59';
240        }
241        $this->getDateRangeCond( $startTimestamp, $endTimestamp );
242
243        $this->templateParser = new TemplateParser();
244        $this->linkBatchFactory = $linkBatchFactory ?? $services->getLinkBatchFactory();
245        $this->hookRunner = new HookRunner( $hookContainer ?? $services->getHookContainer() );
246        $this->revisionStore = $revisionStore ?? $services->getRevisionStore();
247        $this->namespaceInfo = $namespaceInfo ?? $services->getNamespaceInfo();
248        $this->commentFormatter = $commentFormatter ?? $services->getCommentFormatter();
249        $this->tagsCache = new MapCacheLRU( 50 );
250    }
251
252    public function getDefaultQuery() {
253        $query = parent::getDefaultQuery();
254        $query['target'] = $this->target;
255
256        return $query;
257    }
258
259    /**
260     * This method basically executes the exact same code as the parent class, though with
261     * a hook added, to allow extensions to add additional queries.
262     *
263     * @param string $offset Index offset, inclusive
264     * @param int $limit Exact query limit
265     * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
266     * @return IResultWrapper
267     */
268    public function reallyDoQuery( $offset, $limit, $order ) {
269        [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $this->buildQueryInfo(
270            $offset,
271            $limit,
272            $order
273        );
274
275        $options['MAX_EXECUTION_TIME'] =
276            $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries );
277        /*
278         * This hook will allow extensions to add in additional queries, so they can get their data
279         * in My Contributions as well. Extensions should append their results to the $data array.
280         *
281         * Extension queries have to implement the navbar requirement as well. They should
282         * - have a column aliased as $pager->getIndexField()
283         * - have LIMIT set
284         * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset
285         * - have the ORDER BY specified based upon the details provided by the navbar
286         *
287         * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY
288         *
289         * &$data: an array of results of all contribs queries
290         * $pager: the ContribsPager object hooked into
291         * $offset: see phpdoc above
292         * $limit: see phpdoc above
293         * $descending: see phpdoc above
294         */
295        $dbr = $this->getDatabase();
296        $data = [ $dbr->select(
297            $tables, $fields, $conds, $fname, $options, $join_conds
298        ) ];
299        if ( !$this->revisionsOnly ) {
300            // TODO: Range offsets are fairly important and all handlers should take care of it.
301            // If this hook will be replaced (e.g. unified with the DeletedContribsPager one),
302            // please consider passing [ $this->endOffset, $this->startOffset ] to it (T167577).
303            $this->hookRunner->onContribsPager__reallyDoQuery(
304                $data, $this, $offset, $limit, $order );
305        }
306
307        $result = [];
308
309        // loop all results and collect them in an array
310        foreach ( $data as $query ) {
311            foreach ( $query as $i => $row ) {
312                // If the query results are in descending order, the indexes must also be in descending order
313                $index = $order === self::QUERY_ASCENDING ? $i : $limit - 1 - $i;
314                // Left-pad with zeroes, because these values will be sorted as strings
315                $index = str_pad( (string)$index, strlen( (string)$limit ), '0', STR_PAD_LEFT );
316                // use index column as key, allowing us to easily sort in PHP
317                $result[$row->{$this->getIndexField()} . "-$index"] = $row;
318            }
319        }
320
321        // sort results
322        if ( $order === self::QUERY_ASCENDING ) {
323            ksort( $result );
324        } else {
325            krsort( $result );
326        }
327
328        // enforce limit
329        $result = array_slice( $result, 0, $limit );
330
331        // get rid of array keys
332        $result = array_values( $result );
333
334        return new FakeResultWrapper( $result );
335    }
336
337    /**
338     * Return the table targeted for ordering and continuation
339     *
340     * See T200259 and T221380.
341     *
342     * @warning Keep this in sync with self::getQueryInfo()!
343     *
344     * @return string
345     */
346    private function getTargetTable() {
347        $dbr = $this->getDatabase();
348        $ipRangeConds = $this->targetUser->isRegistered()
349            ? null : $this->getIpRangeConds( $dbr, $this->target );
350        if ( $ipRangeConds ) {
351            return 'ip_changes';
352        }
353
354        return 'revision';
355    }
356
357    public function getQueryInfo() {
358        $revQuery = $this->revisionStore->getQueryInfo( [ 'page', 'user' ] );
359        $queryInfo = [
360            'tables' => $revQuery['tables'],
361            'fields' => array_merge( $revQuery['fields'], [ 'page_is_new' ] ),
362            'conds' => [],
363            'options' => [],
364            'join_conds' => $revQuery['joins'],
365        ];
366
367        // WARNING: Keep this in sync with getTargetTable()!
368        $dbr = $this->getDatabase();
369        $ipRangeConds = !$this->targetUser->isRegistered() ? $this->getIpRangeConds( $dbr, $this->target ) : null;
370        if ( $ipRangeConds ) {
371            // Put ip_changes first (T284419)
372            array_unshift( $queryInfo['tables'], 'ip_changes' );
373            $queryInfo['join_conds']['revision'] = [
374                'JOIN', [ 'rev_id = ipc_rev_id' ]
375            ];
376            $queryInfo['conds'][] = $ipRangeConds;
377        } else {
378            $queryInfo['conds']['actor_name'] = $this->targetUser->getName();
379            // Force the appropriate index to avoid bad query plans (T307295)
380            $queryInfo['options']['USE INDEX']['revision'] = 'rev_actor_timestamp';
381        }
382
383        if ( $this->deletedOnly ) {
384            $queryInfo['conds'][] = 'rev_deleted != 0';
385        }
386
387        if ( $this->topOnly ) {
388            $queryInfo['conds'][] = 'rev_id = page_latest';
389        }
390
391        if ( $this->newOnly ) {
392            $queryInfo['conds'][] = 'rev_parent_id = 0';
393        }
394
395        if ( $this->hideMinor ) {
396            $queryInfo['conds'][] = 'rev_minor_edit = 0';
397        }
398
399        $queryInfo['conds'] = array_merge( $queryInfo['conds'], $this->getNamespaceCond() );
400
401        // Paranoia: avoid brute force searches (T19342)
402        if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
403            $queryInfo['conds'][] = $dbr->bitAnd(
404                'rev_deleted', RevisionRecord::DELETED_USER
405                ) . ' = 0';
406        } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
407            $queryInfo['conds'][] = $dbr->bitAnd(
408                'rev_deleted', RevisionRecord::SUPPRESSED_USER
409                ) . ' != ' . RevisionRecord::SUPPRESSED_USER;
410        }
411
412        // $this->getIndexField() must be in the result rows, as reallyDoQuery() tries to access it.
413        $indexField = $this->getIndexField();
414        if ( $indexField !== 'rev_timestamp' ) {
415            $queryInfo['fields'][] = $indexField;
416        }
417
418        ChangeTags::modifyDisplayQuery(
419            $queryInfo['tables'],
420            $queryInfo['fields'],
421            $queryInfo['conds'],
422            $queryInfo['join_conds'],
423            $queryInfo['options'],
424            $this->tagFilter,
425            $this->tagInvert,
426        );
427
428        $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo );
429
430        return $queryInfo;
431    }
432
433    protected function getNamespaceCond() {
434        if ( $this->namespace !== '' ) {
435            $dbr = $this->getDatabase();
436            $selectedNS = $dbr->addQuotes( $this->namespace );
437            $eq_op = $this->nsInvert ? '!=' : '=';
438            $bool_op = $this->nsInvert ? 'AND' : 'OR';
439
440            if ( !$this->associated ) {
441                return [ "page_namespace $eq_op $selectedNS" ];
442            }
443
444            $associatedNS = $dbr->addQuotes( $this->namespaceInfo->getAssociated( $this->namespace ) );
445
446            return [
447                "page_namespace $eq_op $selectedNS " .
448                $bool_op .
449                " page_namespace $eq_op $associatedNS"
450            ];
451        }
452
453        return [];
454    }
455
456    /**
457     * Get SQL conditions for an IP range, if applicable
458     * @param IReadableDatabase $db
459     * @param string $ip The IP address or CIDR
460     * @return string|false SQL for valid IP ranges, false if invalid
461     */
462    private function getIpRangeConds( $db, $ip ) {
463        // First make sure it is a valid range and they are not outside the CIDR limit
464        if ( !self::isQueryableRange( $ip, $this->getConfig() ) ) {
465            return false;
466        }
467
468        [ $start, $end ] = IPUtils::parseRange( $ip );
469
470        return 'ipc_hex BETWEEN ' . $db->addQuotes( $start ) . ' AND ' . $db->addQuotes( $end );
471    }
472
473    /**
474     * Is the given IP a range and within the CIDR limit?
475     *
476     * @internal Public only for SpecialContributions
477     * @param string $ipRange
478     * @param Config $config
479     * @return bool True if it is valid
480     * @since 1.30
481     */
482    public static function isQueryableRange( $ipRange, $config ) {
483        $limits = $config->get( MainConfigNames::RangeContributionsCIDRLimit );
484
485        $bits = IPUtils::parseCIDR( $ipRange )[1];
486        if (
487            ( $bits === false ) ||
488            ( IPUtils::isIPv4( $ipRange ) && $bits < $limits['IPv4'] ) ||
489            ( IPUtils::isIPv6( $ipRange ) && $bits < $limits['IPv6'] )
490        ) {
491            return false;
492        }
493
494        return true;
495    }
496
497    /**
498     * @return string
499     */
500    public function getIndexField() {
501        // The returned column is used for sorting and continuation, so we need to
502        // make sure to use the right denormalized column depending on which table is
503        // being targeted by the query to avoid bad query plans.
504        // See T200259, T204669, T220991, and T221380.
505        $target = $this->getTargetTable();
506        switch ( $target ) {
507            case 'revision':
508                return 'rev_timestamp';
509            case 'ip_changes':
510                return 'ipc_rev_timestamp';
511            default:
512                wfWarn(
513                    __METHOD__ . ": Unknown value '$target' from " . static::class . '::getTargetTable()', 0
514                );
515                return 'rev_timestamp';
516        }
517    }
518
519    /**
520     * @return false|string[]
521     */
522    public function getTagFilter() {
523        return $this->tagFilter;
524    }
525
526    /**
527     * @return string
528     */
529    public function getTarget() {
530        return $this->target;
531    }
532
533    /**
534     * @return bool
535     */
536    public function isNewOnly() {
537        return $this->newOnly;
538    }
539
540    /**
541     * @return int|string
542     */
543    public function getNamespace() {
544        return $this->namespace;
545    }
546
547    /**
548     * @return string[]
549     */
550    protected function getExtraSortFields() {
551        // The returned columns are used for sorting, so we need to make sure
552        // to use the right denormalized column depending on which table is
553        // being targeted by the query to avoid bad query plans.
554        // See T200259, T204669, T220991, and T221380.
555        $target = $this->getTargetTable();
556        switch ( $target ) {
557            case 'revision':
558                return [ 'rev_id' ];
559            case 'ip_changes':
560                return [ 'ipc_rev_id' ];
561            default:
562                wfWarn(
563                    __METHOD__ . ": Unknown value '$target' from " . static::class . '::getTargetTable()', 0
564                );
565                return [ 'rev_id' ];
566        }
567    }
568
569    protected function doBatchLookups() {
570        # Do a link batch query
571        $this->mResult->seek( 0 );
572        $parentRevIds = [];
573        $this->mParentLens = [];
574        $revisions = [];
575        $linkBatch = $this->linkBatchFactory->newLinkBatch();
576        $isIpRange = self::isQueryableRange( $this->target, $this->getConfig() );
577        # Give some pointers to make (last) links
578        foreach ( $this->mResult as $row ) {
579            if ( isset( $row->rev_parent_id ) && $row->rev_parent_id ) {
580                $parentRevIds[] = (int)$row->rev_parent_id;
581            }
582            if ( $this->revisionStore->isRevisionRow( $row ) ) {
583                $this->mParentLens[(int)$row->rev_id] = $row->rev_len;
584                if ( $isIpRange ) {
585                    // If this is an IP range, batch the IP's talk page
586                    $linkBatch->add( NS_USER_TALK, $row->rev_user_text );
587                }
588                $linkBatch->add( $row->page_namespace, $row->page_title );
589                $revisions[$row->rev_id] = $this->revisionStore->newRevisionFromRow( $row );
590            }
591        }
592        # Fetch rev_len for revisions not already scanned above
593        $this->mParentLens += $this->revisionStore->getRevisionSizes(
594            array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
595        );
596        $linkBatch->execute();
597
598        $this->formattedComments = $this->commentFormatter->createRevisionBatch()
599            ->authority( $this->getAuthority() )
600            ->revisions( $revisions )
601            ->hideIfDeleted()
602            ->execute();
603
604        # For performance, save the revision objects for later.
605        # The array is indexed by rev_id. doBatchLookups() may be called
606        # multiple times with different results, so merge the revisions array,
607        # ignoring any duplicates.
608        $this->revisions += $revisions;
609    }
610
611    /**
612     * @inheritDoc
613     */
614    protected function getStartBody() {
615        return "<section class='mw-pager-body'>\n";
616    }
617
618    /**
619     * @inheritDoc
620     */
621    protected function getEndBody() {
622        return "</section>\n";
623    }
624
625    /**
626     * If the object looks like a revision row, or corresponds to a previously
627     * cached revision, return the RevisionRecord. Otherwise, return null.
628     *
629     * @since 1.35
630     *
631     * @param mixed $row
632     * @param Title|null $title
633     * @return RevisionRecord|null
634     */
635    public function tryCreatingRevisionRecord( $row, $title = null ) {
636        if ( $row instanceof stdClass && isset( $row->rev_id )
637            && isset( $this->revisions[$row->rev_id] )
638        ) {
639            return $this->revisions[$row->rev_id];
640        } elseif ( $this->revisionStore->isRevisionRow( $row ) ) {
641            return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
642        } else {
643            return null;
644        }
645    }
646
647    /**
648     * Generates each row in the contributions list.
649     *
650     * Contributions which are marked "top" are currently on top of the history.
651     * For these contributions, a [rollback] link is shown for users with roll-
652     * back privileges. The rollback link restores the most recent version that
653     * was not written by the target user.
654     *
655     * @todo This would probably look a lot nicer in a table.
656     * @param stdClass|mixed $row
657     * @return string
658     */
659    public function formatRow( $row ) {
660        $ret = '';
661        $classes = [];
662        $attribs = [];
663
664        $linkRenderer = $this->getLinkRenderer();
665
666        $page = null;
667        // Create a title for the revision if possible
668        // Rows from the hook may not include title information
669        if ( isset( $row->page_namespace ) && isset( $row->page_title ) ) {
670            $page = Title::newFromRow( $row );
671        }
672        // Flow overrides the ContribsPager::reallyDoQuery hook, causing this
673        // function to be called with a special object for $row. It expects us
674        // skip formatting so that the row can be formatted by the
675        // ContributionsLineEnding hook below.
676        // FIXME: have some better way for extensions to provide formatted rows.
677        $revRecord = $this->tryCreatingRevisionRecord( $row, $page );
678        if ( $revRecord && $page ) {
679            $revRecord = $this->revisionStore->newRevisionFromRow( $row, 0, $page );
680            $attribs['data-mw-revid'] = $revRecord->getId();
681
682            $link = $linkRenderer->makeLink(
683                $page,
684                $page->getPrefixedText(),
685                [ 'class' => 'mw-contributions-title' ],
686                $page->isRedirect() ? [ 'redirect' => 'no' ] : []
687            );
688            # Mark current revisions
689            $topmarktext = '';
690
691            $pagerTools = new PagerTools(
692                $revRecord,
693                null,
694                $row->rev_id === $row->page_latest && !$row->page_is_new,
695                $this->hookRunner,
696                $page,
697                $this->getContext(),
698                $this->getLinkRenderer()
699            );
700            if ( $row->rev_id === $row->page_latest ) {
701                $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
702                $classes[] = 'mw-contributions-current';
703            }
704            if ( $pagerTools->shouldPreventClickjacking() ) {
705                $this->setPreventClickjacking( true );
706            }
707            $topmarktext .= $pagerTools->toHTML();
708            # Is there a visible previous revision?
709            if ( $revRecord->getParentId() !== 0 &&
710                $revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
711            ) {
712                $difftext = $linkRenderer->makeKnownLink(
713                    $page,
714                    new HtmlArmor( $this->messages['diff'] ),
715                    [ 'class' => 'mw-changeslist-diff' ],
716                    [
717                        'diff' => 'prev',
718                        'oldid' => $row->rev_id
719                    ]
720                );
721            } else {
722                $difftext = $this->messages['diff'];
723            }
724            $histlink = $linkRenderer->makeKnownLink(
725                $page,
726                new HtmlArmor( $this->messages['hist'] ),
727                [ 'class' => 'mw-changeslist-history' ],
728                [ 'action' => 'history' ]
729            );
730
731            if ( $row->rev_parent_id === null ) {
732                // For some reason rev_parent_id isn't populated for this row.
733                // Its rumoured this is true on wikipedia for some revisions (T36922).
734                // Next best thing is to have the total number of bytes.
735                $chardiff = ' <span class="mw-changeslist-separator"></span> ';
736                $chardiff .= Linker::formatRevisionSize( $row->rev_len );
737                $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
738            } else {
739                $parentLen = 0;
740                if ( isset( $this->mParentLens[$row->rev_parent_id] ) ) {
741                    $parentLen = $this->mParentLens[$row->rev_parent_id];
742                }
743
744                $chardiff = ' <span class="mw-changeslist-separator"></span> ';
745                $chardiff .= ChangesList::showCharacterDifference(
746                    $parentLen,
747                    $row->rev_len,
748                    $this->getContext()
749                );
750                $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
751            }
752
753            $lang = $this->getLanguage();
754
755            $comment = $this->formattedComments[$row->rev_id];
756
757            if ( $comment === '' ) {
758                $defaultComment = $this->messages['changeslist-nocomment'];
759                $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
760            }
761
762            $comment = $lang->getDirMark() . $comment;
763
764            $authority = $this->getAuthority();
765            $d = ChangesList::revDateLink( $revRecord, $authority, $lang, $page );
766
767            # When querying for an IP range, we want to always show user and user talk links.
768            $userlink = '';
769            $revUser = $revRecord->getUser();
770            $revUserId = $revUser ? $revUser->getId() : 0;
771            $revUserText = $revUser ? $revUser->getName() : '';
772            if ( self::isQueryableRange( $this->target, $this->getConfig() ) ) {
773                $userlink = ' <span class="mw-changeslist-separator"></span> '
774                    . $lang->getDirMark()
775                    . Linker::userLink( $revUserId, $revUserText );
776                $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
777                    Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' ';
778            }
779
780            $flags = [];
781            if ( $revRecord->getParentId() === 0 ) {
782                $flags[] = ChangesList::flag( 'newpage' );
783            }
784
785            if ( $revRecord->isMinor() ) {
786                $flags[] = ChangesList::flag( 'minor' );
787            }
788
789            $del = Linker::getRevDeleteLink( $authority, $revRecord, $page );
790            if ( $del !== '' ) {
791                $del .= ' ';
792            }
793
794            // While it might be tempting to use a list here
795            // this would result in clutter and slows down navigating the content
796            // in assistive technology.
797            // See https://phabricator.wikimedia.org/T205581#4734812
798            $diffHistLinks = Html::rawElement( 'span',
799                [ 'class' => 'mw-changeslist-links' ],
800                // The spans are needed to ensure the dividing '|' elements are not
801                // themselves styled as links.
802                Html::rawElement( 'span', [], $difftext ) .
803                ' ' . // Space needed for separating two words.
804                Html::rawElement( 'span', [], $histlink )
805            );
806
807            # Tags, if any. Save some time using a cache.
808            [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
809                $this->tagsCache->makeKey(
810                    $row->ts_tags ?? '',
811                    $this->getUser()->getName(),
812                    $lang->getCode()
813                ),
814                fn () => ChangeTags::formatSummaryRow(
815                    $row->ts_tags,
816                    null,
817                    $this->getContext()
818                )
819            );
820            $classes = array_merge( $classes, $newClasses );
821
822            $this->hookRunner->onSpecialContributions__formatRow__flags(
823                $this->getContext(), $row, $flags );
824
825            $templateParams = [
826                'del' => $del,
827                'timestamp' => $d,
828                'diffHistLinks' => $diffHistLinks,
829                'charDifference' => $chardiff,
830                'flags' => $flags,
831                'articleLink' => $link,
832                'userlink' => $userlink,
833                'logText' => $comment,
834                'topmarktext' => $topmarktext,
835                'tagSummary' => $tagSummary,
836            ];
837
838            # Denote if username is redacted for this edit
839            if ( $revRecord->isDeleted( RevisionRecord::DELETED_USER ) ) {
840                $templateParams['rev-deleted-user-contribs'] =
841                    $this->msg( 'rev-deleted-user-contribs' )->escaped();
842            }
843
844            $ret = $this->templateParser->processTemplate(
845                'SpecialContributionsLine',
846                $templateParams
847            );
848        }
849
850        // Let extensions add data
851        $this->hookRunner->onContributionsLineEnding( $this, $ret, $row, $classes, $attribs );
852        $attribs = array_filter( $attribs,
853            [ Sanitizer::class, 'isReservedDataAttribute' ],
854            ARRAY_FILTER_USE_KEY
855        );
856
857        // TODO: Handle exceptions in the catch block above.  Do any extensions rely on
858        // receiving empty rows?
859
860        if ( $classes === [] && $attribs === [] && $ret === '' ) {
861            wfDebug( "Dropping Special:Contribution row that could not be formatted" );
862            return "<!-- Could not format Special:Contribution row. -->\n";
863        }
864        $attribs['class'] = $classes;
865
866        // FIXME: The signature of the ContributionsLineEnding hook makes it
867        // very awkward to move this LI wrapper into the template.
868        return Html::rawElement( 'li', $attribs, $ret ) . "\n";
869    }
870
871    /**
872     * Overwrite Pager function and return a helpful comment
873     * @return string
874     */
875    protected function getSqlComment() {
876        if ( $this->namespace || $this->deletedOnly ) {
877            // potentially slow, see CR r58153
878            return 'contributions page filtered for namespace or RevisionDeleted edits';
879        } else {
880            return 'contributions page unfiltered';
881        }
882    }
883
884    /**
885     * @deprecated since 1.38, use ::setPreventClickjacking() instead
886     */
887    protected function preventClickjacking() {
888        $this->setPreventClickjacking( true );
889    }
890
891    /**
892     * @param bool $enable
893     * @since 1.38
894     */
895    protected function setPreventClickjacking( bool $enable ) {
896        $this->preventClickjacking = $enable;
897    }
898
899    /**
900     * @return bool
901     */
902    public function getPreventClickjacking() {
903        return $this->preventClickjacking;
904    }
905
906    /**
907     * Set up date filter options, given request data.
908     *
909     * @param array $opts Options array
910     * @return array Options array with processed start and end date filter options
911     */
912    public static function processDateFilter( array $opts ) {
913        $start = $opts['start'] ?? '';
914        $end = $opts['end'] ?? '';
915        $year = $opts['year'] ?? '';
916        $month = $opts['month'] ?? '';
917
918        if ( $start !== '' && $end !== '' && $start > $end ) {
919            $temp = $start;
920            $start = $end;
921            $end = $temp;
922        }
923
924        // If year/month legacy filtering options are set, convert them to display the new stamp
925        if ( $year !== '' || $month !== '' ) {
926            // Reuse getDateCond logic, but subtract a day because
927            // the endpoints of our date range appear inclusive
928            // but the internal end offsets are always exclusive
929            $legacyTimestamp = ReverseChronologicalPager::getOffsetDate( $year, $month );
930            $legacyDateTime = new DateTime( $legacyTimestamp->getTimestamp( TS_ISO_8601 ) );
931            $legacyDateTime = $legacyDateTime->modify( '-1 day' );
932
933            // Clear the new timestamp range options if used and
934            // replace with the converted legacy timestamp
935            $start = '';
936            $end = $legacyDateTime->format( 'Y-m-d' );
937        }
938
939        $opts['start'] = $start;
940        $opts['end'] = $end;
941
942        return $opts;
943    }
944}
945
946/**
947 * Retain the old class name for backwards compatibility.
948 * @deprecated since 1.41
949 */
950class_alias( ContribsPager::class, 'ContribsPager' );