Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
8.19% |
14 / 171 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
DeletedContribsPager | |
8.24% |
14 / 170 |
|
0.00% |
0 / 14 |
1037.46 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
getDefaultQuery | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getQueryInfo | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
doBatchLookups | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
20 | |||
reallyDoQuery | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
getExtraSortFields | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getIndexField | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTarget | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getNamespace | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getStartBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEndBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getNamespaceCond | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
formatRow | |
77.78% |
14 / 18 |
|
0.00% |
0 / 1 |
5.27 | |||
formatRevisionRow | |
0.00% |
0 / 81 |
|
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 | |
22 | namespace MediaWiki\Pager; |
23 | |
24 | use ChangesList; |
25 | use ChangeTags; |
26 | use IDBAccessObject; |
27 | use MediaWiki\Cache\LinkBatchFactory; |
28 | use MediaWiki\CommentFormatter\CommentFormatter; |
29 | use MediaWiki\Context\IContextSource; |
30 | use MediaWiki\HookContainer\HookContainer; |
31 | use MediaWiki\HookContainer\HookRunner; |
32 | use MediaWiki\Html\Html; |
33 | use MediaWiki\Linker\Linker; |
34 | use MediaWiki\Linker\LinkRenderer; |
35 | use MediaWiki\MediaWikiServices; |
36 | use MediaWiki\Parser\Sanitizer; |
37 | use MediaWiki\Revision\RevisionFactory; |
38 | use MediaWiki\Revision\RevisionRecord; |
39 | use MediaWiki\SpecialPage\SpecialPage; |
40 | use MediaWiki\Title\Title; |
41 | use stdClass; |
42 | use Wikimedia\Rdbms\FakeResultWrapper; |
43 | use Wikimedia\Rdbms\IConnectionProvider; |
44 | use Wikimedia\Rdbms\IResultWrapper; |
45 | |
46 | /** |
47 | * @ingroup Pager |
48 | */ |
49 | class 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 | */ |
440 | class_alias( DeletedContribsPager::class, 'DeletedContribsPager' ); |