wikimedia/mediawiki-core

View on GitHub
includes/page/UndeletePage.php

Summary

Maintainability
F
3 days
Test Coverage
<?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;
    }
}