wikimedia/mediawiki-core

View on GitHub
includes/actions/McrUndoAction.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
/**
 * Temporary action for MCR undos
 * @file
 * @ingroup Actions
 */

use MediaWiki\CommentFormatter\CommentFormatter;
use MediaWiki\CommentStore\CommentStore;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Config\Config;
use MediaWiki\Context\IContextSource;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Linker\Linker;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Status\Status;
use MediaWiki\Storage\EditResult;
use MediaWiki\User\User;
use Wikimedia\Rdbms\ReadOnlyMode;

/**
 * Temporary action for MCR undos
 *
 * This is intended to go away when real MCR support is added to EditPage and
 * the standard undo-with-edit behavior can be implemented there instead.
 *
 * If this were going to be kept, we'd probably want to figure out a good way
 * to reuse the same code for generating the headers, summary box, and buttons
 * on EditPage and here, and to better share the diffing and preview logic
 * between the two. But doing that now would require much of the rewriting of
 * EditPage that we're trying to put off by doing this instead.
 *
 * @ingroup Actions
 * @since 1.32
 */
class McrUndoAction extends FormAction {

    protected int $undo = 0;
    protected int $undoafter = 0;
    protected int $cur = 0;

    /** @var RevisionRecord|null */
    protected $curRev = null;

    private ReadOnlyMode $readOnlyMode;
    private RevisionLookup $revisionLookup;
    private RevisionRenderer $revisionRenderer;
    private CommentFormatter $commentFormatter;
    private bool $useRCPatrol;

    /**
     * @param Article $article
     * @param IContextSource $context
     * @param ReadOnlyMode $readOnlyMode
     * @param RevisionLookup $revisionLookup
     * @param RevisionRenderer $revisionRenderer
     * @param CommentFormatter $commentFormatter
     * @param Config $config
     */
    public function __construct(
        Article $article,
        IContextSource $context,
        ReadOnlyMode $readOnlyMode,
        RevisionLookup $revisionLookup,
        RevisionRenderer $revisionRenderer,
        CommentFormatter $commentFormatter,
        Config $config
    ) {
        parent::__construct( $article, $context );
        $this->readOnlyMode = $readOnlyMode;
        $this->revisionLookup = $revisionLookup;
        $this->revisionRenderer = $revisionRenderer;
        $this->commentFormatter = $commentFormatter;
        $this->useRCPatrol = $config->get( MainConfigNames::UseRCPatrol );
    }

    public function getName() {
        return 'mcrundo';
    }

    public function getDescription() {
        return '';
    }

    public function getRestriction() {
        // Require 'edit' permission to even see this action (T297322)
        return 'edit';
    }

