Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.61% covered (success)
92.61%
326 / 352
40.00% covered (danger)
40.00%
8 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContributionsPager
92.61% covered (success)
92.61%
326 / 352
40.00% covered (danger)
40.00%
8 / 20
91.12
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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 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            $namespaces = [ $this->namespace ];
426            $eq_op = $this->nsInvert ? '!=' : '=';
427            if ( $this->associated ) {
428                $namespaces[] = $this->namespaceInfo->getAssociated( $this->namespace );
429            }
430            return [ $dbr->expr( $this->pageNamespaceField, $eq_op, $namespaces ) ];
431        }
432
433        return [];
434    }
435
436    /**
437     * @return false|string[]
438     */
439    public function getTagFilter() {
440        return $this->tagFilter;
441    }
442
443    /**
444     * @return bool
445     */
446    public function getTagInvert() {
447        return $this->tagInvert;
448    }
449
450    /**
451     * @return string
452     */
453    public function getTarget() {
454        return $this->target;
455    }
456
457    /**
458     * @return bool
459     */
460    public function isNewOnly() {
461        return $this->newOnly;
462    }
463
464    /**
465     * @return int|string
466     */
467    public function getNamespace() {
468        return $this->namespace;
469    }
470
471    protected function doBatchLookups() {
472        # Do a link batch query
473        $this->mResult->seek( 0 );
474        $parentRevIds = [];
475        $this->mParentLens = [];
476        $revisions = [];
477        $linkBatch = $this->linkBatchFactory->newLinkBatch();
478        # Give some pointers to make (last) links
479        foreach ( $this->mResult as $row ) {
480            if ( isset( $row->{$this->revisionParentIdField} ) && $row->{$this->revisionParentIdField} ) {
481                $parentRevIds[] = (int)$row->{$this->revisionParentIdField};
482            }
483            if ( $this->revisionStore->isRevisionRow( $row, $this->isArchive ? 'archive' : 'revision' ) ) {
484                $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
485                if ( $this->target !== $row->{$this->userNameField} ) {
486                    // If the target does not match the author, batch the author's talk page
487                    $linkBatch->add( NS_USER_TALK, $row->{$this->userNameField} );
488                }
489                $linkBatch->add( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
490                $revisions[$row->{$this->revisionIdField}] = $this->createRevisionRecord( $row );
491            }
492        }
493        # Fetch rev_len for revisions not already scanned above
494        $this->mParentLens += $this->revisionStore->getRevisionSizes(
495            array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
496        );
497        $linkBatch->execute();
498
499        $this->formattedComments = $this->commentFormatter->createRevisionBatch()
500            ->authority( $this->getAuthority() )
501            ->revisions( $revisions )
502            ->hideIfDeleted()
503            ->execute();
504
505        # For performance, save the revision objects for later.
506        # The array is indexed by rev_id. doBatchLookups() may be called
507        # multiple times with different results, so merge the revisions array,
508        # ignoring any duplicates.
509        $this->revisions += $revisions;
510    }
511
512    /**
513     * @inheritDoc
514     */
515    protected function getStartBody() {
516        return "<section class='mw-pager-body'>\n";
517    }
518
519    /**
520     * @inheritDoc
521     */
522    protected function getEndBody() {
523        return "</section>\n";
524    }
525
526    /**
527     * If the object looks like a revision row, or corresponds to a previously
528     * cached revision, return the RevisionRecord. Otherwise, return null.
529     *
530     * @since 1.35
531     *
532     * @param mixed $row
533     * @param Title|null $title
534     * @return RevisionRecord|null
535     */
536    public function tryCreatingRevisionRecord( $row, $title = null ) {
537        if ( $row instanceof stdClass && isset( $row->{$this->revisionIdField} )
538            && isset( $this->revisions[$row->{$this->revisionIdField}] )
539        ) {
540            return $this->revisions[$row->{$this->revisionIdField}];
541        }
542
543        if (
544            $this->isArchive &&
545            $this->revisionStore->isRevisionRow( $row, 'archive' )
546        ) {
547            return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
548        }
549
550        if (
551            !$this->isArchive &&
552            $this->revisionStore->isRevisionRow( $row )
553        ) {
554            return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
555        }
556
557        return null;
558    }
559
560    /**
561     * Create a revision record from a $row that models a revision.
562     *
563     * @param mixed $row
564     * @param Title|null $title
565     * @return RevisionRecord
566     */
567    public function createRevisionRecord( $row, $title = null ) {
568        if ( $this->isArchive ) {
569            return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
570        }
571
572        return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
573    }
574
575    /**
576     * Generates each row in the contributions list.
577     *
578     * Contributions which are marked "top" are currently on top of the history.
579     * For these contributions, a [rollback] link is shown for users with roll-
580     * back privileges. The rollback link restores the most recent version that
581     * was not written by the target user.
582     *
583     * @todo This would probably look a lot nicer in a table.
584     * @param stdClass|mixed $row
585     * @return string
586     */
587    public function formatRow( $row ) {
588        $ret = '';
589        $classes = [];
590        $attribs = [];
591
592        $linkRenderer = $this->getLinkRenderer();
593
594        $page = null;
595        // Create a title for the revision if possible
596        // Rows from the hook may not include title information
597        if ( isset( $row->{$this->pageNamespaceField} ) && isset( $row->{$this->pageTitleField} ) ) {
598            $page = Title::makeTitle( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
599        }
600
601        // Flow overrides the ContribsPager::reallyDoQuery hook, causing this
602        // function to be called with a special object for $row. It expects us
603        // skip formatting so that the row can be formatted by the
604        // ContributionsLineEnding hook below.
605        // FIXME: have some better way for extensions to provide formatted rows.
606        $revRecord = $this->tryCreatingRevisionRecord( $row, $page );
607        if ( $revRecord && $page ) {
608            $revRecord = $this->createRevisionRecord( $row, $page );
609            $attribs['data-mw-revid'] = $revRecord->getId();
610
611            $link = $linkRenderer->makeLink(
612                $page,
613                $page->getPrefixedText(),
614                [ 'class' => 'mw-contributions-title' ],
615                $page->isRedirect() ? [ 'redirect' => 'no' ] : []
616            );
617            # Mark current revisions
618            $topmarktext = '';
619
620            // Add links for seeing history, diff, etc.
621            if ( $this->isArchive ) {
622                // Add the same links as DeletedContribsPager::formatRevisionRow
623                $undelete = SpecialPage::getTitleFor( 'Undelete' );
624                if ( $this->getAuthority()->isAllowed( 'deletedtext' ) ) {
625                    $last = $linkRenderer->makeKnownLink(
626                        $undelete,
627                        new HtmlArmor( $this->messages['diff'] ),
628                        [],
629                        [
630                            'target' => $page->getPrefixedText(),
631                            'timestamp' => $revRecord->getTimestamp(),
632                            'diff' => 'prev'
633                        ]
634                    );
635                } else {
636                    $last = $this->messages['diff'];
637                }
638
639                $logs = SpecialPage::getTitleFor( 'Log' );
640                $dellog = $linkRenderer->makeKnownLink(
641                    $logs,
642                    new HtmlArmor( $this->messages['deletionlog'] ),
643                    [],
644                    [
645                        'type' => 'delete',
646                        'page' => $page->getPrefixedText()
647                    ]
648                );
649
650                $reviewlink = $linkRenderer->makeKnownLink(
651                    SpecialPage::getTitleFor( 'Undelete', $page->getPrefixedDBkey() ),
652                    new HtmlArmor( $this->messages['undeleteviewlink'] )
653                );
654
655                $diffHistLinks = Html::rawElement(
656                    'span',
657                    [ 'class' => 'mw-deletedcontribs-tools' ],
658                    $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
659                        [ $last, $dellog, $reviewlink ] ) )->escaped()
660                );
661
662            } else {
663                $pagerTools = new PagerTools(
664                    $revRecord,
665                    null,
666                    $row->{$this->revisionIdField} === $row->page_latest && !$row->page_is_new,
667                    $this->hookRunner,
668                    $page,
669                    $this->getContext(),
670                    $this->getLinkRenderer()
671                );
672                if ( $row->{$this->revisionIdField} === $row->page_latest ) {
673                    $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
674                    $classes[] = 'mw-contributions-current';
675                }
676                if ( $pagerTools->shouldPreventClickjacking() ) {
677                    $this->setPreventClickjacking( true );
678                }
679                $topmarktext .= $pagerTools->toHTML();
680                # Is there a visible previous revision?
681                if ( $revRecord->getParentId() !== 0 &&
682                    $revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
683                ) {
684                    $difftext = $linkRenderer->makeKnownLink(
685                        $page,
686                        new HtmlArmor( $this->messages['diff'] ),
687                        [ 'class' => 'mw-changeslist-diff' ],
688                        [
689                            'diff' => 'prev',
690                            'oldid' => $row->{$this->revisionIdField},
691                        ]
692                    );
693                } else {
694                    $difftext = $this->messages['diff'];
695                }
696                $histlink = $linkRenderer->makeKnownLink(
697                    $page,
698                    new HtmlArmor( $this->messages['hist'] ),
699                    [ 'class' => 'mw-changeslist-history' ],
700                    [ 'action' => 'history' ]
701                );
702
703                // While it might be tempting to use a list here
704                // this would result in clutter and slows down navigating the content
705                // in assistive technology.
706                // See https://phabricator.wikimedia.org/T205581#4734812
707                $diffHistLinks = Html::rawElement( 'span',
708                    [ 'class' => 'mw-changeslist-links' ],
709                    // The spans are needed to ensure the dividing '|' elements are not
710                    // themselves styled as links.
711                    Html::rawElement( 'span', [], $difftext ) .
712                    ' ' . // Space needed for separating two words.
713                    Html::rawElement( 'span', [], $histlink )
714                );
715            }
716
717            if ( $row->{$this->revisionParentIdField} === null ) {
718                // For some reason rev_parent_id isn't populated for this row.
719                // Its rumoured this is true on wikipedia for some revisions (T36922).
720                // Next best thing is to have the total number of bytes.
721                $chardiff = ' <span class="mw-changeslist-separator"></span> ';
722                $chardiff .= Linker::formatRevisionSize( $row->{$this->revisionLengthField} );
723                $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
724            } else {
725                $parentLen = 0;
726                if ( isset( $this->mParentLens[$row->{$this->revisionParentIdField}] ) ) {
727                    $parentLen = $this->mParentLens[$row->{$this->revisionParentIdField}];
728                }
729
730                $chardiff = ' <span class="mw-changeslist-separator"></span> ';
731                $chardiff .= ChangesList::showCharacterDifference(
732                    $parentLen,
733                    $row->{$this->revisionLengthField},
734                    $this->getContext()
735                );
736                $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
737            }
738
739            $lang = $this->getLanguage();
740
741            $comment = $this->formattedComments[$row->{$this->revisionIdField}];
742
743            if ( $comment === '' ) {
744                $defaultComment = $this->messages['changeslist-nocomment'];
745                $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
746            }
747
748            $comment = $lang->getDirMark() . $comment;
749
750            $authority = $this->getAuthority();
751            $d = ChangesList::revDateLink( $revRecord, $authority, $lang, $page );
752
753            // When the author is different from the target, always show user and user talk links
754            $userlink = '';
755            $revUser = $revRecord->getUser();
756            $revUserId = $revUser ? $revUser->getId() : 0;
757            $revUserText = $revUser ? $revUser->getName() : '';
758            if ( $this->target !== $revUserText ) {
759                $userlink = ' <span class="mw-changeslist-separator"></span> '
760                    . $lang->getDirMark()
761                    . Linker::userLink( $revUserId, $revUserText );
762                $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
763                    Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' ';
764            }
765
766            $flags = [];
767            if ( $revRecord->getParentId() === 0 ) {
768                $flags[] = ChangesList::flag( 'newpage' );
769            }
770
771            if ( $revRecord->isMinor() ) {
772                $flags[] = ChangesList::flag( 'minor' );
773            }
774
775            $del = Linker::getRevDeleteLink( $authority, $revRecord, $page );
776            if ( $del !== '' ) {
777                $del .= ' ';
778            }
779
780            # Tags, if any. Save some time using a cache.
781            [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
782                $this->tagsCache->makeKey(
783                    $row->ts_tags ?? '',
784                    $this->getUser()->getName(),
785                    $lang->getCode()
786                ),
787                fn () => ChangeTags::formatSummaryRow(
788                    $row->ts_tags,
789                    null,
790                    $this->getContext()
791                )
792            );
793            $classes = array_merge( $classes, $newClasses );
794
795            if ( !$this->isArchive ) {
796                $this->hookRunner->onSpecialContributions__formatRow__flags(
797                    $this->getContext(), $row, $flags );
798            }
799
800            $templateParams = [
801                'del' => $del,
802                'timestamp' => $d,
803                'diffHistLinks' => $diffHistLinks,
804                'charDifference' => $chardiff,
805                'flags' => $flags,
806                'articleLink' => $link,
807                'userlink' => $userlink,
808                'logText' => $comment,
809                'topmarktext' => $topmarktext,
810                'tagSummary' => $tagSummary,
811            ];
812
813            # Denote if username is redacted for this edit
814            if ( $revRecord->isDeleted( RevisionRecord::DELETED_USER ) ) {
815                $templateParams['rev-deleted-user-contribs'] =
816                    $this->msg( 'rev-deleted-user-contribs' )->escaped();
817            }
818
819            $ret = $this->templateParser->processTemplate(
820                'SpecialContributionsLine',
821                $templateParams
822            );
823        }
824
825        if ( !$this->isArchive ) {
826            // Let extensions add data
827            $this->hookRunner->onContributionsLineEnding( $this, $ret, $row, $classes, $attribs );
828            $attribs = array_filter( $attribs,
829                [ Sanitizer::class, 'isReservedDataAttribute' ],
830                ARRAY_FILTER_USE_KEY
831            );
832        }
833
834        // TODO: Handle exceptions in the catch block above.  Do any extensions rely on
835        // receiving empty rows?
836
837        if ( $classes === [] && $attribs === [] && $ret === '' ) {
838            wfDebug( "Dropping Special:Contribution row that could not be formatted" );
839            return "<!-- Could not format Special:Contribution row. -->\n";
840        }
841        $attribs['class'] = $classes;
842
843        // FIXME: The signature of the ContributionsLineEnding hook makes it
844        // very awkward to move this LI wrapper into the template.
845        return Html::rawElement( 'li', $attribs, $ret ) . "\n";
846    }
847
848    /**
849     * Overwrite Pager function and return a helpful comment
850     * @return string
851     */
852    protected function getSqlComment() {
853        if ( $this->namespace || $this->deletedOnly ) {
854            // potentially slow, see CR r58153
855            return 'contributions page filtered for namespace or RevisionDeleted edits';
856        } else {
857            return 'contributions page unfiltered';
858        }
859    }
860
861    /**
862     * @deprecated since 1.38, use ::setPreventClickjacking() instead
863     */
864    protected function preventClickjacking() {
865        $this->setPreventClickjacking( true );
866    }
867
868    /**
869     * @param bool $enable
870     * @since 1.38
871     */
872    protected function setPreventClickjacking( bool $enable ) {
873        $this->preventClickjacking = $enable;
874    }
875
876    /**
877     * @return bool
878     */
879    public function getPreventClickjacking() {
880        return $this->preventClickjacking;
881    }
882
883}