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