1<?php 2/** 3 * Implements Special:Undelete 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 * @ingroup SpecialPage 22 */ 23 24use MediaWiki\MediaWikiServices; 25use MediaWiki\Revision\RevisionFactory; 26use MediaWiki\Revision\RevisionRecord; 27use MediaWiki\Revision\SlotRecord; 28use MediaWiki\Storage\NameTableAccessException; 29use Wikimedia\Rdbms\IResultWrapper; 30 31/** 32 * Special page allowing users with the appropriate permissions to view 33 * and restore deleted content. 34 * 35 * @ingroup SpecialPage 36 */ 37class SpecialUndelete extends SpecialPage { 38 private $mAction; 39 private $mTarget; 40 private $mTimestamp; 41 private $mRestore; 42 private $mRevdel; 43 private $mInvert; 44 private $mFilename; 45 private $mTargetTimestamp; 46 private $mAllowed; 47 private $mCanView; 48 private $mComment; 49 private $mToken; 50 /** @var bool|null */ 51 private $mPreview; 52 /** @var bool|null */ 53 private $mDiff; 54 /** @var bool|null */ 55 private $mDiffOnly; 56 /** @var bool|null */ 57 private $mUnsuppress; 58 /** @var int[]|null */ 59 private $mFileVersions; 60 61 /** @var Title */ 62 private $mTargetObj; 63 /** 64 * @var string Search prefix 65 */ 66 private $mSearchPrefix; 67 68 public function __construct() { 69 parent::__construct( 'Undelete', 'deletedhistory' ); 70 } 71 72 public function doesWrites() { 73 return true; 74 } 75 76 private function loadRequest( $par ) { 77 $request = $this->getRequest(); 78 $user = $this->getUser(); 79 80 $this->mAction = $request->getVal( 'action' ); 81 if ( $par !== null && $par !== '' ) { 82 $this->mTarget = $par; 83 } else { 84 $this->mTarget = $request->getVal( 'target' ); 85 } 86 87 $this->mTargetObj = null; 88 89 if ( $this->mTarget !== null && $this->mTarget !== '' ) { 90 $this->mTargetObj = Title::newFromText( $this->mTarget ); 91 } 92 93 $this->mSearchPrefix = $request->getText( 'prefix' ); 94 $time = $request->getVal( 'timestamp' ); 95 $this->mTimestamp = $time ? wfTimestamp( TS_MW, $time ) : ''; 96 $this->mFilename = $request->getVal( 'file' ); 97 98 $posted = $request->wasPosted() && 99 $user->matchEditToken( $request->getVal( 'wpEditToken' ) ); 100 $this->mRestore = $request->getCheck( 'restore' ) && $posted; 101 $this->mRevdel = $request->getCheck( 'revdel' ) && $posted; 102 $this->mInvert = $request->getCheck( 'invert' ) && $posted; 103 $this->mPreview = $request->getCheck( 'preview' ) && $posted; 104 $this->mDiff = $request->getCheck( 'diff' ); 105 $this->mDiffOnly = $request->getBool( 'diffonly', $this->getUser()->getOption( 'diffonly' ) ); 106 $this->mComment = $request->getText( 'wpComment' ); 107 $this->mUnsuppress = $request->getVal( 'wpUnsuppress' ) && MediaWikiServices::getInstance() 108 ->getPermissionManager() 109 ->userHasRight( $user, 'suppressrevision' ); 110 $this->mToken = $request->getVal( 'token' ); 111 112 if ( $this->isAllowed( 'undelete' ) ) { 113 $this->mAllowed = true; // user can restore 114 $this->mCanView = true; // user can view content 115 } elseif ( $this->isAllowed( 'deletedtext' ) ) { 116 $this->mAllowed = false; // user cannot restore 117 $this->mCanView = true; // user can view content 118 $this->mRestore = false; 119 } else { // user can only view the list of revisions 120 $this->mAllowed = false; 121 $this->mCanView = false; 122 $this->mTimestamp = ''; 123 $this->mRestore = false; 124 } 125 126 if ( $this->mRestore || $this->mInvert ) { 127 $timestamps = []; 128 $this->mFileVersions = []; 129 foreach ( $request->getValues() as $key => $val ) { 130 $matches = []; 131 if ( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) { 132 array_push( $timestamps, $matches[1] ); 133 } 134 135 if ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) { 136 $this->mFileVersions[] = intval( $matches[1] ); 137 } 138 } 139 rsort( $timestamps ); 140 $this->mTargetTimestamp = $timestamps; 141 } 142 } 143 144 /** 145 * Checks whether a user is allowed the permission for the 146 * specific title if one is set. 147 * 148 * @param string $permission 149 * @param User|null $user 150 * @return bool 151 */ 152 protected function isAllowed( $permission, User $user = null ) { 153 $user = $user ?: $this->getUser(); 154 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); 155 $block = $user->getBlock(); 156 157 if ( $this->mTargetObj !== null ) { 158 return $permissionManager->userCan( $permission, $user, $this->mTargetObj ); 159 } else { 160 $hasRight = $permissionManager->userHasRight( $user, $permission ); 161 $sitewideBlock = $block && $block->isSitewide(); 162 return $permission === 'undelete' ? ( $hasRight && !$sitewideBlock ) : $hasRight; 163 } 164 } 165 166 public function userCanExecute( User $user ) { 167 return $this->isAllowed( $this->mRestriction, $user ); 168 } 169 170 /** 171 * @inheritDoc 172 */ 173 public function checkPermissions() { 174 $user = $this->getUser(); 175 176 // First check if user has the right to use this page. If not, 177 // show a permissions error whether they are blocked or not. 178 if ( !parent::userCanExecute( $user ) ) { 179 $this->displayRestrictionError(); 180 } 181 182 // If a user has the right to use this page, but is blocked from 183 // the target, show a block error. 184 if ( 185 $this->mTargetObj && MediaWikiServices::getInstance() 186 ->getPermissionManager() 187 ->isBlockedFrom( $user, $this->mTargetObj ) 188 ) { 189 throw new UserBlockedError( $user->getBlock() ); 190 } 191 192 // Finally, do the comprehensive permission check via isAllowed. 193 if ( !$this->userCanExecute( $user ) ) { 194 $this->displayRestrictionError(); 195 } 196 } 197 198 public function execute( $par ) { 199 $this->useTransactionalTimeLimit(); 200 201 $user = $this->getUser(); 202 203 $this->setHeaders(); 204 $this->outputHeader(); 205 $this->addHelpLink( 'Help:Deletion_and_undeletion' ); 206 207 $this->loadRequest( $par ); 208 $this->checkPermissions(); // Needs to be after mTargetObj is set 209 210 $out = $this->getOutput(); 211 212 if ( $this->mTargetObj === null ) { 213 $out->addWikiMsg( 'undelete-header' ); 214 215 # Not all users can just browse every deleted page from the list 216 if ( MediaWikiServices::getInstance() 217 ->getPermissionManager() 218 ->userHasRight( $user, 'browsearchive' ) 219 ) { 220 $this->showSearchForm(); 221 } 222 223 return; 224 } 225 226 $this->addHelpLink( 'Help:Undelete' ); 227 if ( $this->mAllowed ) { 228 $out->setPageTitle( $this->msg( 'undeletepage' ) ); 229 } else { 230 $out->setPageTitle( $this->msg( 'viewdeletedpage' ) ); 231 } 232 233 $this->getSkin()->setRelevantTitle( $this->mTargetObj ); 234 235 if ( $this->mTimestamp !== '' ) { 236 $this->showRevision( $this->mTimestamp ); 237 } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) { 238 $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename ); 239 // Check if user is allowed to see this file 240 if ( !$file->exists() ) { 241 $out->addWikiMsg( 'filedelete-nofile', $this->mFilename ); 242 } elseif ( !$file->userCan( File::DELETED_FILE, $user ) ) { 243 if ( $file->isDeleted( File::DELETED_RESTRICTED ) ) { 244 throw new PermissionsError( 'suppressrevision' ); 245 } else { 246 throw new PermissionsError( 'deletedtext' ); 247 } 248 } elseif ( !$user->matchEditToken( $this->mToken, $this->mFilename ) ) { 249 $this->showFileConfirmationForm( $this->mFilename ); 250 } else { 251 $this->showFile( $this->mFilename ); 252 } 253 } elseif ( $this->mAction === 'submit' ) { 254 if ( $this->mRestore ) { 255 $this->undelete(); 256 } elseif ( $this->mRevdel ) { 257 $this->redirectToRevDel(); 258 } 259 260 } else { 261 $this->showHistory(); 262 } 263 } 264 265 /** 266 * Convert submitted form data to format expected by RevisionDelete and 267 * redirect the request 268 */ 269 private function redirectToRevDel() { 270 $archive = new PageArchive( $this->mTargetObj ); 271 272 $revisions = []; 273 274 foreach ( $this->getRequest()->getValues() as $key => $val ) { 275 $matches = []; 276 if ( preg_match( "/^ts(\d{14})$/", $key, $matches ) ) { 277 $revisionRecord = $archive->getRevisionRecordByTimestamp( $matches[1] ); 278 if ( $revisionRecord ) { 279 // Can return null 280 $revisions[ $revisionRecord->getId() ] = 1; 281 } 282 } 283 } 284 285 $query = [ 286 'type' => 'revision', 287 'ids' => $revisions, 288 'target' => $this->mTargetObj->getPrefixedText() 289 ]; 290 $url = SpecialPage::getTitleFor( 'Revisiondelete' )->getFullURL( $query ); 291 $this->getOutput()->redirect( $url ); 292 } 293 294 private function showSearchForm() { 295 $out = $this->getOutput(); 296 $out->setPageTitle( $this->msg( 'undelete-search-title' ) ); 297 $fuzzySearch = $this->getRequest()->getVal( 'fuzzy', true ); 298 299 $out->enableOOUI(); 300 301 $fields = []; 302 $fields[] = new OOUI\ActionFieldLayout( 303 new OOUI\TextInputWidget( [ 304 'name' => 'prefix', 305 'inputId' => 'prefix', 306 'infusable' => true, 307 'value' => $this->mSearchPrefix, 308 'autofocus' => true, 309 ] ), 310 new OOUI\ButtonInputWidget( [ 311 'label' => $this->msg( 'undelete-search-submit' )->text(), 312 'flags' => [ 'primary', 'progressive' ], 313 'inputId' => 'searchUndelete', 314 'type' => 'submit', 315 ] ), 316 [ 317 'label' => new OOUI\HtmlSnippet( 318 $this->msg( 319 $fuzzySearch ? 'undelete-search-full' : 'undelete-search-prefix' 320 )->parse() 321 ), 322 'align' => 'left', 323 ] 324 ); 325 326 $fieldset = new OOUI\FieldsetLayout( [ 327 'label' => $this->msg( 'undelete-search-box' )->text(), 328 'items' => $fields, 329 ] ); 330 331 $form = new OOUI\FormLayout( [ 332 'method' => 'get', 333 'action' => wfScript(), 334 ] ); 335 336 $form->appendContent( 337 $fieldset, 338 new OOUI\HtmlSnippet( 339 Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) . 340 Html::hidden( 'fuzzy', $fuzzySearch ) 341 ) 342 ); 343 344 $out->addHTML( 345 new OOUI\PanelLayout( [ 346 'expanded' => false, 347 'padded' => true, 348 'framed' => true, 349 'content' => $form, 350 ] ) 351 ); 352 353 # List undeletable articles 354 if ( $this->mSearchPrefix ) { 355 // For now, we enable search engine match only when specifically asked to 356 // by using fuzzy=1 parameter. 357 if ( $fuzzySearch ) { 358 $result = PageArchive::listPagesBySearch( $this->mSearchPrefix ); 359 } else { 360 $result = PageArchive::listPagesByPrefix( $this->mSearchPrefix ); 361 } 362 $this->showList( $result ); 363 } 364 } 365 366 /** 367 * Generic list of deleted pages 368 * 369 * @param IResultWrapper $result 370 * @return bool 371 */ 372 private function showList( $result ) { 373 $out = $this->getOutput(); 374 375 if ( $result->numRows() == 0 ) { 376 $out->addWikiMsg( 'undelete-no-results' ); 377 378 return false; 379 } 380 381 $out->addWikiMsg( 'undeletepagetext', $this->getLanguage()->formatNum( $result->numRows() ) ); 382 383 $linkRenderer = $this->getLinkRenderer(); 384 $undelete = $this->getPageTitle(); 385 $out->addHTML( "<ul id='undeleteResultsList'>\n" ); 386 foreach ( $result as $row ) { 387 $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title ); 388 if ( $title !== null ) { 389 $item = $linkRenderer->makeKnownLink( 390 $undelete, 391 $title->getPrefixedText(), 392 [], 393 [ 'target' => $title->getPrefixedText() ] 394 ); 395 } else { 396 // The title is no longer valid, show as text 397 $item = Html::element( 398 'span', 399 [ 'class' => 'mw-invalidtitle' ], 400 Linker::getInvalidTitleDescription( 401 $this->getContext(), 402 $row->ar_namespace, 403 $row->ar_title 404 ) 405 ); 406 } 407 $revs = $this->msg( 'undeleterevisions' )->numParams( $row->count )->parse(); 408 $out->addHTML( 409 Html::rawElement( 410 'li', 411 [ 'class' => 'undeleteResult' ], 412 "{$item} ({$revs})" 413 ) 414 ); 415 } 416 $result->free(); 417 $out->addHTML( "</ul>\n" ); 418 419 return true; 420 } 421 422 private function showRevision( $timestamp ) { 423 if ( !preg_match( '/[0-9]{14}/', $timestamp ) ) { 424 return; 425 } 426 427 $archive = new PageArchive( $this->mTargetObj, $this->getConfig() ); 428 if ( !$this->getHookRunner()->onUndeleteForm__showRevision( 429 $archive, $this->mTargetObj ) 430 ) { 431 return; 432 } 433 $revRecord = $archive->getRevisionRecordByTimestamp( $timestamp ); 434 435 $out = $this->getOutput(); 436 $user = $this->getUser(); 437 438 if ( !$revRecord ) { 439 $out->addWikiMsg( 'undeleterevision-missing' ); 440 441 return; 442 } 443 444 if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) { 445 if ( !RevisionRecord::userCanBitfield( 446 $revRecord->getVisibility(), 447 RevisionRecord::DELETED_TEXT, 448 $user 449 ) ) { 450 $out->wrapWikiMsg( 451 "<div class='mw-warning plainlinks'>\n$1\n</div>\n", 452 $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ? 453 'rev-suppressed-text-permission' : 'rev-deleted-text-permission' 454 ); 455 456 return; 457 } 458 459 $out->wrapWikiMsg( 460 "<div class='mw-warning plainlinks'>\n$1\n</div>\n", 461 $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ? 462 'rev-suppressed-text-view' : 'rev-deleted-text-view' 463 ); 464 $out->addHTML( '<br />' ); 465 // and we are allowed to see... 466 } 467 468 if ( $this->mDiff ) { 469 $previousRevRecord = $archive->getPreviousRevisionRecord( $timestamp ); 470 if ( $previousRevRecord ) { 471 $this->showDiff( $previousRevRecord, $revRecord ); 472 if ( $this->mDiffOnly ) { 473 return; 474 } 475 476 $out->addHTML( '<hr />' ); 477 } else { 478 $out->addWikiMsg( 'undelete-nodiff' ); 479 } 480 } 481 482 $link = $this->getLinkRenderer()->makeKnownLink( 483 $this->getPageTitle( $this->mTargetObj->getPrefixedDBkey() ), 484 $this->mTargetObj->getPrefixedText() 485 ); 486 487 $lang = $this->getLanguage(); 488 489 // date and time are separate parameters to facilitate localisation. 490 // $time is kept for backward compat reasons. 491 $time = $lang->userTimeAndDate( $timestamp, $user ); 492 $d = $lang->userDate( $timestamp, $user ); 493 $t = $lang->userTime( $timestamp, $user ); 494 $userLink = Linker::revUserTools( $revRecord ); 495 496 $content = $revRecord->getContent( 497 SlotRecord::MAIN, 498 RevisionRecord::FOR_THIS_USER, 499 $user 500 ); 501 502 // TODO: MCR: this will have to become something like $hasTextSlots and $hasNonTextSlots 503 $isText = ( $content instanceof TextContent ); 504 505 if ( $this->mPreview || $isText ) { 506 $openDiv = '<div id="mw-undelete-revision" class="mw-warning">'; 507 } else { 508 $openDiv = '<div id="mw-undelete-revision">'; 509 } 510 $out->addHTML( $openDiv ); 511 512 // Revision delete links 513 if ( !$this->mDiff ) { 514 $revdel = Linker::getRevDeleteLink( 515 $user, 516 $revRecord, 517 $this->mTargetObj 518 ); 519 if ( $revdel ) { 520 $out->addHTML( "$revdel " ); 521 } 522 } 523 524 $out->addWikiMsg( 525 'undelete-revision', 526 Message::rawParam( $link ), $time, 527 Message::rawParam( $userLink ), $d, $t 528 ); 529 $out->addHTML( '</div>' ); 530 531 // Hook hard deprecated since 1.35 532 if ( $this->getHookContainer()->isRegistered( 'UndeleteShowRevision' ) ) { 533 // Only create the Revision object if needed 534 $rev = new Revision( $revRecord ); 535 if ( !$this->getHookRunner()->onUndeleteShowRevision( 536 $this->mTargetObj, 537 $rev 538 ) ) { 539 return; 540 } 541 } 542 543 if ( $this->mPreview || !$isText ) { 544 // NOTE: non-text content has no source view, so always use rendered preview 545 546 $popts = $out->parserOptions(); 547 $renderer = MediaWikiServices::getInstance()->getRevisionRenderer(); 548 549 $rendered = $renderer->getRenderedRevision( 550 $revRecord, 551 $popts, 552 $user, 553 [ 'audience' => RevisionRecord::FOR_THIS_USER ] 554 ); 555 556 // Fail hard if the audience check fails, since we already checked 557 // at the beginning of this method. 558 $pout = $rendered->getRevisionParserOutput(); 559 560 $out->addParserOutput( $pout, [ 561 'enableSectionEditLinks' => false, 562 ] ); 563 } 564 565 $out->enableOOUI(); 566 $buttonFields = []; 567 568 if ( $isText ) { 569 '@phan-var TextContent $content'; 570 // TODO: MCR: make this work for multiple slots 571 // source view for textual content 572 $sourceView = Xml::element( 'textarea', [ 573 'readonly' => 'readonly', 574 'cols' => 80, 575 'rows' => 25 576 ], $content->getText() . "\n" ); 577 578 $buttonFields[] = new OOUI\ButtonInputWidget( [ 579 'type' => 'submit', 580 'name' => 'preview', 581 'label' => $this->msg( 'showpreview' )->text() 582 ] ); 583 } else { 584 $sourceView = ''; 585 } 586 587 $buttonFields[] = new OOUI\ButtonInputWidget( [ 588 'name' => 'diff', 589 'type' => 'submit', 590 'label' => $this->msg( 'showdiff' )->text() 591 ] ); 592 593 $out->addHTML( 594 $sourceView . 595 Xml::openElement( 'div', [ 596 'style' => 'clear: both' ] ) . 597 Xml::openElement( 'form', [ 598 'method' => 'post', 599 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ) ] ) . 600 Xml::element( 'input', [ 601 'type' => 'hidden', 602 'name' => 'target', 603 'value' => $this->mTargetObj->getPrefixedDBkey() ] ) . 604 Xml::element( 'input', [ 605 'type' => 'hidden', 606 'name' => 'timestamp', 607 'value' => $timestamp ] ) . 608 Xml::element( 'input', [ 609 'type' => 'hidden', 610 'name' => 'wpEditToken', 611 'value' => $user->getEditToken() ] ) . 612 new OOUI\FieldLayout( 613 new OOUI\Widget( [ 614 'content' => new OOUI\HorizontalLayout( [ 615 'items' => $buttonFields 616 ] ) 617 ] ) 618 ) . 619 Xml::closeElement( 'form' ) . 620 Xml::closeElement( 'div' ) 621 ); 622 } 623 624 /** 625 * Build a diff display between this and the previous either deleted 626 * or non-deleted edit. 627 * 628 * @param RevisionRecord $previousRevRecord 629 * @param RevisionRecord $currentRevRecord 630 */ 631 private function showDiff( 632 RevisionRecord $previousRevRecord, 633 RevisionRecord $currentRevRecord 634 ) { 635 $currentTitle = Title::newFromLinkTarget( $currentRevRecord->getPageAsLinkTarget() ); 636 637 $diffContext = clone $this->getContext(); 638 $diffContext->setTitle( $currentTitle ); 639 $diffContext->setWikiPage( WikiPage::factory( $currentTitle ) ); 640 641 $contentModel = $currentRevRecord->getSlot( 642 SlotRecord::MAIN, 643 RevisionRecord::RAW 644 )->getModel(); 645 646 $diffEngine = MediaWikiServices::getInstance() 647 ->getContentHandlerFactory() 648 ->getContentHandler( $contentModel ) 649 ->createDifferenceEngine( $diffContext ); 650 651 $diffEngine->setRevisions( $previousRevRecord, $currentRevRecord ); 652 $diffEngine->showDiffStyle(); 653 $formattedDiff = $diffEngine->getDiff( 654 $this->diffHeader( $previousRevRecord, 'o' ), 655 $this->diffHeader( $currentRevRecord, 'n' ) 656 ); 657 658 $this->getOutput()->addHTML( "<div>$formattedDiff</div>\n" ); 659 } 660 661 /** 662 * @param RevisionRecord $revRecord 663 * @param string $prefix 664 * @return string 665 */ 666 private function diffHeader( RevisionRecord $revRecord, $prefix ) { 667 $isDeleted = !( $revRecord->getId() && $revRecord->getPageAsLinkTarget() ); 668 if ( $isDeleted ) { 669 /// @todo FIXME: $rev->getTitle() is null for deleted revs...? 670 $targetPage = $this->getPageTitle(); 671 $targetQuery = [ 672 'target' => $this->mTargetObj->getPrefixedText(), 673 'timestamp' => wfTimestamp( TS_MW, $revRecord->getTimestamp() ) 674 ]; 675 } else { 676 /// @todo FIXME: getId() may return non-zero for deleted revs... 677 $targetPage = $revRecord->getPageAsLinkTarget(); 678 $targetQuery = [ 'oldid' => $revRecord->getId() ]; 679 } 680 681 // Add show/hide deletion links if available 682 $user = $this->getUser(); 683 $lang = $this->getLanguage(); 684 $rdel = Linker::getRevDeleteLink( $user, $revRecord, $this->mTargetObj ); 685 686 if ( $rdel ) { 687 $rdel = " $rdel"; 688 } 689 690 $minor = $revRecord->isMinor() ? ChangesList::flag( 'minor' ) : ''; 691 692 $tagIds = wfGetDB( DB_REPLICA )->selectFieldValues( 693 'change_tag', 694 'ct_tag_id', 695 [ 'ct_rev_id' => $revRecord->getId() ], 696 __METHOD__ 697 ); 698 $tags = []; 699 $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore(); 700 foreach ( $tagIds as $tagId ) { 701 try { 702 $tags[] = $changeTagDefStore->getName( (int)$tagId ); 703 } catch ( NameTableAccessException $exception ) { 704 continue; 705 } 706 } 707 $tags = implode( ',', $tags ); 708 $tagSummary = ChangeTags::formatSummaryRow( $tags, 'deleteddiff', $this->getContext() ); 709 710 // FIXME This is reimplementing DifferenceEngine#getRevisionHeader 711 // and partially #showDiffPage, but worse 712 return '<div id="mw-diff-' . $prefix . 'title1"><strong>' . 713 $this->getLinkRenderer()->makeLink( 714 $targetPage, 715 $this->msg( 716 'revisionasof', 717 $lang->userTimeAndDate( $revRecord->getTimestamp(), $user ), 718 $lang->userDate( $revRecord->getTimestamp(), $user ), 719 $lang->userTime( $revRecord->getTimestamp(), $user ) 720 )->text(), 721 [], 722 $targetQuery 723 ) . 724 '</strong></div>' . 725 '<div id="mw-diff-' . $prefix . 'title2">' . 726 Linker::revUserTools( $revRecord ) . '<br />' . 727 '</div>' . 728 '<div id="mw-diff-' . $prefix . 'title3">' . 729 $minor . Linker::revComment( $revRecord ) . $rdel . '<br />' . 730 '</div>' . 731 '<div id="mw-diff-' . $prefix . 'title5">' . 732 $tagSummary[0] . '<br />' . 733 '</div>'; 734 } 735 736 /** 737 * Show a form confirming whether a tokenless user really wants to see a file 738 * @param string $key 739 */ 740 private function showFileConfirmationForm( $key ) { 741 $out = $this->getOutput(); 742 $lang = $this->getLanguage(); 743 $user = $this->getUser(); 744 $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename ); 745 $out->addWikiMsg( 'undelete-show-file-confirm', 746 $this->mTargetObj->getText(), 747 $lang->userDate( $file->getTimestamp(), $user ), 748 $lang->userTime( $file->getTimestamp(), $user ) ); 749 $out->addHTML( 750 Xml::openElement( 'form', [ 751 'method' => 'POST', 752 'action' => $this->getPageTitle()->getLocalURL( [ 753 'target' => $this->mTarget, 754 'file' => $key, 755 'token' => $user->getEditToken( $key ), 756 ] ), 757 ] 758 ) . 759 Xml::submitButton( $this->msg( 'undelete-show-file-submit' )->text() ) . 760 '</form>' 761 ); 762 } 763 764 /** 765 * Show a deleted file version requested by the visitor. 766 * @param string $key 767 */ 768 private function showFile( $key ) { 769 $this->getOutput()->disable(); 770 771 # We mustn't allow the output to be CDN cached, otherwise 772 # if an admin previews a deleted image, and it's cached, then 773 # a user without appropriate permissions can toddle off and 774 # nab the image, and CDN will serve it 775 $response = $this->getRequest()->response(); 776 $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); 777 $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' ); 778 $response->header( 'Pragma: no-cache' ); 779 780 $repo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo(); 781 $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key; 782 $repo->streamFileWithStatus( $path ); 783 } 784 785 protected function showHistory() { 786 $this->checkReadOnly(); 787 788 $out = $this->getOutput(); 789 if ( $this->mAllowed ) { 790 $out->addModules( 'mediawiki.special.undelete' ); 791 } 792 $out->wrapWikiMsg( 793 "<div class='mw-undelete-pagetitle'>\n$1\n</div>\n", 794 [ 'undeletepagetitle', wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) ] 795 ); 796 797 $archive = new PageArchive( $this->mTargetObj, $this->getConfig() ); 798 $this->getHookRunner()->onUndeleteForm__showHistory( $archive, $this->mTargetObj ); 799 800 $out->addHTML( '<div class="mw-undelete-history">' ); 801 if ( $this->mAllowed ) { 802 $out->addWikiMsg( 'undeletehistory' ); 803 $out->addWikiMsg( 'undeleterevdel' ); 804 } else { 805 $out->addWikiMsg( 'undeletehistorynoadmin' ); 806 } 807 $out->addHTML( '</div>' ); 808 809 # List all stored revisions 810 $revisions = $archive->listRevisions(); 811 $files = $archive->listFiles(); 812 813 $haveRevisions = $revisions && $revisions->numRows() > 0; 814 $haveFiles = $files && $files->numRows() > 0; 815 816 # Batch existence check on user and talk pages 817 if ( $haveRevisions ) { 818 $batch = new LinkBatch(); 819 foreach ( $revisions as $row ) { 820 $batch->addObj( Title::makeTitleSafe( NS_USER, $row->ar_user_text ) ); 821 $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->ar_user_text ) ); 822 } 823 $batch->execute(); 824 $revisions->seek( 0 ); 825 } 826 if ( $haveFiles ) { 827 $batch = new LinkBatch(); 828 foreach ( $files as $row ) { 829 $batch->addObj( Title::makeTitleSafe( NS_USER, $row->fa_user_text ) ); 830 $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->fa_user_text ) ); 831 } 832 $batch->execute(); 833 $files->seek( 0 ); 834 } 835 836 if ( $this->mAllowed ) { 837 $out->enableOOUI(); 838 839 $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ); 840 # Start the form here 841 $form = new OOUI\FormLayout( [ 842 'method' => 'post', 843 'action' => $action, 844 'id' => 'undelete', 845 ] ); 846 } 847 848 # Show relevant lines from the deletion log: 849 $deleteLogPage = new LogPage( 'delete' ); 850 $out->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) . "\n" ); 851 LogEventsList::showLogExtract( $out, 'delete', $this->mTargetObj ); 852 # Show relevant lines from the suppression log: 853 $suppressLogPage = new LogPage( 'suppress' ); 854 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); 855 if ( $permissionManager->userHasRight( $this->getUser(), 'suppressionlog' ) ) { 856 $out->addHTML( Xml::element( 'h2', null, $suppressLogPage->getName()->text() ) . "\n" ); 857 LogEventsList::showLogExtract( $out, 'suppress', $this->mTargetObj ); 858 } 859 860 if ( $this->mAllowed && ( $haveRevisions || $haveFiles ) ) { 861 $fields = []; 862 $fields[] = new OOUI\Layout( [ 863 'content' => new OOUI\HtmlSnippet( $this->msg( 'undeleteextrahelp' )->parseAsBlock() ) 864 ] ); 865 866 $fields[] = new OOUI\FieldLayout( 867 new OOUI\TextInputWidget( [ 868 'name' => 'wpComment', 869 'inputId' => 'wpComment', 870 'infusable' => true, 871 'value' => $this->mComment, 872 'autofocus' => true, 873 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP 874 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count 875 // Unicode codepoints. 876 'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT, 877 ] ), 878 [ 879 'label' => $this->msg( 'undeletecomment' )->text(), 880 'align' => 'top', 881 ] 882 ); 883 884 $fields[] = new OOUI\FieldLayout( 885 new OOUI\Widget( [ 886 'content' => new OOUI\HorizontalLayout( [ 887 'items' => [ 888 new OOUI\ButtonInputWidget( [ 889 'name' => 'restore', 890 'inputId' => 'mw-undelete-submit', 891 'value' => '1', 892 'label' => $this->msg( 'undeletebtn' )->text(), 893 'flags' => [ 'primary', 'progressive' ], 894 'type' => 'submit', 895 ] ), 896 new OOUI\ButtonInputWidget( [ 897 'name' => 'invert', 898 'inputId' => 'mw-undelete-invert', 899 'value' => '1', 900 'label' => $this->msg( 'undeleteinvert' )->text() 901 ] ), 902 ] 903 ] ) 904 ] ) 905 ); 906 907 if ( $permissionManager->userHasRight( $this->getUser(), 'suppressrevision' ) ) { 908 $fields[] = new OOUI\FieldLayout( 909 new OOUI\CheckboxInputWidget( [ 910 'name' => 'wpUnsuppress', 911 'inputId' => 'mw-undelete-unsuppress', 912 'value' => '1', 913 ] ), 914 [ 915 'label' => $this->msg( 'revdelete-unsuppress' )->text(), 916 'align' => 'inline', 917 ] 918 ); 919 } 920 921 $fieldset = new OOUI\FieldsetLayout( [ 922 'label' => $this->msg( 'undelete-fieldset-title' )->text(), 923 'id' => 'mw-undelete-table', 924 'items' => $fields, 925 ] ); 926 927 $form->appendContent( 928 new OOUI\PanelLayout( [ 929 'expanded' => false, 930 'padded' => true, 931 'framed' => true, 932 'content' => $fieldset, 933 ] ), 934 new OOUI\HtmlSnippet( 935 Html::hidden( 'target', $this->mTarget ) . 936 Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) 937 ) 938 ); 939 } 940 941 $history = ''; 942 $history .= Xml::element( 'h2', null, $this->msg( 'history' )->text() ) . "\n"; 943 944 if ( $haveRevisions ) { 945 # Show the page's stored (deleted) history 946 947 if ( $permissionManager->userHasRight( $this->getUser(), 'deleterevision' ) ) { 948 $history .= Html::element( 949 'button', 950 [ 951 'name' => 'revdel', 952 'type' => 'submit', 953 'class' => 'deleterevision-log-submit mw-log-deleterevision-button' 954 ], 955 $this->msg( 'showhideselectedversions' )->text() 956 ) . "\n"; 957 } 958 959 $history .= '<ul class="mw-undelete-revlist">'; 960 $remaining = $revisions->numRows(); 961 $earliestLiveTime = $this->mTargetObj->getEarliestRevTime(); 962 963 foreach ( $revisions as $row ) { 964 $remaining--; 965 $history .= $this->formatRevisionRow( $row, $earliestLiveTime, $remaining ); 966 } 967 $revisions->free(); 968 $history .= '</ul>'; 969 } else { 970 $out->addWikiMsg( 'nohistory' ); 971 } 972 973 if ( $haveFiles ) { 974 $history .= Xml::element( 'h2', null, $this->msg( 'filehist' )->text() ) . "\n"; 975 $history .= '<ul class="mw-undelete-revlist">'; 976 foreach ( $files as $row ) { 977 $history .= $this->formatFileRow( $row ); 978 } 979 $files->free(); 980 $history .= '</ul>'; 981 } 982 983 if ( $this->mAllowed ) { 984 # Slip in the hidden controls here 985 $misc = Html::hidden( 'target', $this->mTarget ); 986 $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ); 987 $history .= $misc; 988 989 $form->appendContent( new OOUI\HtmlSnippet( $history ) ); 990 $out->addHTML( $form ); 991 } else { 992 $out->addHTML( $history ); 993 } 994 995 return true; 996 } 997 998 protected function formatRevisionRow( $row, $earliestLiveTime, $remaining ) { 999 $revRecord = MediaWikiServices::getInstance() 1000 ->getRevisionFactory() 1001 ->newRevisionFromArchiveRow( 1002 $row, 1003 RevisionFactory::READ_NORMAL, 1004 $this->mTargetObj 1005 ); 1006 1007 $revTextSize = ''; 1008 $ts = wfTimestamp( TS_MW, $row->ar_timestamp ); 1009 // Build checkboxen... 1010 if ( $this->mAllowed ) { 1011 if ( $this->mInvert ) { 1012 if ( in_array( $ts, $this->mTargetTimestamp ) ) { 1013 $checkBox = Xml::check( "ts$ts" ); 1014 } else { 1015 $checkBox = Xml::check( "ts$ts", true ); 1016 } 1017 } else { 1018 $checkBox = Xml::check( "ts$ts" ); 1019 } 1020 } else { 1021 $checkBox = ''; 1022 } 1023 1024 // Build page & diff links... 1025 $user = $this->getUser(); 1026 if ( $this->mCanView ) { 1027 $titleObj = $this->getPageTitle(); 1028 # Last link 1029 if ( !RevisionRecord::userCanBitfield( 1030 $revRecord->getVisibility(), 1031 RevisionRecord::DELETED_TEXT, 1032 $this->getUser() 1033 ) ) { 1034 $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) ); 1035 $last = $this->msg( 'diff' )->escaped(); 1036 } elseif ( $remaining > 0 || ( $earliestLiveTime && $ts > $earliestLiveTime ) ) { 1037 $pageLink = $this->getPageLink( $revRecord, $titleObj, $ts ); 1038 $last = $this->getLinkRenderer()->makeKnownLink( 1039 $titleObj, 1040 $this->msg( 'diff' )->text(), 1041 [], 1042 [ 1043 'target' => $this->mTargetObj->getPrefixedText(), 1044 'timestamp' => $ts, 1045 'diff' => 'prev' 1046 ] 1047 ); 1048 } else { 1049 $pageLink = $this->getPageLink( $revRecord, $titleObj, $ts ); 1050 $last = $this->msg( 'diff' )->escaped(); 1051 } 1052 } else { 1053 $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) ); 1054 $last = $this->msg( 'diff' )->escaped(); 1055 } 1056 1057 // User links 1058 $userLink = Linker::revUserTools( $revRecord ); 1059 1060 // Minor edit 1061 $minor = $revRecord->isMinor() ? ChangesList::flag( 'minor' ) : ''; 1062 1063 // Revision text size 1064 $size = $row->ar_len; 1065 if ( $size !== null ) { 1066 $revTextSize = Linker::formatRevisionSize( $size ); 1067 } 1068 1069 // Edit summary 1070 $comment = Linker::revComment( $revRecord ); 1071 1072 // Tags 1073 $attribs = []; 1074 list( $tagSummary, $classes ) = ChangeTags::formatSummaryRow( 1075 $row->ts_tags, 1076 'deletedhistory', 1077 $this->getContext() 1078 ); 1079 if ( $classes ) { 1080 $attribs['class'] = implode( ' ', $classes ); 1081 } 1082 1083 $revisionRow = $this->msg( 'undelete-revision-row2' ) 1084 ->rawParams( 1085 $checkBox, 1086 $last, 1087 $pageLink, 1088 $userLink, 1089 $minor, 1090 $revTextSize, 1091 $comment, 1092 $tagSummary 1093 ) 1094 ->escaped(); 1095 1096 return Xml::tags( 'li', $attribs, $revisionRow ) . "\n"; 1097 } 1098 1099 private function formatFileRow( $row ) { 1100 $file = ArchivedFile::newFromRow( $row ); 1101 $ts = wfTimestamp( TS_MW, $row->fa_timestamp ); 1102 $user = $this->getUser(); 1103 1104 $checkBox = ''; 1105 if ( $this->mCanView && $row->fa_storage_key ) { 1106 if ( $this->mAllowed ) { 1107 $checkBox = Xml::check( 'fileid' . $row->fa_id ); 1108 } 1109 $key = urlencode( $row->fa_storage_key ); 1110 $pageLink = $this->getFileLink( $file, $this->getPageTitle(), $ts, $key ); 1111 } else { 1112 $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) ); 1113 } 1114 $userLink = $this->getFileUser( $file ); 1115 $data = $this->msg( 'widthheight' )->numParams( $row->fa_width, $row->fa_height )->text(); 1116 $bytes = $this->msg( 'parentheses' ) 1117 ->plaintextParams( $this->msg( 'nbytes' )->numParams( $row->fa_size )->text() ) 1118 ->plain(); 1119 $data = htmlspecialchars( $data . ' ' . $bytes ); 1120 $comment = $this->getFileComment( $file ); 1121 1122 // Add show/hide deletion links if available 1123 $canHide = $this->isAllowed( 'deleterevision' ); 1124 if ( $canHide || ( $file->getVisibility() && $this->isAllowed( 'deletedhistory' ) ) ) { 1125 if ( !$file->userCan( File::DELETED_RESTRICTED, $user ) ) { 1126 // Revision was hidden from sysops 1127 $revdlink = Linker::revDeleteLinkDisabled( $canHide ); 1128 } else { 1129 $query = [ 1130 'type' => 'filearchive', 1131 'target' => $this->mTargetObj->getPrefixedDBkey(), 1132 'ids' => $row->fa_id 1133 ]; 1134 $revdlink = Linker::revDeleteLink( $query, 1135 $file->isDeleted( File::DELETED_RESTRICTED ), $canHide ); 1136 } 1137 } else { 1138 $revdlink = ''; 1139 } 1140 1141 return "<li>$checkBox $revdlink $pageLink . . $userLink $data $comment</li>\n"; 1142 } 1143 1144 /** 1145 * Fetch revision text link if it's available to all users 1146 * 1147 * @param RevisionRecord $revRecord 1148 * @param Title $titleObj 1149 * @param string $ts Timestamp 1150 * @return string 1151 */ 1152 private function getPageLink( RevisionRecord $revRecord, $titleObj, $ts ) { 1153 $user = $this->getUser(); 1154 $time = $this->getLanguage()->userTimeAndDate( $ts, $user ); 1155 1156 if ( !RevisionRecord::userCanBitfield( 1157 $revRecord->getVisibility(), 1158 RevisionRecord::DELETED_TEXT, 1159 $user 1160 ) ) { 1161 return '<span class="history-deleted">' . $time . '</span>'; 1162 } 1163 1164 $link = $this->getLinkRenderer()->makeKnownLink( 1165 $titleObj, 1166 $time, 1167 [], 1168 [ 1169 'target' => $this->mTargetObj->getPrefixedText(), 1170 'timestamp' => $ts 1171 ] 1172 ); 1173 1174 if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) { 1175 $link = '<span class="history-deleted">' . $link . '</span>'; 1176 } 1177 1178 return $link; 1179 } 1180 1181 /** 1182 * Fetch image view link if it's available to all users 1183 * 1184 * @param File|ArchivedFile $file 1185 * @param Title $titleObj 1186 * @param string $ts A timestamp 1187 * @param string $key A storage key 1188 * 1189 * @return string HTML fragment 1190 */ 1191 private function getFileLink( $file, $titleObj, $ts, $key ) { 1192 $user = $this->getUser(); 1193 $time = $this->getLanguage()->userTimeAndDate( $ts, $user ); 1194 1195 if ( !$file->userCan( File::DELETED_FILE, $user ) ) { 1196 return '<span class="history-deleted">' . htmlspecialchars( $time ) . '</span>'; 1197 } 1198 1199 $link = $this->getLinkRenderer()->makeKnownLink( 1200 $titleObj, 1201 $time, 1202 [], 1203 [ 1204 'target' => $this->mTargetObj->getPrefixedText(), 1205 'file' => $key, 1206 'token' => $user->getEditToken( $key ) 1207 ] 1208 ); 1209 1210 if ( $file->isDeleted( File::DELETED_FILE ) ) { 1211 $link = '<span class="history-deleted">' . $link . '</span>'; 1212 } 1213 1214 return $link; 1215 } 1216 1217 /** 1218 * Fetch file's user id if it's available to this user 1219 * 1220 * @param File|ArchivedFile $file 1221 * @return string HTML fragment 1222 */ 1223 private function getFileUser( $file ) { 1224 if ( !$file->userCan( File::DELETED_USER, $this->getUser() ) ) { 1225 return '<span class="history-deleted">' . 1226 $this->msg( 'rev-deleted-user' )->escaped() . 1227 '</span>'; 1228 } 1229 1230 $link = Linker::userLink( $file->getRawUser(), $file->getRawUserText() ) . 1231 Linker::userToolLinks( $file->getRawUser(), $file->getRawUserText() ); 1232 1233 if ( $file->isDeleted( File::DELETED_USER ) ) { 1234 $link = '<span class="history-deleted">' . $link . '</span>'; 1235 } 1236 1237 return $link; 1238 } 1239 1240 /** 1241 * Fetch file upload comment if it's available to this user 1242 * 1243 * @param File|ArchivedFile $file 1244 * @return string HTML fragment 1245 */ 1246 private function getFileComment( $file ) { 1247 if ( !$file->userCan( File::DELETED_COMMENT, $this->getUser() ) ) { 1248 return '<span class="history-deleted"><span class="comment">' . 1249 $this->msg( 'rev-deleted-comment' )->escaped() . '</span></span>'; 1250 } 1251 1252 $link = Linker::commentBlock( $file->getRawDescription() ); 1253 1254 if ( $file->isDeleted( File::DELETED_COMMENT ) ) { 1255 $link = '<span class="history-deleted">' . $link . '</span>'; 1256 } 1257 1258 return $link; 1259 } 1260 1261 private function undelete() { 1262 if ( $this->getConfig()->get( 'UploadMaintenance' ) 1263 && $this->mTargetObj->getNamespace() == NS_FILE 1264 ) { 1265 throw new ErrorPageError( 'undelete-error', 'filedelete-maintenance' ); 1266 } 1267 1268 $this->checkReadOnly(); 1269 1270 $out = $this->getOutput(); 1271 $archive = new PageArchive( $this->mTargetObj, $this->getConfig() ); 1272 $this->getHookRunner()->onUndeleteForm__undelete( $archive, $this->mTargetObj ); 1273 $ok = $archive->undeleteAsUser( 1274 $this->mTargetTimestamp, 1275 $this->getUser(), 1276 $this->mComment, 1277 $this->mFileVersions, 1278 $this->mUnsuppress 1279 ); 1280 1281 if ( is_array( $ok ) ) { 1282 if ( $ok[1] ) { // Undeleted file count 1283 $this->getHookRunner()->onFileUndeleteComplete( 1284 $this->mTargetObj, $this->mFileVersions, $this->getUser(), $this->mComment ); 1285 } 1286 1287 $link = $this->getLinkRenderer()->makeKnownLink( $this->mTargetObj ); 1288 $out->addWikiMsg( 'undeletedpage', Message::rawParam( $link ) ); 1289 } else { 1290 $out->setPageTitle( $this->msg( 'undelete-error' ) ); 1291 } 1292 1293 // Show revision undeletion warnings and errors 1294 $status = $archive->getRevisionStatus(); 1295 if ( $status && !$status->isGood() ) { 1296 $out->wrapWikiTextAsInterface( 1297 'error', 1298 '<div id="mw-error-cannotundelete">' . 1299 $status->getWikiText( 1300 'cannotundelete', 1301 'cannotundelete', 1302 $this->getLanguage() 1303 ) . '</div>' 1304 ); 1305 } 1306 1307 // Show file undeletion warnings and errors 1308 $status = $archive->getFileStatus(); 1309 if ( $status && !$status->isGood() ) { 1310 $out->wrapWikiTextAsInterface( 1311 'error', 1312 $status->getWikiText( 1313 'undelete-error-short', 1314 'undelete-error-long', 1315 $this->getLanguage() 1316 ) 1317 ); 1318 } 1319 } 1320 1321 /** 1322 * Return an array of subpages beginning with $search that this special page will accept. 1323 * 1324 * @param string $search Prefix to search for 1325 * @param int $limit Maximum number of results to return (usually 10) 1326 * @param int $offset Number of results to skip (usually 0) 1327 * @return string[] Matching subpages 1328 */ 1329 public function prefixSearchSubpages( $search, $limit, $offset ) { 1330 return $this->prefixSearchString( $search, $limit, $offset ); 1331 } 1332 1333 protected function getGroupName() { 1334 return 'pagetools'; 1335 } 1336} 1337