MediaWiki master
McrUndoAction.php
Go to the documentation of this file.
1<?php
8namespace MediaWiki\Actions;
9
37
54
55 protected int $undo = 0;
56 protected int $undoafter = 0;
57 protected int $cur = 0;
58
60 protected $curRev = null;
61
62 private ReadOnlyMode $readOnlyMode;
63 private RevisionLookup $revisionLookup;
64 private RevisionRenderer $revisionRenderer;
65 private CommentFormatter $commentFormatter;
66 private bool $useRCPatrol;
67
77 public function __construct(
78 Article $article,
80 ReadOnlyMode $readOnlyMode,
81 RevisionLookup $revisionLookup,
82 RevisionRenderer $revisionRenderer,
83 CommentFormatter $commentFormatter,
84 Config $config
85 ) {
86 parent::__construct( $article, $context );
87 $this->readOnlyMode = $readOnlyMode;
88 $this->revisionLookup = $revisionLookup;
89 $this->revisionRenderer = $revisionRenderer;
90 $this->commentFormatter = $commentFormatter;
91 $this->useRCPatrol = $config->get( MainConfigNames::UseRCPatrol );
92 }
93
94 public function getName() {
95 return 'mcrundo';
96 }
97
98 public function getDescription() {
99 return '';
100 }
101
102 public function getRestriction() {
103 // Require 'edit' permission to even see this action (T297322)
104 return 'edit';
105 }
106
107 public function show() {
108 // Send a cookie so anons get talk message notifications
109 // (copied from SubmitAction)
110 \MediaWiki\Session\SessionManager::getGlobalSession()->persist();
111
112 // Some stuff copied from EditAction
114
115 $out = $this->getOutput();
116 $out->setRobotPolicy( 'noindex,nofollow' );
117
118 // IP warning headers copied from EditPage
119 // (should more be copied?)
120 if ( $this->readOnlyMode->isReadOnly() ) {
121 $out->wrapWikiMsg(
122 "<div id=\"mw-read-only-warning\">\n$1\n</div>",
123 [ 'readonlywarning', $this->readOnlyMode->getReason() ]
124 );
125 } elseif ( $this->context->getUser()->isAnon() ) {
126 // Note: EditPage has a special message for temp user creation intent here.
127 // But McrUndoAction doesn't support user creation.
128 if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
129 $out->addHTML(
130 Html::warningBox(
131 $out->msg(
132 'anoneditwarning',
133 // Log-in link
134 SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
135 'returnto' => $this->getTitle()->getPrefixedDBkey()
136 ] ),
137 // Sign-up link
138 SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
139 'returnto' => $this->getTitle()->getPrefixedDBkey()
140 ] )
141 )->parse(),
142 'mw-anon-edit-warning'
143 )
144 );
145 } else {
146 $out->addHTML(
147 Html::warningBox(
148 $out->msg( 'anonpreviewwarning' )->parse(),
149 'mw-anon-preview-warning'
150 )
151 );
152 }
153 }
154
155 parent::show();
156 }
157
158 protected function initFromParameters() {
159 $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
160 $this->undo = $this->getRequest()->getInt( 'undo' );
161
162 if ( $this->undo == 0 || $this->undoafter == 0 ) {
163 throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
164 }
165
166 $curRev = $this->getWikiPage()->getRevisionRecord();
167 if ( !$curRev ) {
168 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
169 }
170 $this->curRev = $curRev;
171 $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
172 }
173
174 protected function checkCanExecute( User $user ) {
175 parent::checkCanExecute( $user );
176
177 $this->initFromParameters();
178
179 // We use getRevisionByTitle to verify the revisions belong to this page (T297322)
180 $title = $this->getTitle();
181 $undoRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undo );
182 $oldRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undoafter );
183
184 if ( $undoRev === null || $oldRev === null ||
185 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
186 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
187 ) {
188 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
189 }
190
191 return true;
192 }
193
197 private function getNewRevision() {
198 $undoRev = $this->revisionLookup->getRevisionById( $this->undo );
199 $oldRev = $this->revisionLookup->getRevisionById( $this->undoafter );
201
202 $isLatest = $curRev->getId() === $undoRev->getId();
203
204 if ( $undoRev === null || $oldRev === null ||
205 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
206 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
207 ) {
208 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
209 }
210
211 if ( $isLatest ) {
212 // Short cut! Undoing the current revision means we just restore the old.
214 }
215
217
218 // Figure out the roles that need merging by first collecting all roles
219 // and then removing the ones that don't.
220 $rolesToMerge = array_unique( array_merge(
221 $oldRev->getSlotRoles(),
222 $undoRev->getSlotRoles(),
224 ) );
225
226 // Any roles with the same content in $oldRev and $undoRev can be
227 // inherited because undo won't change them.
228 $rolesToMerge = array_intersect(
229 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
230 );
231 if ( !$rolesToMerge ) {
232 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
233 }
234
235 // Any roles with the same content in $oldRev and $curRev were already reverted
236 // and so can be inherited.
237 $rolesToMerge = array_intersect(
238 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
239 );
240 if ( !$rolesToMerge ) {
241 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
242 }
243
244 // Any roles with the same content in $undoRev and $curRev weren't
245 // changed since and so can be reverted to $oldRev.
246 $diffRoles = array_intersect(
247 $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
248 );
249 foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
250 if ( $oldRev->hasSlot( $role ) ) {
251 $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
252 } else {
253 $newRev->removeSlot( $role );
254 }
255 }
256 $rolesToMerge = $diffRoles;
257
258 // Any slot additions or removals not handled by the above checks can't be undone.
259 // There will be only one of the three revisions missing the slot:
260 // - !old means it was added in the undone revisions and modified after.
261 // Should it be removed entirely for the undo, or should the modified version be kept?
262 // - !undo means it was removed in the undone revisions and then readded with different content.
263 // Which content is should be kept, the old or the new?
264 // - !cur means it was changed in the undone revisions and then deleted after.
265 // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
266 // it), or should it stay gone?
267 foreach ( $rolesToMerge as $role ) {
268 if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
269 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
270 }
271 }
272
273 // Try to merge anything that's left.
274 foreach ( $rolesToMerge as $role ) {
275 $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
276 $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
277 $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
278 $newContent = $undoContent->getContentHandler()
279 ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
280 if ( !$newContent ) {
281 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
282 }
283 $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
284 }
285
286 return $newRev;
287 }
288
289 private function generateDiffOrPreview(): string {
290 $newRev = $this->getNewRevision();
291 if ( $newRev->hasSameContent( $this->curRev ) ) {
292 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
293 }
294
295 $diffEngine = new DifferenceEngine( $this->context );
296 $diffEngine->setRevisions( $this->curRev, $newRev );
297
298 $oldtitle = $this->context->msg( 'currentrev' )->parse();
299 $newtitle = $this->context->msg( 'yourtext' )->parse();
300
301 if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
302 $this->showPreview( $newRev );
303 return '';
304 } else {
305 $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
306 $diffEngine->showDiffStyle();
307 return '<div id="wikiDiff">' . $diffText . '</div>';
308 }
309 }
310
311 private function showPreview( RevisionRecord $rev ) {
312 // Mostly copied from EditPage::getPreviewText()
313 $out = $this->getOutput();
314
315 try {
316 # provide a anchor link to the form
317 $continueEditing = '<span class="mw-continue-editing">' .
318 '[[#mw-mcrundo-form|' .
319 $this->context->getLanguage()->getArrow() . ' ' .
320 $this->context->msg( 'continue-editing' )->text() . ']]</span>';
321
322 $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
323
324 $parserOptions = $this->getWikiPage()->makeParserOptions( $this->context );
325 $parserOptions->setRenderReason( 'page-preview' );
326 $parserOptions->setIsPreview( true );
327 $parserOptions->setIsSectionPreview( false );
328
329 $parserOutput = $this->revisionRenderer
330 ->getRenderedRevision( $rev, $parserOptions, $this->getAuthority() )
331 ->getRevisionParserOutput();
332 // TODO T371004 move runOutputPipeline out of $parserOutput
333 $previewHTML = $parserOutput->runOutputPipeline( $parserOptions, [
334 'enableSectionEditLinks' => false,
335 'includeDebugInfo' => true,
336 ] )->getContentHolderText();
337
338 $out->addParserOutputMetadata( $parserOutput );
339 if ( count( $parserOutput->getWarnings() ) ) {
340 $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
341 }
342 } catch ( MWContentSerializationException $ex ) {
343 $m = $this->context->msg(
344 'content-failed-to-parse',
345 $ex->getMessage()
346 );
347 $note .= "\n\n" . $m->parse();
348 $previewHTML = '';
349 }
350
351 $previewhead = Html::rawElement(
352 'div', [ 'class' => 'previewnote' ],
354 'h2', [ 'id' => 'mw-previewheader' ],
355 $this->context->msg( 'preview' )->text()
356 ) .
357 Html::warningBox(
358 $out->parseAsInterface( $note )
359 )
360 );
361
362 $out->addHTML( $previewhead . $previewHTML );
363 }
364
365 public function onSubmit( $data ) {
366 if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
367 // Diff or preview
368 return false;
369 }
370
371 $updater = $this->getWikiPage()->newPageUpdater( $this->context->getUser() );
372 $curRev = $updater->grabParentRevision();
373 if ( !$curRev ) {
374 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
375 }
376
377 if ( $this->cur !== $curRev->getId() ) {
378 return Status::newFatal( 'mcrundo-changed' );
379 }
380
381 $status = new PermissionStatus();
382 $this->getAuthority()->authorizeWrite( 'edit', $this->getTitle(), $status );
383 if ( !$status->isOK() ) {
384 throw new PermissionsError( 'edit', $status );
385 }
386
387 $newRev = $this->getNewRevision();
388 if ( !$newRev->hasSameContent( $curRev ) ) {
389 $hookRunner = $this->getHookRunner();
390 foreach ( $newRev->getSlotRoles() as $slotRole ) {
391 $slot = $newRev->getSlot( $slotRole, RevisionRecord::RAW );
392
393 $status = new Status();
394 $hookResult = $hookRunner->onEditFilterMergedContent(
395 $this->getContext(),
396 $slot->getContent(),
397 $status,
398 trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ),
399 $this->getUser(),
400 false
401 );
402
403 if ( !$hookResult ) {
404 if ( $status->isGood() ) {
405 $status->error( 'hookaborted' );
406 }
407
408 return $status;
409 } elseif ( !$status->isOK() ) {
410 if ( !$status->getMessages() ) {
411 $status->error( 'hookaborted' );
412 }
413 return $status;
414 }
415 }
416
417 // Copy new slots into the PageUpdater, and remove any removed slots.
418 // TODO: This interface is awful, there should be a way to just pass $newRev.
419 // TODO: MCR: test this once we can store multiple slots
420 foreach ( $newRev->getSlots()->getSlots() as $slot ) {
421 $updater->setSlot( $slot );
422 }
423 foreach ( $curRev->getSlotRoles() as $role ) {
424 if ( !$newRev->hasSlot( $role ) ) {
425 $updater->removeSlot( $role );
426 }
427 }
428
429 $updater->setCause( PageUpdateCauses::CAUSE_UNDO );
430 $updater->markAsRevert( EditResult::REVERT_UNDO, $this->undo, $this->undoafter );
431
432 if ( $this->useRCPatrol && $this->getAuthority()
433 ->authorizeWrite( 'autopatrol', $this->getTitle() )
434 ) {
435 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
436 }
437
438 $updater->saveRevision(
440 trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ) ),
442 );
443
444 return $updater->getStatus();
445 }
446
447 return Status::newGood();
448 }
449
450 protected function usesOOUI() {
451 return true;
452 }
453
454 protected function getFormFields() {
455 $request = $this->getRequest();
456 $ret = [
457 'diff' => [
458 'type' => 'info',
459 'raw' => true,
460 'default' => function () {
461 return $this->generateDiffOrPreview();
462 }
463 ],
464 'summary' => [
465 'type' => 'text',
466 'id' => 'wpSummary',
467 'name' => 'wpSummary',
468 'cssclass' => 'mw-summary',
469 'label-message' => 'summary',
470 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
471 'value' => $request->getVal( 'wpSummary', '' ),
472 'size' => 60,
473 'spellcheck' => 'true',
474 ],
475 'summarypreview' => [
476 'type' => 'info',
477 'label-message' => 'summary-preview',
478 'raw' => true,
479 ],
480 ];
481
482 if ( $request->getCheck( 'wpSummary' ) ) {
483 $ret['summarypreview']['default'] = Html::rawElement(
484 'div',
485 [ 'class' => 'mw-summary-preview' ],
486 $this->commentFormatter->formatBlock(
487 trim( $request->getVal( 'wpSummary' ) ),
488 $this->getTitle(),
489 false
490 )
491 );
492 } else {
493 unset( $ret['summarypreview'] );
494 }
495
496 return $ret;
497 }
498
499 protected function alterForm( HTMLForm $form ) {
500 $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
501
502 $labelAsPublish = $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
503
504 $form->setId( 'mw-mcrundo-form' );
505 $form->setSubmitName( 'wpSave' );
506 $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
507 $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
508 $form->showCancel( true );
509 $form->setCancelTarget( $this->getTitle() );
510 $form->addButton( [
511 'name' => 'wpPreview',
512 'value' => '1',
513 'label-message' => 'showpreview',
514 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
515 ] );
516 $form->addButton( [
517 'name' => 'wpDiff',
518 'value' => '1',
519 'label-message' => 'showdiff',
520 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
521 ] );
522
523 $this->addStatePropagationFields( $form );
524 }
525
526 protected function addStatePropagationFields( HTMLForm $form ) {
527 $form->addHiddenField( 'undo', $this->undo );
528 $form->addHiddenField( 'undoafter', $this->undoafter );
529 $form->addHiddenField( 'cur', $this->curRev->getId() );
530 }
531
532 public function onSuccess() {
533 $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
534 }
535
536 protected function preText() {
537 return '<div style="clear:both"></div>';
538 }
539}
540
542class_alias( McrUndoAction::class, 'McrUndoAction' );
const EDIT_UPDATE
Article is assumed to be pre-existing, fail if it doesn't exist.
Definition Defines.php:131
const EDIT_AUTOSUMMARY
Fill in blank summaries with generated text where possible.
Definition Defines.php:149
DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
getWikiPage()
Get a WikiPage object.
Definition Action.php:205
IContextSource null $context
IContextSource if specified; otherwise we'll use the Context from the Page.
Definition Action.php:79
getTitle()
Shortcut to get the Title object from the page.
Definition Action.php:226
getRequest()
Get the WebRequest being used for this instance.
Definition Action.php:146
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition Action.php:494
getOutput()
Get the OutputPage being used for this instance.
Definition Action.php:156
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.
checkCanExecute(User $user)
Checks if the given user (identified by an object) can perform this action.
getDescription()
Returns the description that goes below the <h1> element.
getRestriction()
Get the permission required to perform this action.
getName()
Return the name of the action this object responds to.
__construct(Article $article, IContextSource $context, ReadOnlyMode $readOnlyMode, RevisionLookup $revisionLookup, RevisionRenderer $revisionRenderer, CommentFormatter $commentFormatter, Config $config)
getFormFields()
Get an HTMLForm descriptor array.
addStatePropagationFields(HTMLForm $form)
preText()
Add pre- or post-text to the form.
onSubmit( $data)
Process the form on POST submission.
usesOOUI()
Whether the form should use OOUI.
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.
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:209
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:57
Some internal bits split of from Skin.php.
Definition Linker.php:61
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:76
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:54
Object for storing information about the effects of an edit.
User class for the MediaWiki software.
Definition User.php:123
Determine whether a site is currently in read-only mode.
Interface for configuration instances.
Definition Config.php:32
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)