includes/actions/McrUndoAction.php
<?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>';
}
}