MediaWiki master
McrUndoAction.php
Go to the documentation of this file.
1<?php
8namespace MediaWiki\Actions;
9
41
58
59 protected int $undo = 0;
60 protected int $undoafter = 0;
61 protected int $cur = 0;
62
64 protected $curRev = null;
65
66 private readonly bool $useRCPatrol;
67
68 public function __construct(
69 Article $article,
71 private readonly ReadOnlyMode $readOnlyMode,
72 private readonly RevisionLookup $revisionLookup,
73 private readonly RevisionRenderer $revisionRenderer,
74 private readonly CommentFormatter $commentFormatter,
75 private readonly LinkRenderer $linkRenderer,
76 Config $config,
77 private readonly TempUserCreator $tempUserCreator,
78 private readonly AuthManager $authManager,
79 ) {
80 parent::__construct( $article, $context );
81 $this->useRCPatrol = $config->get( MainConfigNames::UseRCPatrol );
82 }
83
85 public function getName() {
86 return 'mcrundo';
87 }
88
90 public function getDescription() {
91 return '';
92 }
93
95 public function getRestriction() {
96 // Require 'edit' permission to even see this action (T297322)
97 return 'edit';
98 }
99
101 public function show() {
102 // Send a cookie so anons get talk message notifications
103 // (copied from SubmitAction)
104 $this->getRequest()->getSession()->persist();
105
106 // Some stuff copied from EditAction
108
109 $out = $this->getOutput();
110 $out->setRobotPolicy( 'noindex,nofollow' );
111
112 // IP warning headers copied from EditPage
113 // (should more be copied?)
114 if ( $this->readOnlyMode->isReadOnly() ) {
115 $out->wrapWikiMsg(
116 "<div id=\"mw-read-only-warning\">\n$1\n</div>",
117 [ 'readonlywarning', $this->readOnlyMode->getReason() ]
118 );
119 } elseif ( $this->context->getUser()->isAnon() ) {
120 $shouldAutoCreateTempUser = $this->tempUserCreator->shouldAutoCreate(
121 $this->getAuthority(), 'edit'
122 );
123 if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
124 $out->addHTML(
125 Html::warningBox(
126 $out->msg(
127 $shouldAutoCreateTempUser ? 'autocreate-edit-warning' : 'anoneditwarning',
128 // Log-in link
129 SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
130 'returnto' => $this->getTitle()->getPrefixedDBkey()
131 ] ),
132 // Sign-up link
133 SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
134 'returnto' => $this->getTitle()->getPrefixedDBkey()
135 ] )
136 )->parse(),
137 'mw-anon-edit-warning'
138 )
139 );
140 } else {
141 $out->addHTML(
142 Html::warningBox(
143 $out->msg(
144 $shouldAutoCreateTempUser ? 'autocreate-preview-warning' : 'anonpreviewwarning'
145 )->parse(),
146 'mw-anon-preview-warning'
147 )
148 );
149 }
150 }
151
152 parent::show();
153 }
154
155 protected function initFromParameters() {
156 $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
157 $this->undo = $this->getRequest()->getInt( 'undo' );
158
159 if ( $this->undo == 0 || $this->undoafter == 0 ) {
160 throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
161 }
162
163 $curRev = $this->getWikiPage()->getRevisionRecord();
164 if ( !$curRev ) {
165 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
166 }
167 $this->curRev = $curRev;
168 $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
169 }
170
172 protected function checkCanExecute( User $user ) {
173 parent::checkCanExecute( $user );
174
175 $this->initFromParameters();
176
177 // We use getRevisionByTitle to verify the revisions belong to this page (T297322)
178 $title = $this->getTitle();
179 $undoRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undo );
180 $oldRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undoafter );
181
182 if ( $undoRev === null || $oldRev === null ||
183 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
184 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
185 ) {
186 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
187 }
188
189 return true;
190 }
191
195 private function getNewRevision() {
196 $undoRev = $this->revisionLookup->getRevisionById( $this->undo );
197 $oldRev = $this->revisionLookup->getRevisionById( $this->undoafter );
199
200 $isLatest = $curRev->getId() === $undoRev->getId();
201
202 if ( $undoRev === null || $oldRev === null ||
203 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
204 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
205 ) {
206 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
207 }
208
209 if ( $isLatest ) {
210 // Short cut! Undoing the current revision means we just restore the old.
212 }
213
215
216 // Figure out the roles that need merging by first collecting all roles
217 // and then removing the ones that don't.
218 $rolesToMerge = array_unique( array_merge(
219 $oldRev->getSlotRoles(),
220 $undoRev->getSlotRoles(),
222 ) );
223
224 // Any roles with the same content in $oldRev and $undoRev can be
225 // inherited because undo won't change them.
226 $rolesToMerge = array_intersect(
227 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
228 );
229 if ( !$rolesToMerge ) {
230 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
231 }
232
233 // Any roles with the same content in $oldRev and $curRev were already reverted
234 // and so can be inherited.
235 $rolesToMerge = array_intersect(
236 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
237 );
238 if ( !$rolesToMerge ) {
239 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
240 }
241
242 // Any roles with the same content in $undoRev and $curRev weren't
243 // changed since and so can be reverted to $oldRev.
244 $diffRoles = array_intersect(
245 $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
246 );
247 foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
248 if ( $oldRev->hasSlot( $role ) ) {
249 $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
250 } else {
251 $newRev->removeSlot( $role );
252 }
253 }
254 $rolesToMerge = $diffRoles;
255
256 // Any slot additions or removals not handled by the above checks can't be undone.
257 // There will be only one of the three revisions missing the slot:
258 // - !old means it was added in the undone revisions and modified after.
259 // Should it be removed entirely for the undo, or should the modified version be kept?
260 // - !undo means it was removed in the undone revisions and then readded with different content.
261 // Which content is should be kept, the old or the new?
262 // - !cur means it was changed in the undone revisions and then deleted after.
263 // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
264 // it), or should it stay gone?
265 foreach ( $rolesToMerge as $role ) {
266 if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
267 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
268 }
269 }
270
271 // Try to merge anything that's left.
272 foreach ( $rolesToMerge as $role ) {
273 $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
274 $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
275 $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
276 $newContent = $undoContent->getContentHandler()
277 ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
278 if ( !$newContent ) {
279 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
280 }
281 $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
282 }
283
284 return $newRev;
285 }
286
287 private function generateDiffOrPreview(): string {
288 $newRev = $this->getNewRevision();
289 if ( $newRev->hasSameContent( $this->curRev ) ) {
290 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
291 }
292
293 $diffEngine = new DifferenceEngine( $this->context );
294 $diffEngine->setRevisions( $this->curRev, $newRev );
295
296 $oldtitle = $this->context->msg( 'currentrev' )->parse();
297 $newtitle = $this->context->msg( 'yourtext' )->parse();
298
299 if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
300 $this->showPreview( $newRev );
301 return '';
302 } else {
303 $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
304 $diffEngine->showDiffStyle();
305 return '<div id="wikiDiff">' . $diffText . '</div>';
306 }
307 }
308
309 private function showPreview( RevisionRecord $rev ) {
310 // Mostly copied from EditPage::getPreviewText()
311 $out = $this->getOutput();
312
313 $noteHtml = '';
314 $previewIssuesHtml = '';
315
316 try {
317 # provide a anchor link to the form
318 $continueEditingHtml = Html::rawElement(
319 'span',
320 [ 'class' => 'mw-continue-editing' ],
321 $this->linkRenderer->makePreloadedLink(
322 new TitleValue( NS_MAIN, '', 'mw-mcrundo-form' ),
323 $this->context->getLanguage()->getArrow() . ' ' . $this->context->msg( 'continue-editing' )->text()
324 )
325 );
326
327 $noteHtml .= Html::noticeBox(
328 $this->context->msg( 'previewnote' )->parse() . ' ' . $continueEditingHtml
329 );
330
331 $parserOptions = $this->getWikiPage()->makeParserOptions( $this->context );
332 $parserOptions->setRenderReason( 'page-preview' );
333 $parserOptions->setIsPreview( true );
334 $parserOptions->setIsSectionPreview( false );
335 $parserOptions->setSuppressSectionEditLinks();
336
337 $parserOutput = $this->revisionRenderer
338 ->getRenderedRevision( $rev, $parserOptions, $this->getAuthority() )
339 ->getRevisionParserOutput();
340 // TODO T371004 move runOutputPipeline out of $parserOutput
341 $previewHTML = $parserOutput->runOutputPipeline( $parserOptions, [
342 'includeDebugInfo' => true,
343 ] )->getContentHolderText();
344
345 $out->addParserOutputMetadata( $parserOutput );
346 $parserWarnings = $parserOutput->getWarningMsgs();
347 if ( $parserWarnings ) {
348 $warningsHtml = '';
349 foreach ( $parserWarnings as $mv ) {
350 $warningsHtml .= $this->context->msg( $mv )->parse() . "<br>";
351 }
352 $previewIssuesHtml .= Html::warningBox( $warningsHtml );
353 }
354 } catch ( MWContentSerializationException $ex ) {
355 $m = $this->context->msg(
356 'content-failed-to-parse',
357 $ex->getMessage()
358 );
359 $previewIssuesHtml .= Html::errorBox( $m->parse() );
360 $previewHTML = '';
361 }
362
363 $previewhead = Html::rawElement(
364 'div', [ 'class' => 'previewnote' ],
366 'h2', [ 'id' => 'mw-previewheader' ],
367 $this->context->msg( 'preview' )->text()
368 ) . $previewIssuesHtml . $noteHtml
369 );
370
371 $out->addHTML( $previewhead . $previewHTML );
372 }
373
375 public function onSubmit( $data ) {
376 if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
377 // Diff or preview
378 return false;
379 }
380
381 $user = $this->context->getUser();
382 $request = $this->context->getRequest();
383
384 if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) {
385 $status = $this->tempUserCreator->create( null, $request );
386 if ( !$status->isOK() ) {
387 return $status;
388 }
389 $user = $status->getUser();
390 $this->authManager->setRequestContextUserFromSessionUser();
391 }
392
393 $updater = $this->getWikiPage()->newPageUpdater( $user );
394 $curRev = $updater->grabParentRevision();
395 if ( !$curRev ) {
396 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
397 }
398
399 if ( $this->cur !== $curRev->getId() ) {
400 return Status::newFatal( 'mcrundo-changed' );
401 }
402
403 $status = new PermissionStatus();
404 $this->getAuthority()->authorizeWrite( 'edit', $this->getTitle(), $status );
405 if ( !$status->isOK() ) {
406 throw new PermissionsError( 'edit', $status );
407 }
408
409 $newRev = $this->getNewRevision();
410 if ( !$newRev->hasSameContent( $curRev ) ) {
411 $hookRunner = $this->getHookRunner();
412 foreach ( $newRev->getSlotRoles() as $slotRole ) {
413 $slot = $newRev->getSlot( $slotRole, RevisionRecord::RAW );
414
415 $status = new Status();
416 $hookResult = $hookRunner->onEditFilterMergedContent(
417 $this->getContext(),
418 $slot->getContent(),
419 $status,
420 trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ),
421 $this->getUser(),
422 false
423 );
424
425 if ( !$hookResult ) {
426 if ( $status->isGood() ) {
427 $status->error( 'hookaborted' );
428 }
429
430 return $status;
431 } elseif ( !$status->isOK() ) {
432 if ( !$status->getMessages() ) {
433 $status->error( 'hookaborted' );
434 }
435 return $status;
436 }
437 }
438
439 // Copy new slots into the PageUpdater, and remove any removed slots.
440 // TODO: This interface is awful, there should be a way to just pass $newRev.
441 // TODO: MCR: test this once we can store multiple slots
442 foreach ( $newRev->getSlots()->getSlots() as $slot ) {
443 $updater->setSlot( $slot );
444 }
445 foreach ( $curRev->getSlotRoles() as $role ) {
446 if ( !$newRev->hasSlot( $role ) ) {
447 $updater->removeSlot( $role );
448 }
449 }
450
451 $updater->setCause( PageUpdateCauses::CAUSE_UNDO );
452 $updater->markAsRevert( EditResult::REVERT_UNDO, $this->undo, $this->undoafter );
453
454 if ( $this->useRCPatrol && $this->getAuthority()
455 ->authorizeWrite( 'autopatrol', $this->getTitle() )
456 ) {
457 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
458 }
459
460 $updater->saveRevision(
462 trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ) ),
464 );
465
466 return $updater->getStatus();
467 }
468
469 return Status::newGood();
470 }
471
473 protected function usesOOUI() {
474 return true;
475 }
476
478 protected function getFormFields() {
479 $request = $this->getRequest();
480 $ret = [
481 'diff' => [
482 'type' => 'info',
483 'raw' => true,
484 'default' => $this->generateDiffOrPreview( ... )
485 ],
486 'summary' => [
487 'type' => 'text',
488 'id' => 'wpSummary',
489 'name' => 'wpSummary',
490 'cssclass' => 'mw-summary',
491 'label-message' => 'summary',
492 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
493 'value' => $request->getVal( 'wpSummary', '' ),
494 'size' => 60,
495 'spellcheck' => 'true',
496 ],
497 'summarypreview' => [
498 'type' => 'info',
499 'label-message' => 'summary-preview',
500 'raw' => true,
501 ],
502 ];
503
504 if ( $request->getCheck( 'wpSummary' ) ) {
505 $ret['summarypreview']['default'] = Html::rawElement(
506 'div',
507 [ 'class' => 'mw-summary-preview' ],
508 $this->commentFormatter->formatBlock(
509 trim( $request->getVal( 'wpSummary' ) ),
510 $this->getTitle(),
511 false
512 )
513 );
514 } else {
515 unset( $ret['summarypreview'] );
516 }
517
518 return $ret;
519 }
520
521 protected function alterForm( HTMLForm $form ) {
522 $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
523
524 $labelAsPublish = $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
525
526 $form->setId( 'mw-mcrundo-form' );
527 $form->setSubmitName( 'wpSave' );
528 $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
529 $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
530 $form->showCancel( true );
531 $form->setCancelTarget( $this->getTitle() );
532 $form->addButton( [
533 'name' => 'wpPreview',
534 'value' => '1',
535 'label-message' => 'showpreview',
536 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
537 ] );
538 $form->addButton( [
539 'name' => 'wpDiff',
540 'value' => '1',
541 'label-message' => 'showdiff',
542 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
543 ] );
544
545 $this->addStatePropagationFields( $form );
546 }
547
548 protected function addStatePropagationFields( HTMLForm $form ) {
549 $form->addHiddenField( 'undo', $this->undo );
550 $form->addHiddenField( 'undoafter', $this->undoafter );
551 $form->addHiddenField( 'cur', $this->curRev->getId() );
552 }
553
555 public function onSuccess() {
556 $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
557 }
558
560 protected function preText() {
561 return '<div style="clear:both"></div>';
562 }
563}
564
566class_alias( McrUndoAction::class, 'McrUndoAction' );
const EDIT_UPDATE
Article is assumed to be pre-existing, fail if it doesn't exist.
Definition Defines.php:117
const NS_MAIN
Definition Defines.php:51
const EDIT_AUTOSUMMARY
Fill in blank summaries with generated text where possible.
Definition Defines.php:135
getWikiPage()
Get a WikiPage object.
Definition Action.php:192
IContextSource null $context
IContextSource if specified; otherwise we'll use the Context from the Page.
Definition Action.php:66
getTitle()
Shortcut to get the Title object from the page.
Definition Action.php:213
getRequest()
Get the WebRequest being used for this instance.
Definition Action.php:133
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition Action.php:475
getOutput()
Get the OutputPage being used for this instance.
Definition Action.php:143
getAuthority()
Shortcut to get the Authority executing this instance.
Definition Action.php:163
An action which shows a form and does something based on the input from the form.
Temporary action for MCR undos.
alterForm(HTMLForm $form)
Play with the HTMLForm if you need to more substantially.
show()
The basic pattern for actions is to display some sort of HTMLForm UI, maybe with some stuff underneat...
onSuccess()
Do something exciting on successful processing of the form.This might be to show a confirmation messa...
checkCanExecute(User $user)
Checks if the given user (identified by an object) can perform this action.Can be overridden by sub-c...
getDescription()
Returns the description that goes below the <h1> element.1.17 to override string HTML
getRestriction()
Get the permission required to perform this action.Often, but not always, the same as the action name...
getName()
Return the name of the action this object responds to.1.17string Lowercase name
getFormFields()
Get an HTMLForm descriptor array.to override array
__construct(Article $article, IContextSource $context, private readonly ReadOnlyMode $readOnlyMode, private readonly RevisionLookup $revisionLookup, private readonly RevisionRenderer $revisionRenderer, private readonly CommentFormatter $commentFormatter, private readonly LinkRenderer $linkRenderer, Config $config, private readonly TempUserCreator $tempUserCreator, private readonly AuthManager $authManager,)
addStatePropagationFields(HTMLForm $form)
preText()
Add pre- or post-text to the form.to override string HTML which will be sent to $form->addPreHtml()
onSubmit( $data)
Process the form on POST submission.If you don't want to do anything with the form,...
usesOOUI()
Whether the form should use OOUI.to override bool
AuthManager is the authentication system in MediaWiki and serves entry point for authentication.
This is the main service interface for converting single-line comments from various DB comment fields...
Value object for a comment stored by CommentStore.
static newUnsavedComment( $comment, ?array $data=null)
Create a new, unsaved CommentStoreComment.
Handle database storage of comments such as edit summaries and log reasons.
DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
An error page which can definitely be safely rendered using the OutputPage.
Exception representing a failure to serialize or unserialize a content object.
Show an error when a user tries to do something they do not have the necessary permissions for.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition HTMLForm.php:195
setWrapperLegendMsg( $msg)
Prompt the whole form to be wrapped in a "<fieldset>", with this message as its "<legend>" element.
showCancel( $show=true)
Show a cancel button (or prevent it).
addHiddenField( $name, $value, array $attribs=[])
Add a hidden field to the output Array values are discarded for security reasons (per WebRequest::get...
setSubmitTextMsg( $msg)
Set the text for the submit button to a message.
addButton( $data)
Add a button to the form.
setCancelTarget( $target)
Sets the target where the user is redirected to after clicking cancel.
This class is a collection of static functions that serve two purposes:
Definition Html.php:43
Class that generates HTML for internal links.
Some internal bits split of from Skin.php.
Definition Linker.php:47
A class containing constants representing the names of configuration variables.
const UseRCPatrol
Name constant for the UseRCPatrol setting, for use with Config::get()
const EditSubmitButtonLabelPublish
Name constant for the EditSubmitButtonLabelPublish setting, for use with Config::get()
Legacy class representing an editable page and handling UI for some page actions.
Definition Article.php:64
A StatusValue for permission errors.
Utility class for creating and reading rows in the recentchanges table.
static newFromParentRevision(RevisionRecord $parent)
Returns an incomplete MutableRevisionRecord which uses $parent as its parent revision,...
Page revision base class.
getSlot( $role, $audience=self::FOR_PUBLIC, ?Authority $performer=null)
Returns meta-data for the given slot.
getSlotRoles()
Returns the slot names (roles) of all slots present in this revision.
getSlots()
Returns the slots defined for this revision.
hasSlot( $role)
Returns whether the given slot is defined in this revision.
getId( $wikiId=self::LOCAL)
Get revision ID.
The RevisionRenderer service provides access to rendered output for revisions.
Value object representing a content slot associated with a page revision.
Parent class for all special pages.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
Object for storing information about the effects of an edit.
Represents the target of a wiki link.
Service for temporary user creation.
User class for the MediaWiki software.
Definition User.php:130
Determine whether a site is currently in read-only mode.
Interface for configuration instances.
Definition Config.php:18
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
Interface for objects which can provide a MediaWiki context on request.
Service for looking up page revisions.
Constants for representing well known causes for page updates.
element(SerializerNode $parent, SerializerNode $node, $contents)