    public function show() {
        // Send a cookie so anons get talk message notifications
        // (copied from SubmitAction)
        MediaWiki\Session\SessionManager::getGlobalSession()->persist();

        // Some stuff copied from EditAction
        $this->useTransactionalTimeLimit();

        $out = $this->getOutput();
        $out->setRobotPolicy( 'noindex,nofollow' );

        // IP warning headers copied from EditPage
        // (should more be copied?)
        if ( $this->readOnlyMode->isReadOnly() ) {
            $out->wrapWikiMsg(
                "<div id=\"mw-read-only-warning\">\n$1\n</div>",
                [ 'readonlywarning', $this->readOnlyMode->getReason() ]
            );
        } elseif ( $this->context->getUser()->isAnon() ) {
            // Note: EditPage has a special message for temp user creation intent here.
            // But McrUndoAction doesn't support user creation.
            if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
                $out->addHTML(
                    Html::warningBox(
                        $out->msg(
                            'anoneditwarning',
                            // Log-in link
                            SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
                                'returnto' => $this->getTitle()->getPrefixedDBkey()
                            ] ),
                            // Sign-up link
                            SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
                                'returnto' => $this->getTitle()->getPrefixedDBkey()
                            ] )
                        )->parse(),
                        'mw-anon-edit-warning'
                    )
                );
            } else {
                $out->addHTML(
                    Html::warningBox(
                        $out->msg( 'anonpreviewwarning' )->parse(),
                        'mw-anon-preview-warning'
                    )
                );
            }
        }

        parent::show();
    }

    protected function initFromParameters() {
        $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
        $this->undo = $this->getRequest()->getInt( 'undo' );

        if ( $this->undo == 0 || $this->undoafter == 0 ) {
            throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
        }

        $curRev = $this->getWikiPage()->getRevisionRecord();
        if ( !$curRev ) {
            throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
        }
        $this->curRev = $curRev;
        $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
    }

    protected function checkCanExecute( User $user ) {
        parent::checkCanExecute( $user );

        $this->initFromParameters();

        // We use getRevisionByTitle to verify the revisions belong to this page (T297322)
        $title = $this->getTitle();
        $undoRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undo );
        $oldRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undoafter );

        if ( $undoRev === null || $oldRev === null ||
            $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
            $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
        ) {
            throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
        }

        return true;
    }

    /**
     * @return MutableRevisionRecord
     */
    private function getNewRevision() {
        $undoRev = $this->revisionLookup->getRevisionById( $this->undo );
        $oldRev = $this->revisionLookup->getRevisionById( $this->undoafter );
        $curRev = $this->curRev;

        $isLatest = $curRev->getId() === $undoRev->getId();

        if ( $undoRev === null || $oldRev === null ||
            $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
            $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
        ) {
            throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
        }

        if ( $isLatest ) {
            // Short cut! Undoing the current revision means we just restore the old.
            return MutableRevisionRecord::newFromParentRevision( $oldRev );
        }

        $newRev = MutableRevisionRecord::newFromParentRevision( $curRev );

        // Figure out the roles that need merging by first collecting all roles
        // and then removing the ones that don't.
        $rolesToMerge = array_unique( array_merge(
            $oldRev->getSlotRoles(),
            $undoRev->getSlotRoles(),
            $curRev->getSlotRoles()
        ) );

        // Any roles with the same content in $oldRev and $undoRev can be
        // inherited because undo won't change them.
        $rolesToMerge = array_intersect(
            $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
        );
        if ( !$rolesToMerge ) {
            throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
        }

        // Any roles with the same content in $oldRev and $curRev were already reverted
        // and so can be inherited.
        $rolesToMerge = array_intersect(
            $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
        );
        if ( !$rolesToMerge ) {
            throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
        }

        // Any roles with the same content in $undoRev and $curRev weren't
        // changed since and so can be reverted to $oldRev.
        $diffRoles = array_intersect(
            $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
        );
        foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
            if ( $oldRev->hasSlot( $role ) ) {
                $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
            } else {
                $newRev->removeSlot( $role );
            }
        }
        $rolesToMerge = $diffRoles;

        // Any slot additions or removals not handled by the above checks can't be undone.
        // There will be only one of the three revisions missing the slot:
        //  - !old means it was added in the undone revisions and modified after.
        //    Should it be removed entirely for the undo, or should the modified version be kept?
        //  - !undo means it was removed in the undone revisions and then readded with different content.
        //    Which content is should be kept, the old or the new?
        //  - !cur means it was changed in the undone revisions and then deleted after.
        //    Did someone delete vandalized content instead of undoing (meaning we should ideally restore
        //    it), or should it stay gone?
        foreach ( $rolesToMerge as $role ) {
            if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
                throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
            }
        }

        // Try to merge anything that's left.
        foreach ( $rolesToMerge as $role ) {
            $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
            $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
            $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
            $newContent = $undoContent->getContentHandler()
                ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
            if ( !$newContent ) {
                throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
            }
            $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
        }

        return $newRev;
    }

    private function generateDiffOrPreview() {
        $newRev = $this->getNewRevision();
        if ( $newRev->hasSameContent( $this->curRev ) ) {
            throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
        }

        $diffEngine = new DifferenceEngine( $this->context );
        $diffEngine->setRevisions( $this->curRev, $newRev );

        $oldtitle = $this->context->msg( 'currentrev' )->parse();
        $newtitle = $this->context->msg( 'yourtext' )->parse();

        if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
            $this->showPreview( $newRev );
            return '';
        } else {
            $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
            $diffEngine->showDiffStyle();
            return '<div id="wikiDiff">' . $diffText . '</div>';
        }
    }

    private function showPreview( RevisionRecord $rev ) {
        // Mostly copied from EditPage::getPreviewText()
        $out = $this->getOutput();

        try {
            # provide a anchor link to the form
            $continueEditing = '<span class="mw-continue-editing">' .
                '[[#mw-mcrundo-form|' .
                $this->context->getLanguage()->getArrow() . ' ' .
                $this->context->msg( 'continue-editing' )->text() . ']]</span>';

            $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;

            $parserOptions = $this->getWikiPage()->makeParserOptions( $this->context );
            $parserOptions->setRenderReason( 'page-preview' );
            $parserOptions->setIsPreview( true );
            $parserOptions->setIsSectionPreview( false );

            $parserOutput = $this->revisionRenderer
                ->getRenderedRevision( $rev, $parserOptions, $this->getAuthority() )
                ->getRevisionParserOutput();
            // TODO T371004 move runOutputPipeline out of $parserOutput
            $previewHTML = $parserOutput->runOutputPipeline( $parserOptions, [
                'enableSectionEditLinks' => false,
                'includeDebugInfo' => true,
            ] )->getContentHolderText();

            $out->addParserOutputMetadata( $parserOutput );
            if ( count( $parserOutput->getWarnings() ) ) {
                $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
            }
        } catch ( MWContentSerializationException $ex ) {
            $m = $this->context->msg(
                'content-failed-to-parse',
                $ex->getMessage()
            );
            $note .= "\n\n" . $m->parse();
            $previewHTML = '';
        }

        $previewhead = Html::rawElement(
            'div', [ 'class' => 'previewnote' ],
            Html::element(
                'h2', [ 'id' => 'mw-previewheader' ],
                $this->context->msg( 'preview' )->text()
            ) .
            Html::warningBox(
                $out->parseAsInterface( $note )
            )
        );

        $out->addHTML( $previewhead . $previewHTML );
    }

    public function onSubmit( $data ) {
        if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
            // Diff or preview
            return false;
        }

        $updater = $this->getWikiPage()->newPageUpdater( $this->context->getUser() );
        $curRev = $updater->grabParentRevision();
        if ( !$curRev ) {
            throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
        }

        if ( $this->cur !== $curRev->getId() ) {
            return Status::newFatal( 'mcrundo-changed' );
        }

        $status = new PermissionStatus();
        $this->getAuthority()->authorizeWrite( 'edit', $this->getTitle(), $status );
        if ( !$status->isOK() ) {
            throw new PermissionsError( 'edit', $status );
        }

        $newRev = $this->getNewRevision();
        if ( !$newRev->hasSameContent( $curRev ) ) {
            $hookRunner = $this->getHookRunner();
            foreach ( $newRev->getSlotRoles() as $slotRole ) {
                $slot = $newRev->getSlot( $slotRole, RevisionRecord::RAW );

                $status = new Status();
                $hookResult = $hookRunner->onEditFilterMergedContent(
                    $this->getContext(),
                    $slot->getContent(),
                    $status,
                    trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ),
                    $this->getUser(),
                    false
                );

                if ( !$hookResult ) {
                    if ( $status->isGood() ) {
                        $status->error( 'hookaborted' );
                    }

                    return $status;
                } elseif ( !$status->isOK() ) {
                    if ( !$status->getMessages() ) {
                        $status->error( 'hookaborted' );
                    }
                    return $status;
                }
            }

            // Copy new slots into the PageUpdater, and remove any removed slots.
            // TODO: This interface is awful, there should be a way to just pass $newRev.
            // TODO: MCR: test this once we can store multiple slots
            foreach ( $newRev->getSlots()->getSlots() as $slot ) {
                $updater->setSlot( $slot );
            }
            foreach ( $curRev->getSlotRoles() as $role ) {
                if ( !$newRev->hasSlot( $role ) ) {
                    $updater->removeSlot( $role );
                }
            }

            $updater->markAsRevert( EditResult::REVERT_UNDO, $this->undo, $this->undoafter );

            if ( $this->useRCPatrol && $this->getAuthority()
                    ->authorizeWrite( 'autopatrol', $this->getTitle() )
            ) {
                $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
            }

            $updater->saveRevision(
                CommentStoreComment::newUnsavedComment(
                    trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ) ),
                EDIT_AUTOSUMMARY | EDIT_UPDATE
            );

            return $updater->getStatus();
        }

        return Status::newGood();
    }

    protected function usesOOUI() {
        return true;
    }

    protected function getFormFields() {
        $request = $this->getRequest();
        $ret = [
            'diff' => [
                'type' => 'info',
                'raw' => true,
                'default' => function () {
                    return $this->generateDiffOrPreview();
                }
            ],
            'summary' => [
                'type' => 'text',
                'id' => 'wpSummary',
                'name' => 'wpSummary',
                'cssclass' => 'mw-summary',
                'label-message' => 'summary',
                'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
                'value' => $request->getVal( 'wpSummary', '' ),
                'size' => 60,
                'spellcheck' => 'true',
            ],
            'summarypreview' => [
                'type' => 'info',
                'label-message' => 'summary-preview',
                'raw' => true,
            ],
        ];

        if ( $request->getCheck( 'wpSummary' ) ) {
            $ret['summarypreview']['default'] = Html::rawElement(
                'div',
                [ 'class' => 'mw-summary-preview' ],
                $this->commentFormatter->formatBlock(
                    trim( $request->getVal( 'wpSummary' ) ),
                    $this->getTitle(),
                    false
                )
            );
        } else {
            unset( $ret['summarypreview'] );
        }

        return $ret;
    }

    protected function alterForm( HTMLForm $form ) {
        $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );

        $labelAsPublish = $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );

        $form->setId( 'mw-mcrundo-form' );
        $form->setSubmitName( 'wpSave' );
        $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
        $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
        $form->showCancel( true );
        $form->setCancelTarget( $this->getTitle() );
        $form->addButton( [
            'name' => 'wpPreview',
            'value' => '1',
            'label-message' => 'showpreview',
            'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
        ] );
        $form->addButton( [
            'name' => 'wpDiff',
            'value' => '1',
            'label-message' => 'showdiff',
            'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
        ] );

        $this->addStatePropagationFields( $form );
    }

    protected function addStatePropagationFields( HTMLForm $form ) {
        $form->addHiddenField( 'undo', $this->undo );
        $form->addHiddenField( 'undoafter', $this->undoafter );
        $form->addHiddenField( 'cur', $this->curRev->getId() );
    }

    public function onSuccess() {
        $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
    }

    protected function preText() {
        return '<div style="clear:both"></div>';
    }
}