includes/page/UndeletePage.php
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\Page;
use ChangeTags;
use HTMLCacheUpdateJob;
use IDBAccessObject;
use JobQueueGroup;
use LocalFile;
use ManualLogEntry;
use MediaWiki\Content\IContentHandlerFactory;
use MediaWiki\Content\ValidationParams;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Revision\ArchivedRevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Status\Status;
use MediaWiki\Storage\PageUpdaterFactory;
use MediaWiki\Title\NamespaceInfo;
use Psr\Log\LoggerInterface;
use ReadOnlyError;
use RepoGroup;
use StatusValue;
use Wikimedia\Message\ITextFormatter;
use Wikimedia\Message\MessageValue;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ReadOnlyMode;
use WikiPage;
/**
* Backend logic for performing a page undelete action.
*
* @since 1.38
*/
class UndeletePage {
// Constants used as keys in the StatusValue returned by undelete()
public const FILES_RESTORED = 'files';
public const REVISIONS_RESTORED = 'revs';
/** @var HookRunner */
private $hookRunner;
/** @var JobQueueGroup */
private $jobQueueGroup;
/** @var IConnectionProvider */
private $dbProvider;
/** @var LoggerInterface */
private $logger;
/** @var ReadOnlyMode */
private $readOnlyMode;
/** @var RepoGroup */
private $repoGroup;
/** @var RevisionStore */
private $revisionStore;
/** @var WikiPageFactory */
private $wikiPageFactory;
/** @var PageUpdaterFactory */
private $pageUpdaterFactory;
/** @var IContentHandlerFactory */
private $contentHandlerFactory;
/** @var ArchivedRevisionLookup */
private $archivedRevisionLookup;
/** @var NamespaceInfo */
private $namespaceInfo;
/** @var ProperPageIdentity */
private $page;
/** @var Authority */
private $performer;
/** @var Status|null */
private $fileStatus;
/** @var StatusValue|null */
private $revisionStatus;
/** @var string[] */
private $timestamps = [];
/** @var int[] */
private $fileVersions = [];
/** @var bool */
private $unsuppress = false;
/** @var string[] */
private $tags = [];
/** @var WikiPage|null If not null, it means that we have to undelete it. */
private $associatedTalk;
/** @var ITextFormatter */
private $contLangMsgTextFormatter;
/**
* @internal Create via the UndeletePageFactory service.
* @param HookContainer $hookContainer
* @param JobQueueGroup $jobQueueGroup
* @param IConnectionProvider $dbProvider
* @param ReadOnlyMode $readOnlyMode
* @param RepoGroup $repoGroup
* @param LoggerInterface $logger
* @param RevisionStore $revisionStore
* @param WikiPageFactory $wikiPageFactory
* @param PageUpdaterFactory $pageUpdaterFactory
* @param IContentHandlerFactory $contentHandlerFactory
* @param ArchivedRevisionLookup $archivedRevisionLookup
* @param NamespaceInfo $namespaceInfo
* @param ITextFormatter $contLangMsgTextFormatter
* @param ProperPageIdentity $page
* @param Authority $performer
*/
public function __construct(
HookContainer $hookContainer,
JobQueueGroup $jobQueueGroup,
IConnectionProvider $dbProvider,
ReadOnlyMode $readOnlyMode,
RepoGroup $repoGroup,
LoggerInterface $logger,
RevisionStore $revisionStore,
WikiPageFactory $wikiPageFactory,
PageUpdaterFactory $pageUpdaterFactory,
IContentHandlerFactory $contentHandlerFactory,
ArchivedRevisionLookup $archivedRevisionLookup,
NamespaceInfo $namespaceInfo,
ITextFormatter $contLangMsgTextFormatter,
ProperPageIdentity $page,
Authority $performer
) {
$this->hookRunner = new HookRunner( $hookContainer );
$this->jobQueueGroup = $jobQueueGroup;
$this->dbProvider = $dbProvider;
$this->readOnlyMode = $readOnlyMode;
$this->repoGroup = $repoGroup;
$this->logger = $logger;
$this->revisionStore = $revisionStore;
$this->wikiPageFactory = $wikiPageFactory;
$this->pageUpdaterFactory = $pageUpdaterFactory;
$this->contentHandlerFactory = $contentHandlerFactory;
$this->archivedRevisionLookup = $archivedRevisionLookup;
$this->namespaceInfo = $namespaceInfo;
$this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
$this->page = $page;
$this->performer = $performer;
}
/**
* Whether to remove all ar_deleted/fa_deleted restrictions of selected revs.
*
* @param bool $unsuppress
* @return self For chaining
*/
public function setUnsuppress( bool $unsuppress ): self {
$this->unsuppress = $unsuppress;
return $this;
}
/**
* Change tags to add to log entry (the user should be able to add the specified tags before this is called)
*
* @param string[] $tags
* @return self For chaining
*/
public function setTags( array $tags ): self {
$this->tags = $tags;
return $this;
}
/**
* If you don't want to undelete all revisions, pass an array of timestamps to undelete.
*
* @param string[] $timestamps
* @return self For chaining
*/
public function setUndeleteOnlyTimestamps( array $timestamps ): self {
$this->timestamps = $timestamps;
return $this;
}
/**
* If you don't want to undelete all file versions, pass an array of versions to undelete.
*
* @param int[] $fileVersions
* @return self For chaining
*/
public function setUndeleteOnlyFileVersions( array $fileVersions ): self {
$this->fileVersions = $fileVersions;
return $this;
}
/**
* Tests whether it's probably possible to undelete the associated talk page. This checks the replica,
* so it may not see the latest master change, and is useful e.g. for building the UI.
*
* @return StatusValue
*/
public function canProbablyUndeleteAssociatedTalk(): StatusValue {
if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
return StatusValue::newFatal( 'undelete-error-associated-alreadytalk' );
}
// @todo FIXME: NamespaceInfo should work with PageIdentity
$thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
$talkPage = $this->wikiPageFactory->newFromLinkTarget(
$this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() )
);
// NOTE: The talk may exist, but have some deleted revision. That's fine.
if ( !$this->archivedRevisionLookup->hasArchivedRevisions( $talkPage ) ) {
return StatusValue::newFatal( 'undelete-error-associated-notdeleted' );
}
return StatusValue::newGood();
}
/**
* Whether to delete the associated talk page with the subject page
*
* @param bool $undelete
* @return self For chaining
*/
public function setUndeleteAssociatedTalk( bool $undelete ): self {
if ( !$undelete ) {
$this->associatedTalk = null;
return $this;
}
// @todo FIXME: NamespaceInfo should accept PageIdentity
$thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
$this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
$this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() )
);
return $this;
}
/**
* Same as undeleteUnsafe, but checks permissions.
*
* @param string $comment
* @return StatusValue
*/
public function undeleteIfAllowed( string $comment ): StatusValue {
$status = $this->authorizeUndeletion();
if ( !$status->isGood() ) {
return $status;
}
return $this->undeleteUnsafe( $comment );
}
/**
* @return PermissionStatus
*/
private function authorizeUndeletion(): PermissionStatus {
$status = PermissionStatus::newEmpty();
$this->performer->authorizeWrite( 'undelete', $this->page, $status );
if ( $this->associatedTalk ) {
$this->performer->authorizeWrite( 'undelete', $this->associatedTalk, $status );
}
if ( $this->tags ) {
$status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->performer ) );
}
return $status;
}
/**
* Restore the given (or all) text and file revisions for the page.
* Once restored, the items will be removed from the archive tables.
* The deletion log will be updated with an undeletion notice.
*
* This also sets Status objects, $this->fileStatus and $this->revisionStatus
* (depending what operations are attempted).
*
* @note This method doesn't check user permissions. Use undeleteIfAllowed for that.
*
* @param string $comment
* @return StatusValue Good Status with the following value on success:
* [
* self::REVISIONS_RESTORED => number of text revisions restored,
* self::FILES_RESTORED => number of file revisions restored
* ]
* Fatal Status on failure.
*/
public function undeleteUnsafe( string $comment ): StatusValue {
$hookStatus = $this->runPreUndeleteHook( $comment );
if ( !$hookStatus->isGood() ) {
return $hookStatus;
}
// If both the set of text revisions and file revisions are empty,
// restore everything. Otherwise, just restore the requested items.
$restoreAll = $this->timestamps === [] && $this->fileVersions === [];
$restoreText = $restoreAll || $this->timestamps !== [];
$restoreFiles = $restoreAll || $this->fileVersions !== [];
$resStatus = StatusValue::newGood();
$filesRestored = 0;
if ( $restoreFiles && $this->page->getNamespace() === NS_FILE ) {
/** @var LocalFile $img */
$img = $this->repoGroup->getLocalRepo()->newFile( $this->page );
$img->load( IDBAccessObject::READ_LATEST );
$this->fileStatus = $img->restore( $this->fileVersions, $this->unsuppress );
if ( !$this->fileStatus->isOK() ) {
return $this->fileStatus;
}
$filesRestored = $this->fileStatus->successCount;
$resStatus->merge( $this->fileStatus );
}
$textRestored = 0;
$pageCreated = false;
$restoredRevision = null;
$restoredPageIds = [];
if ( $restoreText ) {
$this->revisionStatus = $this->undeleteRevisions( $this->page, $this->timestamps, $comment );
if ( !$this->revisionStatus->isOK() ) {
return $this->revisionStatus;
}
[ $textRestored, $pageCreated, $restoredRevision, $restoredPageIds ] = $this->revisionStatus->getValue();
$resStatus->merge( $this->revisionStatus );
}
$talkRestored = 0;
$talkCreated = false;
$restoredTalkRevision = null;
$restoredTalkPageIds = [];
if ( $this->associatedTalk ) {
$talkStatus = $this->canProbablyUndeleteAssociatedTalk();
// if undeletion of the page fails we don't want to undelete the talk page
if ( $talkStatus->isGood() && $resStatus->isGood() ) {
$talkStatus = $this->undeleteRevisions( $this->associatedTalk, [], $comment );
if ( !$talkStatus->isOK() ) {
return $talkStatus;
}
[ $talkRestored, $talkCreated, $restoredTalkRevision, $restoredTalkPageIds ] = $talkStatus->getValue();
} else {
// Add errors as warnings since the talk page is secondary to the main action
foreach ( $talkStatus->getMessages() as $msg ) {
$resStatus->warning( $msg );
}
}
}
$resStatus->value = [
self::REVISIONS_RESTORED => $textRestored + $talkRestored,
self::FILES_RESTORED => $filesRestored
];
if ( !$textRestored && !$filesRestored && !$talkRestored ) {
$this->logger->debug( "Undelete: nothing undeleted..." );
return $resStatus;
}
if ( $textRestored || $filesRestored ) {
$logEntry = $this->addLogEntry( $this->page, $comment, $textRestored, $filesRestored );
if ( $textRestored ) {
$this->hookRunner->onPageUndeleteComplete(
$this->page,
$this->performer,
$comment,
$restoredRevision,
$logEntry,
$textRestored,
$pageCreated,
$restoredPageIds
);
}
}
if ( $talkRestored ) {
$talkRestoredComment = $this->contLangMsgTextFormatter->format(
MessageValue::new( 'undelete-talk-summary-prefix' )->plaintextParams( $comment )
);
$logEntry = $this->addLogEntry( $this->associatedTalk, $talkRestoredComment, $talkRestored, 0 );
$this->hookRunner->onPageUndeleteComplete(
$this->associatedTalk,
$this->performer,
$talkRestoredComment,
$restoredTalkRevision,
$logEntry,
$talkRestored,
$talkCreated,
$restoredTalkPageIds
);
}
return $resStatus;
}
/**
* @param string $comment
* @return StatusValue
*/
private function runPreUndeleteHook( string $comment ): StatusValue {
$checkPages = [ $this->page ];
if ( $this->associatedTalk ) {
$checkPages[] = $this->associatedTalk;
}
foreach ( $checkPages as $page ) {
$hookStatus = StatusValue::newGood();
$hookRes = $this->hookRunner->onPageUndelete(
$page,
$this->performer,
$comment,
$this->unsuppress,
$this->timestamps,
$this->fileVersions,
$hookStatus
);
if ( !$hookRes && !$hookStatus->isGood() ) {
// Note: as per the PageUndeleteHook documentation, `return false` is ignored if $status is good.
return $hookStatus;
}
}
return Status::newGood();
}
/**
* @param ProperPageIdentity $page
* @param string $comment
* @param int $textRestored
* @param int $filesRestored
*
* @return ManualLogEntry
*/
private function addLogEntry(
ProperPageIdentity $page,
string $comment,
int $textRestored,
int $filesRestored
): ManualLogEntry {
$logEntry = new ManualLogEntry( 'delete', 'restore' );
$logEntry->setPerformer( $this->performer->getUser() );
$logEntry->setTarget( $page );
$logEntry->setComment( $comment );
$logEntry->addTags( $this->tags );
$logEntry->setParameters( [
':assoc:count' => [
'revisions' => $textRestored,
'files' => $filesRestored,
],
] );
$logid = $logEntry->insert();
$logEntry->publish( $logid );
return $logEntry;
}
/**
* This is the meaty bit -- It restores archived revisions of the given page
* to the revision table.
*
* @param ProperPageIdentity $page
* @param string[] $timestamps
* @param string $comment
* @throws ReadOnlyError
* @return StatusValue Status object containing the number of revisions restored on success
*/
private function undeleteRevisions( ProperPageIdentity $page, array $timestamps, string $comment ): StatusValue {
if ( $this->readOnlyMode->isReadOnly() ) {
throw new ReadOnlyError();
}
$dbw = $this->dbProvider->getPrimaryDatabase();
$dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
$oldWhere = [
'ar_namespace' => $page->getNamespace(),
'ar_title' => $page->getDBkey(),
];
if ( $timestamps ) {
$oldWhere['ar_timestamp'] = array_map( [ $dbw, 'timestamp' ], $timestamps );
}
$revisionStore = $this->revisionStore;
$result = $revisionStore->newArchiveSelectQueryBuilder( $dbw )
->joinComment()
->leftJoin( 'revision', null, 'ar_rev_id=rev_id' )
->field( 'rev_id' )
->where( $oldWhere )
->orderBy( 'ar_timestamp' )
->caller( __METHOD__ )->fetchResultSet();
$rev_count = $result->numRows();
if ( !$rev_count ) {
$this->logger->debug( __METHOD__ . ": no revisions to restore" );
// Status value is count of revisions, whether the page has been created,
// last revision undeleted and all undeleted pages
$status = Status::newGood( [ 0, false, null, [] ] );
$status->error( "undelete-no-results" );
$dbw->endAtomic( __METHOD__ );
return $status;
}
$result->seek( $rev_count - 1 );
$latestRestorableRow = $result->current();
// move back
$result->seek( 0 );
$wikiPage = $this->wikiPageFactory->newFromTitle( $page );
$created = true;
$oldcountable = false;
$updatedCurrentRevision = false;
$restoredRevCount = 0;
$restoredPages = [];
// pass this to ArticleUndelete hook
$oldPageId = (int)$latestRestorableRow->ar_page_id;
// Grab the content to check consistency with global state before restoring the page.
// XXX: The only current use case is Wikibase, which tries to enforce uniqueness of
// certain things across all pages. There may be a better way to do that.
$revision = $revisionStore->newRevisionFromArchiveRow(
$latestRestorableRow,
0,
$page
);
foreach ( $revision->getSlotRoles() as $role ) {
$content = $revision->getContent( $role, RevisionRecord::RAW );
// NOTE: article ID may not be known yet. validateSave() should not modify the database.
$contentHandler = $this->contentHandlerFactory->getContentHandler( $content->getModel() );
$validationParams = new ValidationParams( $wikiPage, 0 );
// @phan-suppress-next-line PhanTypeMismatchArgumentNullable RAW never returns null
$status = $contentHandler->validateSave( $content, $validationParams );
if ( !$status->isOK() ) {
$dbw->endAtomic( __METHOD__ );
return $status;
}
}
$pageId = $wikiPage->insertOn( $dbw, $latestRestorableRow->ar_page_id );
if ( $pageId === false ) {
// The page ID is reserved; let's pick another
$pageId = $wikiPage->insertOn( $dbw );
if ( $pageId === false ) {
// The page title must be already taken (race condition)
$created = false;
}
}
# Does this page already exist? We'll have to update it...
if ( !$created ) {
# Load latest data for the current page (T33179)
$wikiPage->loadPageData( IDBAccessObject::READ_EXCLUSIVE );
$pageId = $wikiPage->getId();
$oldcountable = $wikiPage->isCountable();
$previousTimestamp = false;
$latestRevId = $wikiPage->getLatest();
if ( $latestRevId ) {
$previousTimestamp = $revisionStore->getTimestampFromId(
$latestRevId,
IDBAccessObject::READ_LATEST
);
}
if ( $previousTimestamp === false ) {
$this->logger->debug( __METHOD__ . ": existing page refers to a page_latest that does not exist" );
// Status value is count of revisions, whether the page has been created,
// last revision undeleted and all undeleted pages
$status = Status::newGood( [ 0, false, null, [] ] );
$status->error( 'undeleterevision-missing' );
$dbw->cancelAtomic( __METHOD__ );
return $status;
}
} else {
$previousTimestamp = 0;
}
// Check if a deleted revision will become the current revision...
if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) {
// Check the state of the newest to-be version...
if ( !$this->unsuppress
&& ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT )
) {
$dbw->cancelAtomic( __METHOD__ );
return Status::newFatal( "undeleterevdel" );
}
$updatedCurrentRevision = true;
}
foreach ( $result as $row ) {
// Insert one revision at a time...maintaining deletion status
// unless we are specifically removing all restrictions...
$revision = $revisionStore->newRevisionFromArchiveRow(
$row,
0,
$page,
[
'page_id' => $pageId,
'deleted' => $this->unsuppress ? 0 : $row->ar_deleted
]
);
// This will also copy the revision to ip_changes if it was an IP edit.
$revision = $revisionStore->insertRevisionOn( $revision, $dbw );
$restoredRevCount++;
$this->hookRunner->onRevisionUndeleted( $revision, $row->ar_page_id );
$restoredPages[$row->ar_page_id] = true;
}
// Now that it's safely stored, take it out of the archive
$dbw->newDeleteQueryBuilder()
->deleteFrom( 'archive' )
->where( $oldWhere )
->caller( __METHOD__ )->execute();
// Status value is count of revisions, whether the page has been created,
// last revision undeleted and all undeleted pages
$status = Status::newGood( [ $restoredRevCount, $created, $revision, $restoredPages ] );
// Was anything restored at all?
if ( $restoredRevCount ) {
if ( $updatedCurrentRevision ) {
// Attach the latest revision to the page...
// XXX: updateRevisionOn should probably move into a PageStore service.
$wasnew = $wikiPage->updateRevisionOn(
$dbw,
$revision,
$created ? 0 : $wikiPage->getLatest()
);
} else {
$wasnew = false;
}
if ( $created || $wasnew ) {
// Update site stats, link tables, etc
$user = $revision->getUser( RevisionRecord::RAW );
$options = [
'created' => $created,
'oldcountable' => $oldcountable,
'restored' => true,
'causeAction' => 'undelete-page',
'causeAgent' => $user->getName(),
];
$updater = $this->pageUpdaterFactory->newDerivedPageDataUpdater( $wikiPage );
$updater->prepareUpdate( $revision, $options );
$updater->doUpdates();
}
$this->hookRunner->onArticleUndelete(
$wikiPage->getTitle(), $created, $comment, $oldPageId, $restoredPages );
if ( $page->getNamespace() === NS_FILE ) {
$job = HTMLCacheUpdateJob::newForBacklinks(
$page,
'imagelinks',
[ 'causeAction' => 'undelete-file' ]
);
$this->jobQueueGroup->lazyPush( $job );
}
}
$dbw->endAtomic( __METHOD__ );
return $status;
}
/**
* @internal BC method to be used by PageArchive only
* @return Status|null
*/
public function getFileStatus(): ?Status {
return $this->fileStatus;
}
/**
* @internal BC methods to be used by PageArchive only
* @return StatusValue|null
*/
public function getRevisionStatus(): ?StatusValue {
return $this->revisionStatus;
}
}