permissionManager = $permissionManager; $this->revisionStore = $revisionStore; $this->revisionRenderer = $revisionRenderer; $this->contentHandlerFactory = $contentHandlerFactory; $this->changeTagDefStore = $changeTagDefStore; $this->linkBatchFactory = $linkBatchFactory; $this->localRepo = $repoGroup->getLocalRepo(); $this->dbProvider = $dbProvider; $this->userOptionsLookup = $userOptionsLookup; $this->wikiPageFactory = $wikiPageFactory; $this->searchEngineFactory = $searchEngineFactory; $this->undeletePageFactory = $undeletePageFactory; $this->archivedRevisionLookup = $archivedRevisionLookup; $this->commentFormatter = $commentFormatter; $this->watchlistManager = $watchlistManager; } public function doesWrites() { return true; } private function loadRequest( $par ) { $request = $this->getRequest(); $user = $this->getUser(); $this->mAction = $request->getRawVal( 'action' ); if ( $par !== null && $par !== '' ) { $this->mTarget = $par; } else { $this->mTarget = $request->getVal( 'target' ); } $this->mTargetObj = null; if ( $this->mTarget !== null && $this->mTarget !== '' ) { $this->mTargetObj = Title::newFromText( $this->mTarget ); } $this->mSearchPrefix = $request->getText( 'prefix' ); $time = $request->getVal( 'timestamp' ); $this->mTimestamp = $time ? wfTimestamp( TS_MW, $time ) : ''; $this->mFilename = $request->getVal( 'file' ); $posted = $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ); $this->mRestore = $request->getCheck( 'restore' ) && $posted; $this->mRevdel = $request->getCheck( 'revdel' ) && $posted; $this->mInvert = $request->getCheck( 'invert' ) && $posted; $this->mPreview = $request->getCheck( 'preview' ) && $posted; $this->mDiff = $request->getCheck( 'diff' ); $this->mDiffOnly = $request->getBool( 'diffonly', $this->userOptionsLookup->getOption( $this->getUser(), 'diffonly' ) ); $commentList = $request->getText( 'wpCommentList', 'other' ); $comment = $request->getText( 'wpComment' ); if ( $commentList === 'other' ) { $this->mComment = $comment; } elseif ( $comment !== '' ) { $this->mComment = $commentList . $this->msg( 'colon-separator' )->inContentLanguage()->text() . $comment; } else { $this->mComment = $commentList; } $this->mUnsuppress = $request->getVal( 'wpUnsuppress' ) && $this->permissionManager->userHasRight( $user, 'suppressrevision' ); $this->mToken = $request->getVal( 'token' ); $this->mUndeleteTalk = $request->getCheck( 'undeletetalk' ); $this->mHistoryOffset = $request->getVal( 'historyoffset' ); if ( $this->isAllowed( 'undelete' ) ) { $this->mAllowed = true; // user can restore $this->mCanView = true; // user can view content } elseif ( $this->isAllowed( 'deletedtext' ) ) { $this->mAllowed = false; // user cannot restore $this->mCanView = true; // user can view content $this->mRestore = false; } else { // user can only view the list of revisions $this->mAllowed = false; $this->mCanView = false; $this->mTimestamp = ''; $this->mRestore = false; } if ( $this->mRestore || $this->mInvert ) { $timestamps = []; $this->mFileVersions = []; foreach ( $request->getValues() as $key => $val ) { $matches = []; if ( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) { $timestamps[] = $matches[1]; } if ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) { $this->mFileVersions[] = intval( $matches[1] ); } } rsort( $timestamps ); $this->mTargetTimestamp = $timestamps; } } /** * Checks whether a user is allowed the permission for the * specific title if one is set. * * @param string $permission * @param User|null $user * @return bool */ protected function isAllowed( $permission, ?User $user = null ) { $user ??= $this->getUser(); $block = $user->getBlock(); if ( $this->mTargetObj !== null ) { return $this->permissionManager->userCan( $permission, $user, $this->mTargetObj ); } else { $hasRight = $this->permissionManager->userHasRight( $user, $permission ); $sitewideBlock = $block && $block->isSitewide(); return $permission === 'undelete' ? ( $hasRight && !$sitewideBlock ) : $hasRight; } } public function userCanExecute( User $user ) { return $this->isAllowed( $this->mRestriction, $user ); } /** * @inheritDoc */ public function checkPermissions() { $user = $this->getUser(); // First check if user has the right to use this page. If not, // show a permissions error whether they are blocked or not. if ( !parent::userCanExecute( $user ) ) { $this->displayRestrictionError(); } // If a user has the right to use this page, but is blocked from // the target, show a block error. if ( $this->mTargetObj && $this->permissionManager->isBlockedFrom( $user, $this->mTargetObj ) ) { // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null throw new UserBlockedError( $user->getBlock() ); } // Finally, do the comprehensive permission check via isAllowed. if ( !$this->userCanExecute( $user ) ) { $this->displayRestrictionError(); } } public function execute( $par ) { $this->useTransactionalTimeLimit(); $user = $this->getUser(); $this->setHeaders(); $this->outputHeader(); $this->addHelpLink( 'Help:Deletion_and_undeletion' ); $this->loadRequest( $par ); $this->checkPermissions(); // Needs to be after mTargetObj is set $out = $this->getOutput(); if ( $this->mTargetObj === null ) { $out->addWikiMsg( 'undelete-header' ); # Not all users can just browse every deleted page from the list if ( $this->permissionManager->userHasRight( $user, 'browsearchive' ) ) { $this->showSearchForm(); } return; } $this->addHelpLink( 'Help:Undelete' ); if ( $this->mAllowed ) { $out->setPageTitleMsg( $this->msg( 'undeletepage' ) ); } else { $out->setPageTitleMsg( $this->msg( 'viewdeletedpage' ) ); } $this->getSkin()->setRelevantTitle( $this->mTargetObj ); if ( $this->mTimestamp !== '' ) { $this->showRevision( $this->mTimestamp ); } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) { $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename ); // Check if user is allowed to see this file if ( !$file->exists() ) { $out->addWikiMsg( 'filedelete-nofile', $this->mFilename ); } elseif ( !$file->userCan( File::DELETED_FILE, $user ) ) { if ( $file->isDeleted( File::DELETED_RESTRICTED ) ) { throw new PermissionsError( 'suppressrevision' ); } else { throw new PermissionsError( 'deletedtext' ); } } elseif ( !$user->matchEditToken( $this->mToken, $this->mFilename ) ) { $this->showFileConfirmationForm( $this->mFilename ); } else { $this->showFile( $this->mFilename ); } } elseif ( $this->mAction === 'submit' ) { if ( $this->mRestore ) { $this->undelete(); } elseif ( $this->mRevdel ) { $this->redirectToRevDel(); } } elseif ( $this->mAction === 'render' ) { $this->showMoreHistory(); } else { $this->showHistory(); } } /** * Convert submitted form data to format expected by RevisionDelete and * redirect the request */ private function redirectToRevDel() { $revisions = []; foreach ( $this->getRequest()->getValues() as $key => $val ) { $matches = []; if ( preg_match( "/^ts(\d{14})$/", $key, $matches ) ) { $revisionRecord = $this->archivedRevisionLookup ->getRevisionRecordByTimestamp( $this->mTargetObj, $matches[1] ); if ( $revisionRecord ) { // Can return null $revisions[ $revisionRecord->getId() ] = 1; } } } $query = [ 'type' => 'revision', 'ids' => $revisions, 'target' => $this->mTargetObj->getPrefixedText() ]; $url = SpecialPage::getTitleFor( 'Revisiondelete' )->getFullURL( $query ); $this->getOutput()->redirect( $url ); } private function showSearchForm() { $out = $this->getOutput(); $out->setPageTitleMsg( $this->msg( 'undelete-search-title' ) ); $fuzzySearch = $this->getRequest()->getVal( 'fuzzy', '1' ); $out->enableOOUI(); $fields = []; $fields[] = new ActionFieldLayout( new TextInputWidget( [ 'name' => 'prefix', 'inputId' => 'prefix', 'infusable' => true, 'value' => $this->mSearchPrefix, 'autofocus' => true, ] ), new ButtonInputWidget( [ 'label' => $this->msg( 'undelete-search-submit' )->text(), 'flags' => [ 'primary', 'progressive' ], 'inputId' => 'searchUndelete', 'type' => 'submit', ] ), [ 'label' => new HtmlSnippet( $this->msg( $fuzzySearch ? 'undelete-search-full' : 'undelete-search-prefix' )->parse() ), 'align' => 'left', ] ); $fieldset = new FieldsetLayout( [ 'label' => $this->msg( 'undelete-search-box' )->text(), 'items' => $fields, ] ); $form = new FormLayout( [ 'method' => 'get', 'action' => wfScript(), ] ); $form->appendContent( $fieldset, new HtmlSnippet( Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) . Html::hidden( 'fuzzy', $fuzzySearch ) ) ); $out->addHTML( new PanelLayout( [ 'expanded' => false, 'padded' => true, 'framed' => true, 'content' => $form, ] ) ); # List undeletable articles if ( $this->mSearchPrefix ) { // For now, we enable search engine match only when specifically asked to // by using fuzzy=1 parameter. if ( $fuzzySearch ) { $result = PageArchive::listPagesBySearch( $this->mSearchPrefix ); } else { $result = PageArchive::listPagesByPrefix( $this->mSearchPrefix ); } $this->showList( $result ); } } /** * Generic list of deleted pages * * @param IResultWrapper $result * @return bool */ private function showList( $result ) { $out = $this->getOutput(); if ( $result->numRows() == 0 ) { $out->addWikiMsg( 'undelete-no-results' ); return false; } $out->addWikiMsg( 'undeletepagetext', $this->getLanguage()->formatNum( $result->numRows() ) ); $linkRenderer = $this->getLinkRenderer(); $undelete = $this->getPageTitle(); $out->addHTML( "\n" ); return true; } private function showRevision( $timestamp ) { if ( !preg_match( '/[0-9]{14}/', $timestamp ) ) { return; } $out = $this->getOutput(); $out->addModuleStyles( 'mediawiki.interface.helpers.styles' ); // When viewing a specific revision, add a subtitle link back to the overall // history, see T284114 $listLink = $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $this->msg( 'undelete-back-to-list' )->text(), [], [ 'target' => $this->mTargetObj->getPrefixedText() ] ); // same < arrow as with subpages $subtitle = "< $listLink"; $out->setSubtitle( $subtitle ); $archive = new PageArchive( $this->mTargetObj ); // FIXME: This hook must be deprecated, passing PageArchive by ref is awful. if ( !$this->getHookRunner()->onUndeleteForm__showRevision( $archive, $this->mTargetObj ) ) { return; } $revRecord = $this->archivedRevisionLookup->getRevisionRecordByTimestamp( $this->mTargetObj, $timestamp ); $user = $this->getUser(); if ( !$revRecord ) { $out->addWikiMsg( 'undeleterevision-missing' ); return; } if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) { // Used in wikilinks, should not contain whitespaces $titleText = $this->mTargetObj->getPrefixedDBkey(); if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) { $msg = $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ? [ 'rev-suppressed-text-permission', $titleText ] : [ 'rev-deleted-text-permission', $titleText ]; $out->addHTML( Html::warningBox( $this->msg( $msg[0], $msg[1] )->parse(), 'plainlinks' ) ); return; } $msg = $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ? [ 'rev-suppressed-text-view', $titleText ] : [ 'rev-deleted-text-view', $titleText ]; $out->addHTML( Html::warningBox( $this->msg( $msg[0], $msg[1] )->parse(), 'plainlinks' ) ); // and we are allowed to see... } if ( $this->mDiff ) { $previousRevRecord = $this->archivedRevisionLookup ->getPreviousRevisionRecord( $this->mTargetObj, $timestamp ); if ( $previousRevRecord ) { $this->showDiff( $previousRevRecord, $revRecord ); if ( $this->mDiffOnly ) { return; } $out->addHTML( '
' ); } else { $out->addWikiMsg( 'undelete-nodiff' ); } } $link = $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle( $this->mTargetObj->getPrefixedDBkey() ), $this->mTargetObj->getPrefixedText() ); $lang = $this->getLanguage(); // date and time are separate parameters to facilitate localisation. // $time is kept for backward compat reasons. $time = $lang->userTimeAndDate( $timestamp, $user ); $d = $lang->userDate( $timestamp, $user ); $t = $lang->userTime( $timestamp, $user ); $userLink = Linker::revUserTools( $revRecord ); try { $content = $revRecord->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ); } catch ( RevisionAccessException $e ) { $content = null; } // TODO: MCR: this will have to become something like $hasTextSlots and $hasNonTextSlots $isText = ( $content instanceof TextContent ); $undeleteRevisionContent = ''; // Revision delete links if ( !$this->mDiff ) { $revdel = Linker::getRevDeleteLink( $user, $revRecord, $this->mTargetObj ); if ( $revdel ) { $undeleteRevisionContent = $revdel . ' '; } } $undeleteRevisionContent .= $out->msg( 'undelete-revision', Message::rawParam( $link ), $time, Message::rawParam( $userLink ), $d, $t )->parseAsBlock(); if ( $this->mPreview || $isText ) { $out->addHTML( Html::warningBox( $undeleteRevisionContent, 'mw-undelete-revision' ) ); } else { $out->addHTML( Html::rawElement( 'div', [ 'class' => 'mw-undelete-revision', ], $undeleteRevisionContent ) ); } if ( $this->mPreview || !$isText ) { // NOTE: non-text content has no source view, so always use rendered preview $popts = $out->parserOptions(); try { $rendered = $this->revisionRenderer->getRenderedRevision( $revRecord, $popts, $user, [ 'audience' => RevisionRecord::FOR_THIS_USER, 'causeAction' => 'undelete-preview' ] ); // Fail hard if the audience check fails, since we already checked // at the beginning of this method. $pout = $rendered->getRevisionParserOutput(); $out->addParserOutput( $pout, [ 'enableSectionEditLinks' => false, ] ); } catch ( RevisionAccessException $e ) { } } $out->enableOOUI(); $buttonFields = []; if ( $isText ) { '@phan-var TextContent $content'; // TODO: MCR: make this work for multiple slots // source view for textual content $sourceView = Xml::element( 'textarea', [ 'readonly' => 'readonly', 'cols' => 80, 'rows' => 25 ], $content->getText() . "\n" ); $buttonFields[] = new ButtonInputWidget( [ 'type' => 'submit', 'name' => 'preview', 'label' => $this->msg( 'showpreview' )->text() ] ); } else { $sourceView = ''; } $buttonFields[] = new ButtonInputWidget( [ 'name' => 'diff', 'type' => 'submit', 'label' => $this->msg( 'showdiff' )->text() ] ); $out->addHTML( $sourceView . Xml::openElement( 'div', [ 'style' => 'clear: both' ] ) . Xml::openElement( 'form', [ 'method' => 'post', 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ) ] ) . Xml::element( 'input', [ 'type' => 'hidden', 'name' => 'target', 'value' => $this->mTargetObj->getPrefixedDBkey() ] ) . Xml::element( 'input', [ 'type' => 'hidden', 'name' => 'timestamp', 'value' => $timestamp ] ) . Xml::element( 'input', [ 'type' => 'hidden', 'name' => 'wpEditToken', 'value' => $user->getEditToken() ] ) . new FieldLayout( new Widget( [ 'content' => new HorizontalLayout( [ 'items' => $buttonFields ] ) ] ) ) . Xml::closeElement( 'form' ) . Xml::closeElement( 'div' ) ); } /** * Build a diff display between this and the previous either deleted * or non-deleted edit. * * @param RevisionRecord $previousRevRecord * @param RevisionRecord $currentRevRecord */ private function showDiff( RevisionRecord $previousRevRecord, RevisionRecord $currentRevRecord ) { $currentTitle = Title::newFromLinkTarget( $currentRevRecord->getPageAsLinkTarget() ); $diffContext = new DerivativeContext( $this->getContext() ); $diffContext->setTitle( $currentTitle ); $diffContext->setWikiPage( $this->wikiPageFactory->newFromTitle( $currentTitle ) ); $contentModel = $currentRevRecord->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel(); $diffEngine = $this->contentHandlerFactory->getContentHandler( $contentModel ) ->createDifferenceEngine( $diffContext ); $diffEngine->setRevisions( $previousRevRecord, $currentRevRecord ); $diffEngine->showDiffStyle(); $formattedDiff = $diffEngine->getDiff( $this->diffHeader( $previousRevRecord, 'o' ), $this->diffHeader( $currentRevRecord, 'n' ) ); $this->getOutput()->addHTML( "
$formattedDiff
\n" ); } /** * @param RevisionRecord $revRecord * @param string $prefix * @return string */ private function diffHeader( RevisionRecord $revRecord, $prefix ) { if ( $revRecord instanceof RevisionArchiveRecord ) { // Revision in the archive table, only viewable via this special page $targetPage = $this->getPageTitle(); $targetQuery = [ 'target' => $this->mTargetObj->getPrefixedText(), 'timestamp' => wfTimestamp( TS_MW, $revRecord->getTimestamp() ) ]; } else { // Revision in the revision table, viewable by oldid $targetPage = $revRecord->getPageAsLinkTarget(); $targetQuery = [ 'oldid' => $revRecord->getId() ]; } // Add show/hide deletion links if available $user = $this->getUser(); $lang = $this->getLanguage(); $rdel = Linker::getRevDeleteLink( $user, $revRecord, $this->mTargetObj ); if ( $rdel ) { $rdel = " $rdel"; } $minor = $revRecord->isMinor() ? ChangesList::flag( 'minor' ) : ''; $dbr = $this->dbProvider->getReplicaDatabase(); $tagIds = $dbr->newSelectQueryBuilder() ->select( 'ct_tag_id' ) ->from( 'change_tag' ) ->where( [ 'ct_rev_id' => $revRecord->getId() ] ) ->caller( __METHOD__ )->fetchFieldValues(); $tags = []; foreach ( $tagIds as $tagId ) { try { $tags[] = $this->changeTagDefStore->getName( (int)$tagId ); } catch ( NameTableAccessException $exception ) { continue; } } $tags = implode( ',', $tags ); $tagSummary = ChangeTags::formatSummaryRow( $tags, 'deleteddiff', $this->getContext() ); $asof = $this->getLinkRenderer()->makeLink( $targetPage, $this->msg( 'revisionasof', $lang->userTimeAndDate( $revRecord->getTimestamp(), $user ), $lang->userDate( $revRecord->getTimestamp(), $user ), $lang->userTime( $revRecord->getTimestamp(), $user ) )->text(), [], $targetQuery ); if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) { $asof = Html::rawElement( 'span', [ 'class' => Linker::getRevisionDeletedClass( $revRecord ) ], $asof ); } // FIXME This is reimplementing DifferenceEngine#getRevisionHeader // and partially #showDiffPage, but worse return '
' . $asof . '
' . '
' . Linker::revUserTools( $revRecord ) . '
' . '
' . '
' . $minor . $this->commentFormatter->formatRevision( $revRecord, $user ) . $rdel . '
' . '
' . '
' . $tagSummary[0] . '
' . '
'; } /** * Show a form confirming whether a tokenless user really wants to see a file * @param string $key */ private function showFileConfirmationForm( $key ) { $out = $this->getOutput(); $lang = $this->getLanguage(); $user = $this->getUser(); $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename ); $out->addWikiMsg( 'undelete-show-file-confirm', $this->mTargetObj->getText(), $lang->userDate( $file->getTimestamp(), $user ), $lang->userTime( $file->getTimestamp(), $user ) ); $out->addHTML( Html::rawElement( 'form', [ 'method' => 'POST', 'action' => $this->getPageTitle()->getLocalURL( [ 'target' => $this->mTarget, 'file' => $key, 'token' => $user->getEditToken( $key ), ] ), ], Xml::submitButton( $this->msg( 'undelete-show-file-submit' )->text() ) ) ); } /** * Show a deleted file version requested by the visitor. * @param string $key */ private function showFile( $key ) { $this->getOutput()->disable(); # We mustn't allow the output to be CDN cached, otherwise # if an admin previews a deleted image, and it's cached, then # a user without appropriate permissions can toddle off and # nab the image, and CDN will serve it $response = $this->getRequest()->response(); $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' ); $path = $this->localRepo->getZonePath( 'deleted' ) . '/' . $this->localRepo->getDeletedHashPath( $key ) . $key; $this->localRepo->streamFileWithStatus( $path ); } /** * @param LinkBatch $batch * @param IResultWrapper $revisions */ private function addRevisionsToBatch( LinkBatch $batch, IResultWrapper $revisions ) { foreach ( $revisions as $row ) { $batch->add( NS_USER, $row->ar_user_text ); $batch->add( NS_USER_TALK, $row->ar_user_text ); } } /** * @param LinkBatch $batch * @param IResultWrapper $files */ private function addFilesToBatch( LinkBatch $batch, IResultWrapper $files ) { foreach ( $files as $row ) { $batch->add( NS_USER, $row->fa_user_text ); $batch->add( NS_USER_TALK, $row->fa_user_text ); } } /** * Handle XHR "show more history" requests (T249977) */ protected function showMoreHistory() { $out = $this->getOutput(); $out->setArticleBodyOnly( true ); $dbr = $this->dbProvider->getReplicaDatabase(); if ( $this->mHistoryOffset ) { $extraConds = [ $dbr->expr( 'ar_timestamp', '<', $dbr->timestamp( $this->mHistoryOffset ) ) ]; } else { $extraConds = []; } $revisions = $this->archivedRevisionLookup->listRevisions( $this->mTargetObj, $extraConds, self::REVISION_HISTORY_LIMIT + 1 ); $batch = $this->linkBatchFactory->newLinkBatch(); $this->addRevisionsToBatch( $batch, $revisions ); $batch->execute(); $out->addHTML( $this->formatRevisionHistory( $revisions ) ); if ( $revisions->numRows() > self::REVISION_HISTORY_LIMIT ) { // Indicate to JS that the "show more" button should remain active $out->setStatusCode( 206 ); } } /** * Generate the