Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.19% covered (danger)
8.19%
14 / 171
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
DeletedContribsPager
8.24% covered (danger)
8.24%
14 / 170
0.00% covered (danger)
0.00%
0 / 14
1037.46
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultQuery
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 doBatchLookups
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 reallyDoQuery
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getExtraSortFields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getIndexField
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
 getNamespace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStartBody
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEndBody
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNamespaceCond
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 formatRow
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
5.27
 formatRevisionRow
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 1
90
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 IDBAccessObject;
27use MediaWiki\Cache\LinkBatchFactory;
28use MediaWiki\CommentFormatter\CommentFormatter;
29use MediaWiki\Context\IContextSource;
30use MediaWiki\HookContainer\HookContainer;
31use MediaWiki\HookContainer\HookRunner;
32use MediaWiki\Html\Html;
33use MediaWiki\Linker\Linker;
34use MediaWiki\Linker\LinkRenderer;
35use MediaWiki\MediaWikiServices;
36use MediaWiki\Parser\Sanitizer;
37use MediaWiki\Revision\RevisionFactory;
38use MediaWiki\Revision\RevisionRecord;
39use MediaWiki\SpecialPage\SpecialPage;
40use MediaWiki\Title\Title;
41use stdClass;
42use Wikimedia\Rdbms\FakeResultWrapper;
43use Wikimedia\Rdbms\IConnectionProvider;
44use Wikimedia\Rdbms\IResultWrapper;
45
46/**
47 * @ingroup Pager
48 */
49class DeletedContribsPager extends ReverseChronologicalPager {
50
51    public $mGroupByDate = true;
52
53    /**
54     * @var string[] Local cache for escaped messages
55     */
56    public $messages;
57
58    /**
59     * @var string User name, or a string describing an IP address range
60     */
61    public $target;
62
63    /**
64     * @var string|int A single namespace number, or an empty string for all namespaces
65     */
66    public $namespace = '';
67
68    /** @var string[] */
69    private $formattedComments = [];
70
71    /** @var RevisionRecord[] Cached revisions by ID */
72    private $revisions = [];
73
74    private HookRunner $hookRunner;
75    private RevisionFactory $revisionFactory;
76    private CommentFormatter $commentFormatter;
77    private LinkBatchFactory $linkBatchFactory;
78
79    /**
80     * @param IContextSource $context
81     * @param HookContainer $hookContainer
82     * @param LinkRenderer $linkRenderer
83     * @param IConnectionProvider $dbProvider
84     * @param RevisionFactory $revisionFactory
85     * @param CommentFormatter $commentFormatter
86     * @param LinkBatchFactory $linkBatchFactory
87     * @param string $target
88     * @param string|int $namespace
89     */
90    public function __construct(
91        IContextSource $context,
92        HookContainer $hookContainer,
93        LinkRenderer $linkRenderer,
94        IConnectionProvider $dbProvider,
95        RevisionFactory $revisionFactory,
96        CommentFormatter $commentFormatter,
97        LinkBatchFactory $linkBatchFactory,
98        $target,
99        $namespace
100    ) {
101        parent::__construct( $context, $linkRenderer );
102
103        $msgs = [ 'deletionlog', 'undeleteviewlink', 'diff' ];
104        foreach ( $msgs as $msg ) {
105            $this->messages[$msg] = $this->msg( $msg )->text();
106        }
107        $this->target = $target;
108        $this->namespace = $namespace;
109        $this->hookRunner = new HookRunner( $hookContainer );
110        $this->revisionFactory = $revisionFactory;
111        $this->commentFormatter = $commentFormatter;
112        $this->linkBatchFactory = $linkBatchFactory;
113    }
114
115    public function getDefaultQuery() {
116        $query = parent::getDefaultQuery();
117        $query['target'] = $this->target;
118
119        return $query;
120    }
121
122    public function getQueryInfo() {
123        $dbr = $this->getDatabase();
124        $queryBuilder = $this->revisionFactory->newArchiveSelectQueryBuilder( $dbr )
125            ->joinComment()
126            ->where( [ 'actor_name' => $this->target ] )
127            ->andWhere( $this->getNamespaceCond() );
128        // Paranoia: avoid brute force searches (T19792)
129        if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
130            $queryBuilder->andWhere(
131                $dbr->bitAnd( 'ar_deleted', RevisionRecord::DELETED_USER ) . ' = 0'
132            );
133        } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
134            $queryBuilder->andWhere(
135                $dbr->bitAnd( 'ar_deleted', RevisionRecord::SUPPRESSED_USER ) .
136                ' != ' . RevisionRecord::SUPPRESSED_USER
137            );
138        }
139
140        MediaWikiServices::getInstance()->getChangeTagsStore()->modifyDisplayQueryBuilder( $queryBuilder, 'archive' );
141
142        return $queryBuilder->getQueryInfo( 'join_conds' );
143    }
144
145    protected function doBatchLookups() {
146        // Do a link batch query
147        $this->mResult->seek( 0 );
148        $revisions = [];
149        $linkBatch = $this->linkBatchFactory->newLinkBatch();
150        // Give some pointers to make (last) links
151        $revisionRows = [];
152        foreach ( $this->mResult as $row ) {
153            if ( $this->revisionFactory->isRevisionRow( $row, 'archive' ) ) {
154                $revisionRows[] = $row;
155                $linkBatch->add( $row->ar_namespace, $row->ar_title );
156            }
157        }
158        // Cannot combine both loops, because RevisionFactory::newRevisionFromArchiveRow needs
159        // the title information in LinkCache to avoid extra db queries
160        $linkBatch->execute();
161
162        foreach ( $revisionRows as $row ) {
163            $revisions[$row->ar_rev_id] = $this->revisionFactory->newRevisionFromArchiveRow(
164                $row,
165                IDBAccessObject::READ_NORMAL,
166                Title::makeTitle( $row->ar_namespace, $row->ar_title )
167            );
168        }
169
170        $this->formattedComments = $this->commentFormatter->createRevisionBatch()
171            ->authority( $this->getAuthority() )
172            ->revisions( $revisions )
173            ->execute();
174
175        // For performance, save the revision objects for later.
176        // The array is indexed by rev_id. doBatchLookups() may be called
177        // multiple times with different results, so merge the revisions array,
178        // ignoring any duplicates.
179        $this->revisions += $revisions;
180    }
181
182    /**
183     * This method basically executes the exact same code as the parent class, though with
184     * a hook added, to allow extensions to add additional queries.
185     *
186     * @param string $offset Index offset, inclusive
187     * @param int $limit Exact query limit
188     * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
189     * @return IResultWrapper
190     */
191    public function reallyDoQuery( $offset, $limit, $order ) {
192        $data = [ parent::reallyDoQuery( $offset, $limit, $order ) ];
193
194        // This hook will allow extensions to add in additional queries, nearly
195        // identical to ContribsPager::reallyDoQuery.
196        $this->hookRunner->onDeletedContribsPager__reallyDoQuery(
197            $data, $this, $offset, $limit, $order );
198
199        $result = [];
200
201        // loop all results and collect them in an array
202        foreach ( $data as $query ) {
203            foreach ( $query as $i => $row ) {
204                // use index column as key, allowing us to easily sort in PHP
205                $result[$row->{$this->getIndexField()} . "-$i"] = $row;
206            }
207        }
208
209        // sort results
210        if ( $order === self::QUERY_ASCENDING ) {
211            ksort( $result );
212        } else {
213            krsort( $result );
214        }
215
216        // enforce limit
217        $result = array_slice( $result, 0, $limit );
218
219        // get rid of array keys
220        $result = array_values( $result );
221
222        return new FakeResultWrapper( $result );
223    }
224
225    /**
226     * @return string[]
227     */
228    protected function getExtraSortFields() {
229        return [ 'ar_id' ];
230    }
231
232    public function getIndexField() {
233        return 'ar_timestamp';
234    }
235
236    /**
237     * @return string
238     */
239    public function getTarget() {
240        return $this->target;
241    }
242
243    /**
244     * @return int|string
245     */
246    public function getNamespace() {
247        return $this->namespace;
248    }
249
250    /**
251     * @inheritDoc
252     */
253    protected function getStartBody() {
254        return "<section class='mw-pager-body'>\n";
255    }
256
257    /**
258     * @inheritDoc
259     */
260    protected function getEndBody() {
261        return "</section>\n";
262    }
263
264    private function getNamespaceCond() {
265        if ( $this->namespace !== '' ) {
266            return [ 'ar_namespace' => (int)$this->namespace ];
267        } else {
268            return [];
269        }
270    }
271
272    /**
273     * Generates each row in the contributions list.
274     *
275     * @todo This would probably look a lot nicer in a table.
276     * @param stdClass $row
277     * @return string
278     */
279    public function formatRow( $row ) {
280        $ret = '';
281        $classes = [];
282        $attribs = [];
283
284        if ( $this->revisionFactory->isRevisionRow( $row, 'archive' ) ) {
285            $attribs['data-mw-revid'] = $row->ar_rev_id;
286            [ $ret, $classes ] = $this->formatRevisionRow( $row );
287        }
288
289        // Let extensions add data
290        $this->hookRunner->onDeletedContributionsLineEnding(
291            $this, $ret, $row, $classes, $attribs );
292        $attribs = array_filter( $attribs,
293            [ Sanitizer::class, 'isReservedDataAttribute' ],
294            ARRAY_FILTER_USE_KEY
295        );
296
297        if ( $classes === [] && $attribs === [] && $ret === '' ) {
298            wfDebug( "Dropping Special:DeletedContribution row that could not be formatted" );
299            $ret = "<!-- Could not format Special:DeletedContribution row. -->\n";
300        } else {
301            $attribs['class'] = $classes;
302            $ret = Html::rawElement( 'li', $attribs, $ret ) . "\n";
303        }
304
305        return $ret;
306    }
307
308    /**
309     * Generates each row in the contributions list for archive entries.
310     *
311     * Contributions which are marked "top" are currently on top of the history.
312     * For these contributions, a [rollback] link is shown for users with sysop
313     * privileges. The rollback link restores the most recent version that was not
314     * written by the target user.
315     *
316     * @todo This would probably look a lot nicer in a table.
317     * @param stdClass $row
318     * @return array
319     */
320    private function formatRevisionRow( $row ) {
321        $page = Title::makeTitle( $row->ar_namespace, $row->ar_title );
322
323        $linkRenderer = $this->getLinkRenderer();
324
325        $revRecord = $this->revisions[$row->ar_rev_id] ?? $this->revisionFactory->newRevisionFromArchiveRow(
326                $row,
327                IDBAccessObject::READ_NORMAL,
328                $page
329            );
330
331        $undelete = SpecialPage::getTitleFor( 'Undelete' );
332
333        $logs = SpecialPage::getTitleFor( 'Log' );
334        $dellog = $linkRenderer->makeKnownLink(
335            $logs,
336            $this->messages['deletionlog'],
337            [],
338            [
339                'type' => 'delete',
340                'page' => $page->getPrefixedText()
341            ]
342        );
343
344        $reviewlink = $linkRenderer->makeKnownLink(
345            SpecialPage::getTitleFor( 'Undelete', $page->getPrefixedDBkey() ),
346            $this->messages['undeleteviewlink']
347        );
348
349        $user = $this->getUser();
350
351        if ( $this->getAuthority()->isAllowed( 'deletedtext' ) ) {
352            $last = $linkRenderer->makeKnownLink(
353                $undelete,
354                $this->messages['diff'],
355                [],
356                [
357                    'target' => $page->getPrefixedText(),
358                    'timestamp' => $revRecord->getTimestamp(),
359                    'diff' => 'prev'
360                ]
361            );
362        } else {
363            $last = htmlspecialchars( $this->messages['diff'] );
364        }
365
366        $comment = $row->ar_rev_id
367            ? $this->formattedComments[$row->ar_rev_id]
368            : $this->commentFormatter->formatRevision( $revRecord, $user );
369        $date = $this->getLanguage()->userTimeAndDate( $revRecord->getTimestamp(), $user );
370
371        if ( !$this->getAuthority()->isAllowed( 'undelete' ) ||
372            !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
373        ) {
374            $link = htmlspecialchars( $date ); // unusable link
375        } else {
376            $link = $linkRenderer->makeKnownLink(
377                $undelete,
378                $date,
379                [ 'class' => 'mw-changeslist-date' ],
380                [
381                    'target' => $page->getPrefixedText(),
382                    'timestamp' => $revRecord->getTimestamp()
383                ]
384            );
385        }
386        // Style deleted items
387        if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
388            $class = Linker::getRevisionDeletedClass( $revRecord );
389            $link = '<span class="' . $class . '">' . $link . '</span>';
390        }
391
392        $pagelink = $linkRenderer->makeLink(
393            $page,
394            null,
395            [ 'class' => 'mw-changeslist-title' ]
396        );
397
398        if ( $revRecord->isMinor() ) {
399            $mflag = ChangesList::flag( 'minor' );
400        } else {
401            $mflag = '';
402        }
403
404        // Revision delete link
405        $del = Linker::getRevDeleteLink( $user, $revRecord, $page );
406        if ( $del ) {
407            $del .= ' ';
408        }
409
410        $tools = Html::rawElement(
411            'span',
412            [ 'class' => 'mw-deletedcontribs-tools' ],
413            $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
414                [ $last, $dellog, $reviewlink ] ) )->escaped()
415        );
416
417        // Tags, if any.
418        [ $tagSummary, $classes ] = ChangeTags::formatSummaryRow(
419            $row->ts_tags,
420            'deletedcontributions',
421            $this->getContext()
422        );
423
424        $separator = '<span class="mw-changeslist-separator">. .</span>';
425        $ret = "{$del}{$link} {$tools} {$separator} {$mflag} {$pagelink} {$comment} {$tagSummary}";
426
427        # Denote if username is redacted for this edit
428        if ( $revRecord->isDeleted( RevisionRecord::DELETED_USER ) ) {
429            $ret .= " <strong>" . $this->msg( 'rev-deleted-user-contribs' )->escaped() . "</strong>";
430        }
431
432        return [ $ret, $classes ];
433    }
434}
435
436/**
437 * Retain the old class name for backwards compatibility.
438 * @deprecated since 1.41
439 */
440class_alias( DeletedContribsPager::class, 'DeletedContribsPager' );