MediaWiki  master
McrUndoAction.php
Go to the documentation of this file.
1 <?php
16 
32 class McrUndoAction extends FormAction {
33 
34  protected $undo = 0, $undoafter = 0, $cur = 0;
35 
37  protected $curRev = null;
38 
40  private $readOnlyMode;
41 
43  private $revisionLookup;
44 
46  private $revisionRenderer;
47 
49  private $useRCPatrol;
50 
59  public function __construct(
60  Article $article,
62  ReadOnlyMode $readOnlyMode,
63  RevisionLookup $revisionLookup,
64  RevisionRenderer $revisionRenderer,
65  Config $config
66  ) {
67  parent::__construct( $article, $context );
68  $this->readOnlyMode = $readOnlyMode;
69  $this->revisionLookup = $revisionLookup;
70  $this->revisionRenderer = $revisionRenderer;
71  $this->useRCPatrol = $config->get( MainConfigNames::UseRCPatrol );
72  }
73 
74  public function getName() {
75  return 'mcrundo';
76  }
77 
78  public function getDescription() {
79  return '';
80  }
81 
82  public function getRestriction() {
83  // Require 'edit' permission to even see this action (T297322)
84  return 'edit';
85  }
86 
87  public function show() {
88  // Send a cookie so anons get talk message notifications
89  // (copied from SubmitAction)
91 
92  // Some stuff copied from EditAction
94 
95  $out = $this->getOutput();
96  $out->setRobotPolicy( 'noindex,nofollow' );
97  if ( $this->getContext()->getConfig()->get( MainConfigNames::UseMediaWikiUIEverywhere ) ) {
98  $out->addModuleStyles( [
99  'mediawiki.ui.input',
100  'mediawiki.ui.checkbox',
101  ] );
102  }
103 
104  // IP warning headers copied from EditPage
105  // (should more be copied?)
106  if ( $this->readOnlyMode->isReadOnly() ) {
107  $out->wrapWikiMsg(
108  "<div id=\"mw-read-only-warning\">\n$1\n</div>",
109  [ 'readonlywarning', $this->readOnlyMode->getReason() ]
110  );
111  } elseif ( $this->context->getUser()->isAnon() ) {
112  // Note: EditPage has a special message for temp user creation intent here.
113  // But McrUndoAction doesn't support user creation.
114  if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
115  $out->addHTML(
117  $out->msg(
118  'anoneditwarning',
119  // Log-in link
120  SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
121  'returnto' => $this->getTitle()->getPrefixedDBkey()
122  ] ),
123  // Sign-up link
124  SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
125  'returnto' => $this->getTitle()->getPrefixedDBkey()
126  ] )
127  )->parse(),
128  'mw-anon-edit-warning'
129  )
130  );
131  } else {
132  $out->addHTML(
134  $out->msg( 'anonpreviewwarning' )->parse(),
135  'mw-anon-preview-warning'
136  )
137  );
138  }
139  }
140 
141  parent::show();
142  }
143 
144  protected function initFromParameters() {
145  $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
146  $this->undo = $this->getRequest()->getInt( 'undo' );
147 
148  if ( $this->undo == 0 || $this->undoafter == 0 ) {
149  throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
150  }
151 
152  $curRev = $this->getWikiPage()->getRevisionRecord();
153  if ( !$curRev ) {
154  throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
155  }
156  $this->curRev = $curRev;
157  $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
158  }
159 
160  protected function checkCanExecute( User $user ) {
161  parent::checkCanExecute( $user );
162 
163  $this->initFromParameters();
164 
165  // We use getRevisionByTitle to verify the revisions belong to this page (T297322)
166  $title = $this->getTitle();
167  $undoRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undo );
168  $oldRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undoafter );
169 
170  if ( $undoRev === null || $oldRev === null ||
171  $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
172  $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
173  ) {
174  throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
175  }
176 
177  return true;
178  }
179 
183  private function getNewRevision() {
184  $undoRev = $this->revisionLookup->getRevisionById( $this->undo );
185  $oldRev = $this->revisionLookup->getRevisionById( $this->undoafter );
187 
188  $isLatest = $curRev->getId() === $undoRev->getId();
189 
190  if ( $undoRev === null || $oldRev === null ||
191  $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
192  $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
193  ) {
194  throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
195  }
196 
197  if ( $isLatest ) {
198  // Short cut! Undoing the current revision means we just restore the old.
199  return MutableRevisionRecord::newFromParentRevision( $oldRev );
200  }
201 
202  $newRev = MutableRevisionRecord::newFromParentRevision( $curRev );
203 
204  // Figure out the roles that need merging by first collecting all roles
205  // and then removing the ones that don't.
206  $rolesToMerge = array_unique( array_merge(
207  $oldRev->getSlotRoles(),
208  $undoRev->getSlotRoles(),
210  ) );
211 
212  // Any roles with the same content in $oldRev and $undoRev can be
213  // inherited because undo won't change them.
214  $rolesToMerge = array_intersect(
215  $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
216  );
217  if ( !$rolesToMerge ) {
218  throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
219  }
220 
221  // Any roles with the same content in $oldRev and $curRev were already reverted
222  // and so can be inherited.
223  $rolesToMerge = array_intersect(
224  $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
225  );
226  if ( !$rolesToMerge ) {
227  throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
228  }
229 
230  // Any roles with the same content in $undoRev and $curRev weren't
231  // changed since and so can be reverted to $oldRev.
232  $diffRoles = array_intersect(
233  $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
234  );
235  foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
236  if ( $oldRev->hasSlot( $role ) ) {
237  $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
238  } else {
239  $newRev->removeSlot( $role );
240  }
241  }
242  $rolesToMerge = $diffRoles;
243 
244  // Any slot additions or removals not handled by the above checks can't be undone.
245  // There will be only one of the three revisions missing the slot:
246  // - !old means it was added in the undone revisions and modified after.
247  // Should it be removed entirely for the undo, or should the modified version be kept?
248  // - !undo means it was removed in the undone revisions and then readded with different content.
249  // Which content is should be kept, the old or the new?
250  // - !cur means it was changed in the undone revisions and then deleted after.
251  // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
252  // it), or should it stay gone?
253  foreach ( $rolesToMerge as $role ) {
254  if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
255  throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
256  }
257  }
258 
259  // Try to merge anything that's left.
260  foreach ( $rolesToMerge as $role ) {
261  $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
262  $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
263  $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
264  $newContent = $undoContent->getContentHandler()
265  ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
266  if ( !$newContent ) {
267  throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
268  }
269  $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
270  }
271 
272  return $newRev;
273  }
274 
275  private function generateDiffOrPreview() {
276  $newRev = $this->getNewRevision();
277  if ( $newRev->hasSameContent( $this->curRev ) ) {
278  throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
279  }
280 
281  $diffEngine = new DifferenceEngine( $this->context );
282  $diffEngine->setRevisions( $this->curRev, $newRev );
283 
284  $oldtitle = $this->context->msg( 'currentrev' )->parse();
285  $newtitle = $this->context->msg( 'yourtext' )->parse();
286 
287  if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
288  $this->showPreview( $newRev );
289  return '';
290  } else {
291  $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
292  $diffEngine->showDiffStyle();
293  return '<div id="wikiDiff">' . $diffText . '</div>';
294  }
295  }
296 
297  private function showPreview( RevisionRecord $rev ) {
298  // Mostly copied from EditPage::getPreviewText()
299  $out = $this->getOutput();
300 
301  try {
302  # provide a anchor link to the form
303  $continueEditing = '<span class="mw-continue-editing">' .
304  '[[#mw-mcrundo-form|' .
305  $this->context->getLanguage()->getArrow() . ' ' .
306  $this->context->msg( 'continue-editing' )->text() . ']]</span>';
307 
308  $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
309 
310  $parserOptions = $this->getWikiPage()->makeParserOptions( $this->context );
311  $parserOptions->setRenderReason( 'page-preview' );
312  $parserOptions->setIsPreview( true );
313  $parserOptions->setIsSectionPreview( false );
314 
315  $parserOutput = $this->revisionRenderer
316  ->getRenderedRevision( $rev, $parserOptions, $this->getAuthority() )
317  ->getRevisionParserOutput();
318  $previewHTML = $parserOutput->getText( [
319  'enableSectionEditLinks' => false,
320  'includeDebugInfo' => true,
321  ] );
322 
323  $out->addParserOutputMetadata( $parserOutput );
324  if ( count( $parserOutput->getWarnings() ) ) {
325  $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
326  }
327  } catch ( MWContentSerializationException $ex ) {
328  $m = $this->context->msg(
329  'content-failed-to-parse',
330  $ex->getMessage()
331  );
332  $note .= "\n\n" . $m->parse();
333  $previewHTML = '';
334  }
335 
336  $previewhead = Html::rawElement(
337  'div', [ 'class' => 'previewnote' ],
339  'h2', [ 'id' => 'mw-previewheader' ],
340  $this->context->msg( 'preview' )->text()
341  ) .
343  $out->parseAsInterface( $note )
344  )
345  );
346 
347  $pageViewLang = $this->getTitle()->getPageViewLanguage();
348  $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(),
349  'class' => 'mw-content-' . $pageViewLang->getDir() ];
350  $previewHTML = Html::rawElement( 'div', $attribs, $previewHTML );
351 
352  $out->addHTML( $previewhead . $previewHTML );
353  }
354 
355  public function onSubmit( $data ) {
356  if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
357  // Diff or preview
358  return false;
359  }
360 
361  $updater = $this->getWikiPage()->newPageUpdater( $this->context->getUser() );
362  $curRev = $updater->grabParentRevision();
363  if ( !$curRev ) {
364  throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
365  }
366 
367  if ( $this->cur !== $curRev->getId() ) {
368  return Status::newFatal( 'mcrundo-changed' );
369  }
370 
371  $status = new PermissionStatus();
372  $this->getAuthority()->authorizeWrite( 'edit', $this->getTitle(), $status );
373  if ( !$status->isOK() ) {
374  throw new PermissionsError( 'edit', $status );
375  }
376 
377  $newRev = $this->getNewRevision();
378  if ( !$newRev->hasSameContent( $curRev ) ) {
379  $hookRunner = $this->getHookRunner();
380  foreach ( $newRev->getSlotRoles() as $slotRole ) {
381  $slot = $newRev->getSlot( $slotRole, RevisionRecord::RAW );
382 
383  $status = new Status();
384  $hookResult = $hookRunner->onEditFilterMergedContent(
385  $this->getContext(),
386  $slot->getContent(),
387  $status,
388  trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ),
389  $this->getUser(),
390  false
391  );
392 
393  if ( !$hookResult ) {
394  if ( $status->isGood() ) {
395  $status->error( 'hookaborted' );
396  }
397 
398  return $status;
399  } elseif ( !$status->isOK() ) {
400  if ( !$status->getErrors() ) {
401  $status->error( 'hookaborted' );
402  }
403  return $status;
404  }
405  }
406 
407  // Copy new slots into the PageUpdater, and remove any removed slots.
408  // TODO: This interface is awful, there should be a way to just pass $newRev.
409  // TODO: MCR: test this once we can store multiple slots
410  foreach ( $newRev->getSlots()->getSlots() as $slot ) {
411  $updater->setSlot( $slot );
412  }
413  foreach ( $curRev->getSlotRoles() as $role ) {
414  if ( !$newRev->hasSlot( $role ) ) {
415  $updater->removeSlot( $role );
416  }
417  }
418 
419  $updater->markAsRevert( EditResult::REVERT_UNDO, $this->undo, $this->undoafter );
420 
421  if ( $this->useRCPatrol && $this->getAuthority()
422  ->authorizeWrite( 'autopatrol', $this->getTitle() )
423  ) {
424  $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
425  }
426 
427  $updater->saveRevision(
429  trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ) ),
431  );
432 
433  return $updater->getStatus();
434  }
435 
436  return Status::newGood();
437  }
438 
439  protected function usesOOUI() {
440  return true;
441  }
442 
443  protected function getFormFields() {
444  $request = $this->getRequest();
445  $ret = [
446  'diff' => [
447  'type' => 'info',
448  'raw' => true,
449  'default' => function () {
450  return $this->generateDiffOrPreview();
451  }
452  ],
453  'summary' => [
454  'type' => 'text',
455  'id' => 'wpSummary',
456  'name' => 'wpSummary',
457  'cssclass' => 'mw-summary',
458  'label-message' => 'summary',
460  'value' => $request->getVal( 'wpSummary', '' ),
461  'size' => 60,
462  'spellcheck' => 'true',
463  ],
464  'summarypreview' => [
465  'type' => 'info',
466  'label-message' => 'summary-preview',
467  'raw' => true,
468  ],
469  ];
470 
471  if ( $request->getCheck( 'wpSummary' ) ) {
472  $ret['summarypreview']['default'] = Xml::tags( 'div', [ 'class' => 'mw-summary-preview' ],
473  Linker::commentBlock( trim( $request->getVal( 'wpSummary' ) ), $this->getTitle(), false )
474  );
475  } else {
476  unset( $ret['summarypreview'] );
477  }
478 
479  return $ret;
480  }
481 
482  protected function alterForm( HTMLForm $form ) {
483  $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
484 
485  $labelAsPublish = $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
486 
487  $form->setId( 'mw-mcrundo-form' );
488  $form->setSubmitName( 'wpSave' );
489  $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
490  $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
491  $form->showCancel( true );
492  $form->setCancelTarget( $this->getTitle() );
493  $form->addButton( [
494  'name' => 'wpPreview',
495  'value' => '1',
496  'label-message' => 'showpreview',
497  'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
498  ] );
499  $form->addButton( [
500  'name' => 'wpDiff',
501  'value' => '1',
502  'label-message' => 'showdiff',
503  'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
504  ] );
505 
506  $this->addStatePropagationFields( $form );
507  }
508 
509  protected function addStatePropagationFields( HTMLForm $form ) {
510  $form->addHiddenField( 'undo', $this->undo );
511  $form->addHiddenField( 'undoafter', $this->undoafter );
512  $form->addHiddenField( 'cur', $this->curRev->getId() );
513  }
514 
515  public function onSuccess() {
516  $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
517  }
518 
519  protected function preText() {
520  return '<div style="clear:both"></div>';
521  }
522 }
const EDIT_UPDATE
Definition: Defines.php:127
const EDIT_AUTOSUMMARY
Definition: Defines.php:132
getWikiPage()
Get a WikiPage object.
Definition: Action.php:203
IContextSource null $context
IContextSource if specified; otherwise we'll use the Context from the Page.
Definition: Action.php:57
getHookRunner()
Definition: Action.php:268
getTitle()
Shortcut to get the Title object from the page.
Definition: Action.php:224
getContext()
Get the IContextSource in use here.
Definition: Action.php:130
getOutput()
Get the OutputPage being used for this instance.
Definition: Action.php:154
getUser()
Shortcut to get the User being used for this instance.
Definition: Action.php:164
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition: Action.php:460
getAuthority()
Shortcut to get the Authority executing this instance.
Definition: Action.php:174
getRequest()
Get the WebRequest being used for this instance.
Definition: Action.php:144
Legacy class representing an editable page and handling UI for some page actions.
Definition: Article.php:48
static newUnsavedComment( $comment, array $data=null)
Create a new, unsaved CommentStoreComment.
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
An error page which can definitely be safely rendered using the OutputPage.
An action which shows a form and does something based on the input from the form.
Definition: FormAction.php:30
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition: HTMLForm.php:150
setSubmitName( $name)
Definition: HTMLForm.php:1635
setSubmitTextMsg( $msg)
Set the text for the submit button to a message.
Definition: HTMLForm.php:1613
setWrapperLegendMsg( $msg)
Prompt the whole form to be wrapped in a "<fieldset>", with this message as its "<legend>" element.
Definition: HTMLForm.php:1818
setId( $id)
Definition: HTMLForm.php:1763
addButton( $data)
Add a button to the form.
Definition: HTMLForm.php:1183
setSubmitTooltip( $name)
Definition: HTMLForm.php:1646
addHiddenField( $name, $value, array $attribs=[])
Add a hidden field to the output.
Definition: HTMLForm.php:1135
setCancelTarget( $target)
Sets the target where the user is redirected to after clicking cancel.
Definition: HTMLForm.php:1720
showCancel( $show=true)
Show a cancel button (or prevent it).
Definition: HTMLForm.php:1709
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:236
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:214
static warningBox( $html, $className='')
Return a warning box.
Definition: Html.php:775
static commentBlock( $comment, $title=null, $local=false, $wikiId=null, $useParentheses=true)
Wrap a comment in standard punctuation and formatting if it's non-empty, otherwise return empty strin...
Definition: Linker.php:1529
static tooltipAndAccesskeyAttribs( $name, array $msgParams=[], $options=null, $localizer=null, $user=null, $config=null, $relevantTitle=null)
Returns the attributes for the tooltip and access key.
Definition: Linker.php:2243
Exception representing a failure to serialize or unserialize a content object.
Temporary action for MCR undos.
show()
The basic pattern for actions is to display some sort of HTMLForm UI, maybe with some stuff underneat...
__construct(Article $article, IContextSource $context, ReadOnlyMode $readOnlyMode, RevisionLookup $revisionLookup, RevisionRenderer $revisionRenderer, Config $config)
onSuccess()
Do something exciting on successful processing of the form.
alterForm(HTMLForm $form)
Play with the HTMLForm if you need to more substantially.
checkCanExecute(User $user)
Checks if the given user (identified by an object) can perform this action.
onSubmit( $data)
Process the form on POST submission.
RevisionRecord null $curRev
getDescription()
Returns the description that goes below the <h1> element.
preText()
Add pre- or post-text to the form.
addStatePropagationFields(HTMLForm $form)
getName()
Return the name of the action this object responds to.
getRestriction()
Get the permission required to perform this action.
usesOOUI()
Whether the form should use OOUI.
getFormFields()
Get an HTMLForm descriptor array.
A class containing constants representing the names of configuration variables.
A StatusValue for permission errors.
Page revision base class.
getSlotRoles()
Returns the slot names (roles) of all slots present in this revision.
getSlots()
Returns the slots defined for this revision.
getSlot( $role, $audience=self::FOR_PUBLIC, Authority $performer=null)
Returns meta-data for the given slot.
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.
Definition: SlotRecord.php:40
static getGlobalSession()
If PHP's session_id() has been set, returns that session.
Object for storing information about the effects of an edit.
Definition: EditResult.php:35
Show an error when a user tries to do something they do not have the necessary permissions for.
A service class for fetching the wiki's current read-only mode.
const PRC_AUTOPATROLLED
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,...
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:73
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:85
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:45
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:70
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition: Xml.php:134
Interface for configuration instances.
Definition: Config.php:30
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.