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  Page $page,
62  ReadOnlyMode $readOnlyMode,
63  RevisionLookup $revisionLookup,
64  RevisionRenderer $revisionRenderer,
65  Config $config
66  ) {
67  parent::__construct( $page, $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  $previewHTML = '';
303 
304  # provide a anchor link to the form
305  $continueEditing = '<span class="mw-continue-editing">' .
306  '[[#mw-mcrundo-form|' .
307  $this->context->getLanguage()->getArrow() . ' ' .
308  $this->context->msg( 'continue-editing' )->text() . ']]</span>';
309 
310  $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
311 
312  $parserOptions = $this->getWikiPage()->makeParserOptions( $this->context );
313  $parserOptions->setIsPreview( true );
314  $parserOptions->setIsSectionPreview( false );
315 
316  $parserOutput = $this->revisionRenderer
317  ->getRenderedRevision( $rev, $parserOptions, $this->getAuthority() )
318  ->getRevisionParserOutput();
319  $previewHTML = $parserOutput->getText( [
320  'enableSectionEditLinks' => false,
321  'includeDebugInfo' => true,
322  ] );
323 
324  $out->addParserOutputMetadata( $parserOutput );
325  if ( count( $parserOutput->getWarnings() ) ) {
326  $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
327  }
328  } catch ( MWContentSerializationException $ex ) {
329  $m = $this->context->msg(
330  'content-failed-to-parse',
331  $ex->getMessage()
332  );
333  $note .= "\n\n" . $m->parse();
334  $previewHTML = '';
335  }
336 
337  $previewhead = Html::rawElement(
338  'div', [ 'class' => 'previewnote' ],
340  'h2', [ 'id' => 'mw-previewheader' ],
341  $this->context->msg( 'preview' )->text()
342  ) .
344  $out->parseAsInterface( $note )
345  )
346  );
347 
348  $pageViewLang = $this->getTitle()->getPageViewLanguage();
349  $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(),
350  'class' => 'mw-content-' . $pageViewLang->getDir() ];
351  $previewHTML = Html::rawElement( 'div', $attribs, $previewHTML );
352 
353  $out->addHTML( $previewhead . $previewHTML );
354  }
355 
356  public function onSubmit( $data ) {
357  if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
358  // Diff or preview
359  return false;
360  }
361 
362  $updater = $this->getWikiPage()->newPageUpdater( $this->context->getUser() );
363  $curRev = $updater->grabParentRevision();
364  if ( !$curRev ) {
365  throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
366  }
367 
368  if ( $this->cur !== $curRev->getId() ) {
369  return Status::newFatal( 'mcrundo-changed' );
370  }
371 
372  $status = new PermissionStatus();
373  $this->getAuthority()->authorizeWrite( 'edit', $this->getTitle(), $status );
374  if ( !$status->isOK() ) {
375  throw new PermissionsError( 'edit', $status );
376  }
377 
378  $newRev = $this->getNewRevision();
379  if ( !$newRev->hasSameContent( $curRev ) ) {
380  $hookRunner = Hooks::runner();
381  foreach ( $newRev->getSlotRoles() as $slotRole ) {
382  $slot = $newRev->getSlot( $slotRole, RevisionRecord::RAW );
383 
384  $status = new Status();
385  $hookResult = $hookRunner->onEditFilterMergedContent(
386  $this->getContext(),
387  $slot->getContent(),
388  $status,
389  trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ),
390  $this->getUser(),
391  false
392  );
393 
394  if ( !$hookResult ) {
395  if ( $status->isGood() ) {
396  $status->error( 'hookaborted' );
397  }
398 
399  return $status;
400  } elseif ( !$status->isOK() ) {
401  if ( !$status->getErrors() ) {
402  $status->error( 'hookaborted' );
403  }
404  return $status;
405  }
406  }
407 
408  // Copy new slots into the PageUpdater, and remove any removed slots.
409  // TODO: This interface is awful, there should be a way to just pass $newRev.
410  // TODO: MCR: test this once we can store multiple slots
411  foreach ( $newRev->getSlots()->getSlots() as $slot ) {
412  $updater->setSlot( $slot );
413  }
414  foreach ( $curRev->getSlotRoles() as $role ) {
415  if ( !$newRev->hasSlot( $role ) ) {
416  $updater->removeSlot( $role );
417  }
418  }
419 
420  $updater->markAsRevert( EditResult::REVERT_UNDO, $this->undo, $this->undoafter );
421 
422  if ( $this->useRCPatrol && $this->getAuthority()
423  ->authorizeWrite( 'autopatrol', $this->getTitle() )
424  ) {
425  $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
426  }
427 
428  $updater->saveRevision(
430  trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ) ),
432  );
433 
434  return $updater->getStatus();
435  }
436 
437  return Status::newGood();
438  }
439 
440  protected function usesOOUI() {
441  return true;
442  }
443 
444  protected function getFormFields() {
445  $request = $this->getRequest();
446  $ret = [
447  'diff' => [
448  'type' => 'info',
449  'raw' => true,
450  'default' => function () {
451  return $this->generateDiffOrPreview();
452  }
453  ],
454  'summary' => [
455  'type' => 'text',
456  'id' => 'wpSummary',
457  'name' => 'wpSummary',
458  'cssclass' => 'mw-summary',
459  'label-message' => 'summary',
461  'value' => $request->getVal( 'wpSummary', '' ),
462  'size' => 60,
463  'spellcheck' => 'true',
464  ],
465  'summarypreview' => [
466  'type' => 'info',
467  'label-message' => 'summary-preview',
468  'raw' => true,
469  ],
470  ];
471 
472  if ( $request->getCheck( 'wpSummary' ) ) {
473  $ret['summarypreview']['default'] = Xml::tags( 'div', [ 'class' => 'mw-summary-preview' ],
474  Linker::commentBlock( trim( $request->getVal( 'wpSummary' ) ), $this->getTitle(), false )
475  );
476  } else {
477  unset( $ret['summarypreview'] );
478  }
479 
480  return $ret;
481  }
482 
483  protected function alterForm( HTMLForm $form ) {
484  $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
485 
486  $labelAsPublish = $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
487 
488  $form->setId( 'mw-mcrundo-form' );
489  $form->setSubmitName( 'wpSave' );
490  $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
491  $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
492  $form->showCancel( true );
493  $form->setCancelTarget( $this->getTitle() );
494  $form->addButton( [
495  'name' => 'wpPreview',
496  'value' => '1',
497  'label-message' => 'showpreview',
498  'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
499  ] );
500  $form->addButton( [
501  'name' => 'wpDiff',
502  'value' => '1',
503  'label-message' => 'showdiff',
504  'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
505  ] );
506 
507  $this->addStatePropagationFields( $form );
508  }
509 
510  protected function addStatePropagationFields( HTMLForm $form ) {
511  $form->addHiddenField( 'undo', $this->undo );
512  $form->addHiddenField( 'undoafter', $this->undoafter );
513  $form->addHiddenField( 'cur', $this->curRev->getId() );
514  }
515 
516  public function onSuccess() {
517  $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
518  }
519 
520  protected function preText() {
521  return '<div style="clear:both"></div>';
522  }
523 }
const EDIT_UPDATE
Definition: Defines.php:127
const EDIT_AUTOSUMMARY
Definition: Defines.php:132
getWikiPage()
Get a WikiPage object.
Definition: Action.php:209
IContextSource null $context
IContextSource if specified; otherwise we'll use the Context from the Page.
Definition: Action.php:67
WikiPage Article ImagePage CategoryPage Page $page
Page on which we're performing the action.
Definition: Action.php:54
getTitle()
Shortcut to get the Title object from the page.
Definition: Action.php:230
getContext()
Get the IContextSource in use here.
Definition: Action.php:136
getOutput()
Get the OutputPage being used for this instance.
Definition: Action.php:160
getUser()
Shortcut to get the User being used for this instance.
Definition: Action.php:170
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition: Action.php:485
getAuthority()
Shortcut to get the Authority executing this instance.
Definition: Action.php:180
getRequest()
Get the WebRequest being used for this instance.
Definition: Action.php:150
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:1626
setSubmitTextMsg( $msg)
Set the text for the submit button to a message.
Definition: HTMLForm.php:1604
setWrapperLegendMsg( $msg)
Prompt the whole form to be wrapped in a "<fieldset>", with this message as its "<legend>" element.
Definition: HTMLForm.php:1809
setId( $id)
Definition: HTMLForm.php:1754
addButton( $data)
Add a button to the form.
Definition: HTMLForm.php:1174
setSubmitTooltip( $name)
Definition: HTMLForm.php:1637
addHiddenField( $name, $value, array $attribs=[])
Add a hidden field to the output.
Definition: HTMLForm.php:1126
setCancelTarget( $target)
Sets the target where the user is redirected to after clicking cancel.
Definition: HTMLForm.php:1711
showCancel( $show=true)
Show a cancel button (or prevent it).
Definition: HTMLForm.php:1700
static runner()
Get a HookRunner instance for calling hooks using the new interfaces.
Definition: Hooks.php:173
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:1541
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:2255
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...
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.
__construct(Page $page, IContextSource $context, ReadOnlyMode $readOnlyMode, RevisionLookup $revisionLookup, RevisionRenderer $revisionRenderer, Config $config)
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:38
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:70
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:82
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:44
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.
Interface for type hinting (accepts WikiPage, Article, ImagePage, CategoryPage)
Definition: Page.php:29