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