MediaWiki  master
McrUndoAction.php
Go to the documentation of this file.
1 <?php
21 
37 class McrUndoAction extends FormAction {
38 
39  protected $undo = 0, $undoafter = 0, $cur = 0;
40 
42  protected $curRev = null;
43 
45  private $readOnlyMode;
46 
48  private $revisionLookup;
49 
51  private $revisionRenderer;
52 
54  private $commentFormatter;
55 
57  private $useRCPatrol;
58 
68  public function __construct(
69  Article $article,
71  ReadOnlyMode $readOnlyMode,
72  RevisionLookup $revisionLookup,
73  RevisionRenderer $revisionRenderer,
74  CommentFormatter $commentFormatter,
75  Config $config
76  ) {
77  parent::__construct( $article, $context );
78  $this->readOnlyMode = $readOnlyMode;
79  $this->revisionLookup = $revisionLookup;
80  $this->revisionRenderer = $revisionRenderer;
81  $this->commentFormatter = $commentFormatter;
82  $this->useRCPatrol = $config->get( MainConfigNames::UseRCPatrol );
83  }
84 
85  public function getName() {
86  return 'mcrundo';
87  }
88 
89  public function getDescription() {
90  return '';
91  }
92 
93  public function getRestriction() {
94  // Require 'edit' permission to even see this action (T297322)
95  return 'edit';
96  }
97 
98  public function show() {
99  // Send a cookie so anons get talk message notifications
100  // (copied from SubmitAction)
102 
103  // Some stuff copied from EditAction
104  $this->useTransactionalTimeLimit();
105 
106  $out = $this->getOutput();
107  $out->setRobotPolicy( 'noindex,nofollow' );
108  if ( $this->getContext()->getConfig()->get( MainConfigNames::UseMediaWikiUIEverywhere ) ) {
109  $out->addModuleStyles( [
110  'mediawiki.ui.input',
111  'mediawiki.ui.checkbox',
112  ] );
113  }
114 
115  // IP warning headers copied from EditPage
116  // (should more be copied?)
117  if ( $this->readOnlyMode->isReadOnly() ) {
118  $out->wrapWikiMsg(
119  "<div id=\"mw-read-only-warning\">\n$1\n</div>",
120  [ 'readonlywarning', $this->readOnlyMode->getReason() ]
121  );
122  } elseif ( $this->context->getUser()->isAnon() ) {
123  // Note: EditPage has a special message for temp user creation intent here.
124  // But McrUndoAction doesn't support user creation.
125  if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
126  $out->addHTML(
127  Html::warningBox(
128  $out->msg(
129  'anoneditwarning',
130  // Log-in link
131  SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
132  'returnto' => $this->getTitle()->getPrefixedDBkey()
133  ] ),
134  // Sign-up link
135  SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
136  'returnto' => $this->getTitle()->getPrefixedDBkey()
137  ] )
138  )->parse(),
139  'mw-anon-edit-warning'
140  )
141  );
142  } else {
143  $out->addHTML(
144  Html::warningBox(
145  $out->msg( 'anonpreviewwarning' )->parse(),
146  'mw-anon-preview-warning'
147  )
148  );
149  }
150  }
151 
152  parent::show();
153  }
154 
155  protected function initFromParameters() {
156  $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
157  $this->undo = $this->getRequest()->getInt( 'undo' );
158 
159  if ( $this->undo == 0 || $this->undoafter == 0 ) {
160  throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
161  }
162 
163  $curRev = $this->getWikiPage()->getRevisionRecord();
164  if ( !$curRev ) {
165  throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
166  }
167  $this->curRev = $curRev;
168  $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
169  }
170 
171  protected function checkCanExecute( User $user ) {
172  parent::checkCanExecute( $user );
173 
174  $this->initFromParameters();
175 
176  // We use getRevisionByTitle to verify the revisions belong to this page (T297322)
177  $title = $this->getTitle();
178  $undoRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undo );
179  $oldRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undoafter );
180 
181  if ( $undoRev === null || $oldRev === null ||
182  $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
183  $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
184  ) {
185  throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
186  }
187 
188  return true;
189  }
190 
194  private function getNewRevision() {
195  $undoRev = $this->revisionLookup->getRevisionById( $this->undo );
196  $oldRev = $this->revisionLookup->getRevisionById( $this->undoafter );
198 
199  $isLatest = $curRev->getId() === $undoRev->getId();
200 
201  if ( $undoRev === null || $oldRev === null ||
202  $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
203  $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
204  ) {
205  throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
206  }
207 
208  if ( $isLatest ) {
209  // Short cut! Undoing the current revision means we just restore the old.
210  return MutableRevisionRecord::newFromParentRevision( $oldRev );
211  }
212 
213  $newRev = MutableRevisionRecord::newFromParentRevision( $curRev );
214 
215  // Figure out the roles that need merging by first collecting all roles
216  // and then removing the ones that don't.
217  $rolesToMerge = array_unique( array_merge(
218  $oldRev->getSlotRoles(),
219  $undoRev->getSlotRoles(),
221  ) );
222 
223  // Any roles with the same content in $oldRev and $undoRev can be
224  // inherited because undo won't change them.
225  $rolesToMerge = array_intersect(
226  $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
227  );
228  if ( !$rolesToMerge ) {
229  throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
230  }
231 
232  // Any roles with the same content in $oldRev and $curRev were already reverted
233  // and so can be inherited.
234  $rolesToMerge = array_intersect(
235  $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
236  );
237  if ( !$rolesToMerge ) {
238  throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
239  }
240 
241  // Any roles with the same content in $undoRev and $curRev weren't
242  // changed since and so can be reverted to $oldRev.
243  $diffRoles = array_intersect(
244  $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
245  );
246  foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
247  if ( $oldRev->hasSlot( $role ) ) {
248  $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
249  } else {
250  $newRev->removeSlot( $role );
251  }
252  }
253  $rolesToMerge = $diffRoles;
254 
255  // Any slot additions or removals not handled by the above checks can't be undone.
256  // There will be only one of the three revisions missing the slot:
257  // - !old means it was added in the undone revisions and modified after.
258  // Should it be removed entirely for the undo, or should the modified version be kept?
259  // - !undo means it was removed in the undone revisions and then readded with different content.
260  // Which content is should be kept, the old or the new?
261  // - !cur means it was changed in the undone revisions and then deleted after.
262  // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
263  // it), or should it stay gone?
264  foreach ( $rolesToMerge as $role ) {
265  if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
266  throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
267  }
268  }
269 
270  // Try to merge anything that's left.
271  foreach ( $rolesToMerge as $role ) {
272  $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
273  $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
274  $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
275  $newContent = $undoContent->getContentHandler()
276  ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
277  if ( !$newContent ) {
278  throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
279  }
280  $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
281  }
282 
283  return $newRev;
284  }
285 
286  private function generateDiffOrPreview() {
287  $newRev = $this->getNewRevision();
288  if ( $newRev->hasSameContent( $this->curRev ) ) {
289  throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
290  }
291 
292  $diffEngine = new DifferenceEngine( $this->context );
293  $diffEngine->setRevisions( $this->curRev, $newRev );
294 
295  $oldtitle = $this->context->msg( 'currentrev' )->parse();
296  $newtitle = $this->context->msg( 'yourtext' )->parse();
297 
298  if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
299  $this->showPreview( $newRev );
300  return '';
301  } else {
302  $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
303  $diffEngine->showDiffStyle();
304  return '<div id="wikiDiff">' . $diffText . '</div>';
305  }
306  }
307 
308  private function showPreview( RevisionRecord $rev ) {
309  // Mostly copied from EditPage::getPreviewText()
310  $out = $this->getOutput();
311 
312  try {
313  # provide a anchor link to the form
314  $continueEditing = '<span class="mw-continue-editing">' .
315  '[[#mw-mcrundo-form|' .
316  $this->context->getLanguage()->getArrow() . ' ' .
317  $this->context->msg( 'continue-editing' )->text() . ']]</span>';
318 
319  $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
320 
321  $parserOptions = $this->getWikiPage()->makeParserOptions( $this->context );
322  $parserOptions->setRenderReason( 'page-preview' );
323  $parserOptions->setIsPreview( true );
324  $parserOptions->setIsSectionPreview( false );
325 
326  $parserOutput = $this->revisionRenderer
327  ->getRenderedRevision( $rev, $parserOptions, $this->getAuthority() )
328  ->getRevisionParserOutput();
329  $previewHTML = $parserOutput->getText( [
330  'enableSectionEditLinks' => false,
331  'includeDebugInfo' => true,
332  ] );
333 
334  $out->addParserOutputMetadata( $parserOutput );
335  if ( count( $parserOutput->getWarnings() ) ) {
336  $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
337  }
338  } catch ( MWContentSerializationException $ex ) {
339  $m = $this->context->msg(
340  'content-failed-to-parse',
341  $ex->getMessage()
342  );
343  $note .= "\n\n" . $m->parse();
344  $previewHTML = '';
345  }
346 
347  $previewhead = Html::rawElement(
348  'div', [ 'class' => 'previewnote' ],
349  Html::element(
350  'h2', [ 'id' => 'mw-previewheader' ],
351  $this->context->msg( 'preview' )->text()
352  ) .
353  Html::warningBox(
354  $out->parseAsInterface( $note )
355  )
356  );
357 
358  $pageViewLang = $this->getTitle()->getPageViewLanguage();
359  $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(),
360  'class' => 'mw-content-' . $pageViewLang->getDir() ];
361  $previewHTML = Html::rawElement( 'div', $attribs, $previewHTML );
362 
363  $out->addHTML( $previewhead . $previewHTML );
364  }
365 
366  public function onSubmit( $data ) {
367  if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
368  // Diff or preview
369  return false;
370  }
371 
372  $updater = $this->getWikiPage()->newPageUpdater( $this->context->getUser() );
373  $curRev = $updater->grabParentRevision();
374  if ( !$curRev ) {
375  throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
376  }
377 
378  if ( $this->cur !== $curRev->getId() ) {
379  return Status::newFatal( 'mcrundo-changed' );
380  }
381 
382  $status = new PermissionStatus();
383  $this->getAuthority()->authorizeWrite( 'edit', $this->getTitle(), $status );
384  if ( !$status->isOK() ) {
385  throw new PermissionsError( 'edit', $status );
386  }
387 
388  $newRev = $this->getNewRevision();
389  if ( !$newRev->hasSameContent( $curRev ) ) {
390  $hookRunner = $this->getHookRunner();
391  foreach ( $newRev->getSlotRoles() as $slotRole ) {
392  $slot = $newRev->getSlot( $slotRole, RevisionRecord::RAW );
393 
394  $status = new Status();
395  $hookResult = $hookRunner->onEditFilterMergedContent(
396  $this->getContext(),
397  $slot->getContent(),
398  $status,
399  trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ),
400  $this->getUser(),
401  false
402  );
403 
404  if ( !$hookResult ) {
405  if ( $status->isGood() ) {
406  $status->error( 'hookaborted' );
407  }
408 
409  return $status;
410  } elseif ( !$status->isOK() ) {
411  if ( !$status->getErrors() ) {
412  $status->error( 'hookaborted' );
413  }
414  return $status;
415  }
416  }
417 
418  // Copy new slots into the PageUpdater, and remove any removed slots.
419  // TODO: This interface is awful, there should be a way to just pass $newRev.
420  // TODO: MCR: test this once we can store multiple slots
421  foreach ( $newRev->getSlots()->getSlots() as $slot ) {
422  $updater->setSlot( $slot );
423  }
424  foreach ( $curRev->getSlotRoles() as $role ) {
425  if ( !$newRev->hasSlot( $role ) ) {
426  $updater->removeSlot( $role );
427  }
428  }
429 
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(
439  CommentStoreComment::newUnsavedComment(
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'] = Xml::tags(
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 }
const EDIT_UPDATE
Definition: Defines.php:127
const EDIT_AUTOSUMMARY
Definition: Defines.php:132
getWikiPage()
Get a WikiPage object.
Definition: Action.php:200
IContextSource null $context
IContextSource if specified; otherwise we'll use the Context from the Page.
Definition: Action.php:58
getHookRunner()
Definition: Action.php:265
getTitle()
Shortcut to get the Title object from the page.
Definition: Action.php:221
getContext()
Get the IContextSource in use here.
Definition: Action.php:127
getOutput()
Get the OutputPage being used for this instance.
Definition: Action.php:151
getUser()
Shortcut to get the User being used for this instance.
Definition: Action.php:161
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition: Action.php:457
getAuthority()
Shortcut to get the Authority executing this instance.
Definition: Action.php:171
getRequest()
Get the WebRequest being used for this instance.
Definition: Action.php:141
Legacy class representing an editable page and handling UI for some page actions.
Definition: Article.php:55
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:153
setSubmitName( $name)
Definition: HTMLForm.php:1658
setSubmitTextMsg( $msg)
Set the text for the submit button to a message.
Definition: HTMLForm.php:1636
setWrapperLegendMsg( $msg)
Prompt the whole form to be wrapped in a "<fieldset>", with this message as its "<legend>" element.
Definition: HTMLForm.php:1841
setId( $id)
Definition: HTMLForm.php:1786
addButton( $data)
Add a button to the form.
Definition: HTMLForm.php:1206
setSubmitTooltip( $name)
Definition: HTMLForm.php:1669
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:1150
setCancelTarget( $target)
Sets the target where the user is redirected to after clicking cancel.
Definition: HTMLForm.php:1743
showCancel( $show=true)
Show a cancel button (or prevent it).
Definition: HTMLForm.php:1732
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:55
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.
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:46
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:71
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition: Xml.php:135
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.