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