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/** 23 * @ingroup Pager 24 */ 25use MediaWiki\Linker\LinkRenderer; 26use MediaWiki\MediaWikiServices; 27use MediaWiki\Revision\RevisionFactory; 28use MediaWiki\Revision\RevisionRecord; 29use Wikimedia\Rdbms\FakeResultWrapper; 30use Wikimedia\Rdbms\IDatabase; 31use Wikimedia\Rdbms\IResultWrapper; 32 33class DeletedContribsPager extends IndexPager { 34 35 /** 36 * @var bool Default direction for pager 37 */ 38 public $mDefaultDirection = IndexPager::DIR_DESCENDING; 39 40 /** 41 * @var string[] Local cache for escaped messages 42 */ 43 public $messages; 44 45 /** 46 * @var string User name, or a string describing an IP address range 47 */ 48 public $target; 49 50 /** 51 * @var string|int A single namespace number, or an empty string for all namespaces 52 */ 53 public $namespace = ''; 54 55 /** 56 * @var IDatabase 57 */ 58 public $mDb; 59 60 /** 61 * @var string Navigation bar with paging links. 62 */ 63 protected $mNavigationBar; 64 65 public function __construct( IContextSource $context, $target, $namespace, 66 LinkRenderer $linkRenderer 67 ) { 68 parent::__construct( $context, $linkRenderer ); 69 $msgs = [ 'deletionlog', 'undeleteviewlink', 'diff' ]; 70 foreach ( $msgs as $msg ) { 71 $this->messages[$msg] = $this->msg( $msg )->text(); 72 } 73 $this->target = $target; 74 $this->namespace = $namespace; 75 $this->mDb = wfGetDB( DB_REPLICA, 'contributions' ); 76 } 77 78 public function getDefaultQuery() { 79 $query = parent::getDefaultQuery(); 80 $query['target'] = $this->target; 81 82 return $query; 83 } 84 85 public function getQueryInfo() { 86 $userCond = [ 87 // ->getJoin() below takes care of any joins needed 88 ActorMigration::newMigration()->getWhere( 89 wfGetDB( DB_REPLICA ), 'ar_user', User::newFromName( $this->target, false ), false 90 )['conds'] 91 ]; 92 $conds = array_merge( $userCond, $this->getNamespaceCond() ); 93 $user = $this->getUser(); 94 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); 95 // Paranoia: avoid brute force searches (T19792) 96 if ( !$permissionManager->userHasRight( $user, 'deletedhistory' ) ) { 97 $conds[] = $this->mDb->bitAnd( 'ar_deleted', RevisionRecord::DELETED_USER ) . ' = 0'; 98 } elseif ( !$permissionManager->userHasAnyRight( $user, 'suppressrevision', 'viewsuppressed' ) ) { 99 $conds[] = $this->mDb->bitAnd( 'ar_deleted', RevisionRecord::SUPPRESSED_USER ) . 100 ' != ' . RevisionRecord::SUPPRESSED_USER; 101 } 102 103 $commentQuery = CommentStore::getStore()->getJoin( 'ar_comment' ); 104 $actorQuery = ActorMigration::newMigration()->getJoin( 'ar_user' ); 105 106 return [ 107 'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'], 108 'fields' => [ 109 'ar_rev_id', 'ar_id', 'ar_namespace', 'ar_title', 'ar_timestamp', 110 'ar_minor_edit', 'ar_deleted' 111 ] + $commentQuery['fields'] + $actorQuery['fields'], 112 'conds' => $conds, 113 'options' => [], 114 'join_conds' => $commentQuery['joins'] + $actorQuery['joins'], 115 ]; 116 } 117 118 /** 119 * This method basically executes the exact same code as the parent class, though with 120 * a hook added, to allow extensions to add additional queries. 121 * 122 * @param string $offset Index offset, inclusive 123 * @param int $limit Exact query limit 124 * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING 125 * @return IResultWrapper 126 */ 127 public function reallyDoQuery( $offset, $limit, $order ) { 128 $data = [ parent::reallyDoQuery( $offset, $limit, $order ) ]; 129 130 // This hook will allow extensions to add in additional queries, nearly 131 // identical to ContribsPager::reallyDoQuery. 132 $this->getHookRunner()->onDeletedContribsPager__reallyDoQuery( 133 $data, $this, $offset, $limit, $order ); 134 135 $result = []; 136 137 // loop all results and collect them in an array 138 foreach ( $data as $query ) { 139 foreach ( $query as $i => $row ) { 140 // use index column as key, allowing us to easily sort in PHP 141 $result[$row->{$this->getIndexField()} . "-$i"] = $row; 142 } 143 } 144 145 // sort results 146 if ( $order === self::QUERY_ASCENDING ) { 147 ksort( $result ); 148 } else { 149 krsort( $result ); 150 } 151 152 // enforce limit 153 $result = array_slice( $result, 0, $limit ); 154 155 // get rid of array keys 156 $result = array_values( $result ); 157 158 return new FakeResultWrapper( $result ); 159 } 160 161 public function getIndexField() { 162 return 'ar_timestamp'; 163 } 164 165 /** 166 * @return string 167 */ 168 public function getTarget() { 169 return $this->target; 170 } 171 172 /** 173 * @return int|string 174 */ 175 public function getNamespace() { 176 return $this->namespace; 177 } 178 179 protected function getStartBody() { 180 return "<ul>\n"; 181 } 182 183 protected function getEndBody() { 184 return "</ul>\n"; 185 } 186 187 public function getNavigationBar() { 188 if ( isset( $this->mNavigationBar ) ) { 189 return $this->mNavigationBar; 190 } 191 192 $linkTexts = [ 193 'prev' => $this->msg( 'pager-newer-n' )->numParams( $this->mLimit )->escaped(), 194 'next' => $this->msg( 'pager-older-n' )->numParams( $this->mLimit )->escaped(), 195 'first' => $this->msg( 'histlast' )->escaped(), 196 'last' => $this->msg( 'histfirst' )->escaped() 197 ]; 198 199 $pagingLinks = $this->getPagingLinks( $linkTexts ); 200 $limitLinks = $this->getLimitLinks(); 201 $lang = $this->getLanguage(); 202 $limits = $lang->pipeList( $limitLinks ); 203 204 $firstLast = $lang->pipeList( [ $pagingLinks['first'], $pagingLinks['last'] ] ); 205 $firstLast = $this->msg( 'parentheses' )->rawParams( $firstLast )->escaped(); 206 $prevNext = $this->msg( 'viewprevnext' ) 207 ->rawParams( 208 $pagingLinks['prev'], 209 $pagingLinks['next'], 210 $limits 211 )->escaped(); 212 $separator = $this->msg( 'word-separator' )->escaped(); 213 $this->mNavigationBar = $firstLast . $separator . $prevNext; 214 215 return $this->mNavigationBar; 216 } 217 218 private function getNamespaceCond() { 219 if ( $this->namespace !== '' ) { 220 return [ 'ar_namespace' => (int)$this->namespace ]; 221 } else { 222 return []; 223 } 224 } 225 226 /** 227 * Generates each row in the contributions list. 228 * 229 * @todo This would probably look a lot nicer in a table. 230 * @param stdClass $row 231 * @return string 232 */ 233 public function formatRow( $row ) { 234 $ret = ''; 235 $classes = []; 236 $attribs = []; 237 238 $revFactory = MediaWikiServices::getInstance()->getRevisionFactory(); 239 240 /* 241 * There may be more than just revision rows. To make sure that we'll only be processing 242 * revisions here, let's _try_ to build a revision out of our row (without displaying 243 * notices though) and then trying to grab data from the built object. If we succeed, 244 * we're definitely dealing with revision data and we may proceed, if not, we'll leave it 245 * to extensions to subscribe to the hook to parse the row. 246 */ 247 Wikimedia\suppressWarnings(); 248 try { 249 $revRecord = $revFactory->newRevisionFromArchiveRow( $row ); 250 $validRevision = (bool)$revRecord->getId(); 251 } catch ( Exception $e ) { 252 $validRevision = false; 253 } 254 Wikimedia\restoreWarnings(); 255 256 if ( $validRevision ) { 257 $attribs['data-mw-revid'] = $revRecord->getId(); 258 $ret = $this->formatRevisionRow( $row ); 259 } 260 261 // Let extensions add data 262 $this->getHookRunner()->onDeletedContributionsLineEnding( 263 $this, $ret, $row, $classes, $attribs ); 264 $attribs = array_filter( $attribs, 265 [ Sanitizer::class, 'isReservedDataAttribute' ], 266 ARRAY_FILTER_USE_KEY 267 ); 268 269 if ( $classes === [] && $attribs === [] && $ret === '' ) { 270 wfDebug( "Dropping Special:DeletedContribution row that could not be formatted" ); 271 $ret = "<!-- Could not format Special:DeletedContribution row. -->\n"; 272 } else { 273 $attribs['class'] = $classes; 274 $ret = Html::rawElement( 'li', $attribs, $ret ) . "\n"; 275 } 276 277 return $ret; 278 } 279 280 /** 281 * Generates each row in the contributions list for archive entries. 282 * 283 * Contributions which are marked "top" are currently on top of the history. 284 * For these contributions, a [rollback] link is shown for users with sysop 285 * privileges. The rollback link restores the most recent version that was not 286 * written by the target user. 287 * 288 * @todo This would probably look a lot nicer in a table. 289 * @param stdClass $row 290 * @return string 291 */ 292 private function formatRevisionRow( $row ) { 293 $page = Title::makeTitle( $row->ar_namespace, $row->ar_title ); 294 295 $linkRenderer = $this->getLinkRenderer(); 296 297 $revRecord = MediaWikiServices::getInstance() 298 ->getRevisionFactory() 299 ->newRevisionFromArchiveRow( 300 $row, 301 RevisionFactory::READ_NORMAL, 302 $page 303 ); 304 305 $undelete = SpecialPage::getTitleFor( 'Undelete' ); 306 307 $logs = SpecialPage::getTitleFor( 'Log' ); 308 $dellog = $linkRenderer->makeKnownLink( 309 $logs, 310 $this->messages['deletionlog'], 311 [], 312 [ 313 'type' => 'delete', 314 'page' => $page->getPrefixedText() 315 ] 316 ); 317 318 $reviewlink = $linkRenderer->makeKnownLink( 319 SpecialPage::getTitleFor( 'Undelete', $page->getPrefixedDBkey() ), 320 $this->messages['undeleteviewlink'] 321 ); 322 323 $user = $this->getUser(); 324 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); 325 326 if ( $permissionManager->userHasRight( $user, 'deletedtext' ) ) { 327 $last = $linkRenderer->makeKnownLink( 328 $undelete, 329 $this->messages['diff'], 330 [], 331 [ 332 'target' => $page->getPrefixedText(), 333 'timestamp' => $revRecord->getTimestamp(), 334 'diff' => 'prev' 335 ] 336 ); 337 } else { 338 $last = htmlspecialchars( $this->messages['diff'] ); 339 } 340 341 $comment = Linker::revComment( $revRecord ); 342 $date = $this->getLanguage()->userTimeAndDate( $revRecord->getTimestamp(), $user ); 343 344 if ( !$permissionManager->userHasRight( $user, 'undelete' ) || 345 !RevisionRecord::userCanBitfield( 346 $revRecord->getVisibility(), 347 RevisionRecord::DELETED_TEXT, 348 $user 349 ) 350 ) { 351 $link = htmlspecialchars( $date ); // unusable link 352 } else { 353 $link = $linkRenderer->makeKnownLink( 354 $undelete, 355 $date, 356 [ 'class' => 'mw-changeslist-date' ], 357 [ 358 'target' => $page->getPrefixedText(), 359 'timestamp' => $revRecord->getTimestamp() 360 ] 361 ); 362 } 363 // Style deleted items 364 if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) { 365 $link = '<span class="history-deleted">' . $link . '</span>'; 366 } 367 368 $pagelink = $linkRenderer->makeLink( 369 $page, 370 null, 371 [ 'class' => 'mw-changeslist-title' ] 372 ); 373 374 if ( $revRecord->isMinor() ) { 375 $mflag = ChangesList::flag( 'minor' ); 376 } else { 377 $mflag = ''; 378 } 379 380 // Revision delete link 381 $del = Linker::getRevDeleteLink( $user, $revRecord, $page ); 382 if ( $del ) { 383 $del .= ' '; 384 } 385 386 $tools = Html::rawElement( 387 'span', 388 [ 'class' => 'mw-deletedcontribs-tools' ], 389 $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList( 390 [ $last, $dellog, $reviewlink ] ) )->escaped() 391 ); 392 393 $separator = '<span class="mw-changeslist-separator">. .</span>'; 394 $ret = "{$del}{$link} {$tools} {$separator} {$mflag} {$pagelink} {$comment}"; 395 396 # Denote if username is redacted for this edit 397 if ( $revRecord->isDeleted( RevisionRecord::DELETED_USER ) ) { 398 $ret .= " <strong>" . $this->msg( 'rev-deleted-user-contribs' )->escaped() . "</strong>"; 399 } 400 401 return $ret; 402 } 403 404 /** 405 * Get the Database object in use 406 * 407 * @return IDatabase 408 */ 409 public function getDatabase() { 410 return $this->mDb; 411 } 412} 413