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