MediaWiki  master
McrUndoAction.php
Go to the documentation of this file.
1 <?php
12 
29 class McrUndoAction extends FormAction {
30 
31  protected $undo = 0, $undoafter = 0, $cur = 0;
32 
34  protected $curRev = null;
35 
36  public function getName() {
37  return 'mcrundo';
38  }
39 
40  public function getDescription() {
41  return '';
42  }
43 
44  public function show() {
45  // Send a cookie so anons get talk message notifications
46  // (copied from SubmitAction)
48 
49  // Some stuff copied from EditAction
51 
52  $out = $this->getOutput();
53  $out->setRobotPolicy( 'noindex,nofollow' );
54  if ( $this->getContext()->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
55  $out->addModuleStyles( [
56  'mediawiki.ui.input',
57  'mediawiki.ui.checkbox',
58  ] );
59  }
60 
61  // IP warning headers copied from EditPage
62  // (should more be copied?)
63  if ( wfReadOnly() ) {
64  $out->wrapWikiMsg(
65  "<div id=\"mw-read-only-warning\">\n$1\n</div>",
66  [ 'readonlywarning', wfReadOnlyReason() ]
67  );
68  } elseif ( $this->context->getUser()->isAnon() ) {
69  if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
70  $out->wrapWikiMsg(
71  "<div id='mw-anon-edit-warning' class='warningbox'>\n$1\n</div>",
72  [ 'anoneditwarning',
73  // Log-in link
74  SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
75  'returnto' => $this->getTitle()->getPrefixedDBkey()
76  ] ),
77  // Sign-up link
78  SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
79  'returnto' => $this->getTitle()->getPrefixedDBkey()
80  ] )
81  ]
82  );
83  } else {
84  $out->wrapWikiMsg( "<div id=\"mw-anon-preview-warning\" class=\"warningbox\">\n$1</div>",
85  'anonpreviewwarning'
86  );
87  }
88  }
89 
90  parent::show();
91  }
92 
93  protected function initFromParameters() {
94  $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
95  $this->undo = $this->getRequest()->getInt( 'undo' );
96 
97  if ( $this->undo == 0 || $this->undoafter == 0 ) {
98  throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
99  }
100 
101  $curRev = $this->page->getRevision();
102  if ( !$curRev ) {
103  throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
104  }
105  $this->curRev = $curRev->getRevisionRecord();
106  $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
107  }
108 
109  protected function checkCanExecute( User $user ) {
110  parent::checkCanExecute( $user );
111 
112  $this->initFromParameters();
113 
114  $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
115 
116  $undoRev = $revisionLookup->getRevisionById( $this->undo );
117  $oldRev = $revisionLookup->getRevisionById( $this->undoafter );
118 
119  if ( $undoRev === null || $oldRev === null ||
120  $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
121  $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
122  ) {
123  throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
124  }
125 
126  return true;
127  }
128 
132  private function getNewRevision() {
133  $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
134 
135  $undoRev = $revisionLookup->getRevisionById( $this->undo );
136  $oldRev = $revisionLookup->getRevisionById( $this->undoafter );
138 
139  $isLatest = $curRev->getId() === $undoRev->getId();
140 
141  if ( $undoRev === null || $oldRev === null ||
142  $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
143  $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
144  ) {
145  throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
146  }
147 
148  if ( $isLatest ) {
149  // Short cut! Undoing the current revision means we just restore the old.
150  return MutableRevisionRecord::newFromParentRevision( $oldRev );
151  }
152 
153  $newRev = MutableRevisionRecord::newFromParentRevision( $curRev );
154 
155  // Figure out the roles that need merging by first collecting all roles
156  // and then removing the ones that don't.
157  $rolesToMerge = array_unique( array_merge(
158  $oldRev->getSlotRoles(),
159  $undoRev->getSlotRoles(),
161  ) );
162 
163  // Any roles with the same content in $oldRev and $undoRev can be
164  // inherited because undo won't change them.
165  $rolesToMerge = array_intersect(
166  $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
167  );
168  if ( !$rolesToMerge ) {
169  throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
170  }
171 
172  // Any roles with the same content in $oldRev and $curRev were already reverted
173  // and so can be inherited.
174  $rolesToMerge = array_intersect(
175  $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
176  );
177  if ( !$rolesToMerge ) {
178  throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
179  }
180 
181  // Any roles with the same content in $undoRev and $curRev weren't
182  // changed since and so can be reverted to $oldRev.
183  $diffRoles = array_intersect(
184  $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
185  );
186  foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
187  if ( $oldRev->hasSlot( $role ) ) {
188  $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
189  } else {
190  $newRev->removeSlot( $role );
191  }
192  }
193  $rolesToMerge = $diffRoles;
194 
195  // Any slot additions or removals not handled by the above checks can't be undone.
196  // There will be only one of the three revisions missing the slot:
197  // - !old means it was added in the undone revisions and modified after.
198  // Should it be removed entirely for the undo, or should the modified version be kept?
199  // - !undo means it was removed in the undone revisions and then readded with different content.
200  // Which content is should be kept, the old or the new?
201  // - !cur means it was changed in the undone revisions and then deleted after.
202  // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
203  // it), or should it stay gone?
204  foreach ( $rolesToMerge as $role ) {
205  if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
206  throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
207  }
208  }
209 
210  // Try to merge anything that's left.
211  foreach ( $rolesToMerge as $role ) {
212  $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
213  $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
214  $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
215  $newContent = $undoContent->getContentHandler()
216  ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
217  if ( !$newContent ) {
218  throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
219  }
220  $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
221  }
222 
223  return $newRev;
224  }
225 
226  private function generateDiffOrPreview() {
227  $newRev = $this->getNewRevision();
228  if ( $newRev->hasSameContent( $this->curRev ) ) {
229  throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
230  }
231 
232  $diffEngine = new DifferenceEngine( $this->context );
233  $diffEngine->setRevisions( $this->curRev, $newRev );
234 
235  $oldtitle = $this->context->msg( 'currentrev' )->parse();
236  $newtitle = $this->context->msg( 'yourtext' )->parse();
237 
238  if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
239  $this->showPreview( $newRev );
240  return '';
241  } else {
242  $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
243  $diffEngine->showDiffStyle();
244  return '<div id="wikiDiff">' . $diffText . '</div>';
245  }
246  }
247 
248  private function showPreview( RevisionRecord $rev ) {
249  // Mostly copied from EditPage::getPreviewText()
250  $out = $this->getOutput();
251 
252  try {
253  $previewHTML = '';
254 
255  # provide a anchor link to the form
256  $continueEditing = '<span class="mw-continue-editing">' .
257  '[[#mw-mcrundo-form|' .
258  $this->context->getLanguage()->getArrow() . ' ' .
259  $this->context->msg( 'continue-editing' )->text() . ']]</span>';
260 
261  $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
262 
263  $parserOptions = $this->page->makeParserOptions( $this->context );
264  $parserOptions->setIsPreview( true );
265  $parserOptions->setIsSectionPreview( false );
266  $parserOptions->enableLimitReport();
267 
268  $parserOutput = MediaWikiServices::getInstance()->getRevisionRenderer()
269  ->getRenderedRevision( $rev, $parserOptions, $this->context->getUser() )
270  ->getRevisionParserOutput();
271  $previewHTML = $parserOutput->getText( [ 'enableSectionEditLinks' => false ] );
272 
273  $out->addParserOutputMetadata( $parserOutput );
274  if ( count( $parserOutput->getWarnings() ) ) {
275  $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
276  }
277  } catch ( MWContentSerializationException $ex ) {
278  $m = $this->context->msg(
279  'content-failed-to-parse',
280  $ex->getMessage()
281  );
282  $note .= "\n\n" . $m->parse();
283  $previewHTML = '';
284  }
285 
286  $previewhead = Html::rawElement(
287  'div', [ 'class' => 'previewnote' ],
289  'h2', [ 'id' => 'mw-previewheader' ],
290  $this->context->msg( 'preview' )->text()
291  ) .
292  Html::rawElement( 'div', [ 'class' => 'warningbox' ],
293  $out->parseAsInterface( $note )
294  )
295  );
296 
297  $pageViewLang = $this->getTitle()->getPageViewLanguage();
298  $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(),
299  'class' => 'mw-content-' . $pageViewLang->getDir() ];
300  $previewHTML = Html::rawElement( 'div', $attribs, $previewHTML );
301 
302  $out->addHTML( $previewhead . $previewHTML );
303  }
304 
305  public function onSubmit( $data ) {
306  global $wgUseRCPatrol;
307 
308  if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
309  // Diff or preview
310  return false;
311  }
312 
313  $updater = $this->page->getPage()->newPageUpdater( $this->context->getUser() );
314  $curRev = $updater->grabParentRevision();
315  if ( !$curRev ) {
316  throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
317  }
318 
319  if ( $this->cur !== $curRev->getId() ) {
320  return Status::newFatal( 'mcrundo-changed' );
321  }
322 
323  $newRev = $this->getNewRevision();
324  if ( !$newRev->hasSameContent( $curRev ) ) {
325  // Copy new slots into the PageUpdater, and remove any removed slots.
326  // TODO: This interface is awful, there should be a way to just pass $newRev.
327  // TODO: MCR: test this once we can store multiple slots
328  foreach ( $newRev->getSlots()->getSlots() as $slot ) {
329  $updater->setSlot( $slot );
330  }
331  foreach ( $curRev->getSlotRoles() as $role ) {
332  if ( !$newRev->hasSlot( $role ) ) {
333  $updater->removeSlot( $role );
334  }
335  }
336 
337  $updater->setOriginalRevisionId( false );
338  $updater->setUndidRevisionId( $this->undo );
339 
340  $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
341 
342  // TODO: Ugh.
343  if ( $wgUseRCPatrol && $permissionManager->userCan(
344  'autopatrol',
345  $this->getUser(),
346  $this->getTitle() )
347  ) {
348  $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
349  }
350 
351  $updater->saveRevision(
352  CommentStoreComment::newUnsavedComment( trim( $this->getRequest()->getVal( 'wpSummary' ) ) ),
354  );
355 
356  return $updater->getStatus();
357  }
358 
359  return Status::newGood();
360  }
361 
362  protected function usesOOUI() {
363  return true;
364  }
365 
366  protected function getFormFields() {
367  $request = $this->getRequest();
368  $ret = [
369  'diff' => [
370  'type' => 'info',
371  'vertical-label' => true,
372  'raw' => true,
373  'default' => function () {
374  return $this->generateDiffOrPreview();
375  }
376  ],
377  'summary' => [
378  'type' => 'text',
379  'id' => 'wpSummary',
380  'name' => 'wpSummary',
381  'cssclass' => 'mw-summary',
382  'label-message' => 'summary',
384  'value' => $request->getVal( 'wpSummary', '' ),
385  'size' => 60,
386  'spellcheck' => 'true',
387  ],
388  'summarypreview' => [
389  'type' => 'info',
390  'label-message' => 'summary-preview',
391  'raw' => true,
392  ],
393  ];
394 
395  if ( $request->getCheck( 'wpSummary' ) ) {
396  $ret['summarypreview']['default'] = Xml::tags( 'div', [ 'class' => 'mw-summary-preview' ],
397  Linker::commentBlock( trim( $request->getVal( 'wpSummary' ) ), $this->getTitle(), false )
398  );
399  } else {
400  unset( $ret['summarypreview'] );
401  }
402 
403  return $ret;
404  }
405 
406  protected function alterForm( HTMLForm $form ) {
407  $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
408 
409  $labelAsPublish = $this->context->getConfig()->get( 'EditSubmitButtonLabelPublish' );
410 
411  $form->setId( 'mw-mcrundo-form' );
412  $form->setSubmitName( 'wpSave' );
413  $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
414  $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
415  $form->showCancel( true );
416  $form->setCancelTarget( $this->getTitle() );
417  $form->addButton( [
418  'name' => 'wpPreview',
419  'value' => '1',
420  'label-message' => 'showpreview',
421  'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
422  ] );
423  $form->addButton( [
424  'name' => 'wpDiff',
425  'value' => '1',
426  'label-message' => 'showdiff',
427  'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
428  ] );
429 
430  $this->addStatePropagationFields( $form );
431  }
432 
433  protected function addStatePropagationFields( HTMLForm $form ) {
434  $form->addHiddenField( 'undo', $this->undo );
435  $form->addHiddenField( 'undoafter', $this->undoafter );
436  $form->addHiddenField( 'cur', $this->curRev->getId() );
437  }
438 
439  public function onSuccess() {
440  $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
441  }
442 
443  protected function preText() {
444  return '<div style="clear:both"></div>';
445  }
446 }
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:69
DifferenceEngine is responsible for rendering the difference between two revisions as HTML...
getOutput()
Get the OutputPage being used for this instance.
Definition: Action.php:208
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:231
getTitle()
Shortcut to get the Title object from the page.
Definition: Action.php:247
setSubmitName( $name)
Definition: HTMLForm.php:1414
showCancel( $show=true)
Show a cancel button (or prevent it).
Definition: HTMLForm.php:1488
addStatePropagationFields(HTMLForm $form)
getSlots()
Returns the slots defined for this revision.
setSubmitTooltip( $name)
Definition: HTMLForm.php:1425
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:209
addButton( $data)
Add a button to the form.
Definition: HTMLForm.php:995
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
const EDIT_UPDATE
Definition: Defines.php:133
hasSlot( $role)
Returns whether the given slot is defined in this revision.
static newUnsavedComment( $comment, array $data=null)
Create a new, unsaved CommentStoreComment.
showPreview(RevisionRecord $rev)
setId( $id)
Definition: HTMLForm.php:1524
The User object encapsulates all of the user-specific settings (user_id, name, rights, email address, options, last login time).
Definition: User.php:51
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition: Action.php:422
An action which shows a form and does something based on the input from the form. ...
Definition: FormAction.php:28
setCancelTarget( $target)
Sets the target where the user is redirected to after clicking cancel.
Definition: HTMLForm.php:1499
addHiddenField( $name, $value, array $attribs=[])
Add a hidden field to the output.
Definition: HTMLForm.php:943
wfReadOnly()
Check whether the wiki is in read-only mode.
getSlotRoles()
Returns the slot names (roles) of all slots present in this revision.
An error page which can definitely be safely rendered using the OutputPage.
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:81
alterForm(HTMLForm $form)
const EDIT_AUTOSUMMARY
Definition: Defines.php:138
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition: Xml.php:130
getContext()
Get the IContextSource in use here.
Definition: Action.php:179
$wgUseRCPatrol
Use RC Patrolling to check for vandalism (from recent changes and watchlists) New pages and new files...
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don&#39;t need a full Title object...
Definition: SpecialPage.php:83
RevisionRecord null $curRev
static getGlobalSession()
Get the "global" session.
getSlot( $role, $audience=self::FOR_PUBLIC, User $user=null)
Returns meta-data for the given slot.
getId()
Get revision ID.
wfReadOnlyReason()
Check if the site is in read-only mode and return the message if so.
checkCanExecute(User $user)
Temporary action for MCR undos.
setSubmitTextMsg( $msg)
Set the text for the submit button to a message.
Definition: HTMLForm.php:1392
Page revision base class.
getUser( $audience=self::FOR_PUBLIC, User $user=null)
Fetch revision&#39;s author&#39;s user identity, if it&#39;s available to the specified audience.
setWrapperLegendMsg( $msg)
Prompt the whole form to be wrapped in a "<fieldset>", with this message as its "<legend>" element...
Definition: HTMLForm.php:1579
static tooltipAndAccesskeyAttribs( $name, array $msgParams=[], $options=null)
Returns the attributes for the tooltip and access key.
Definition: Linker.php:2190
getRequest()
Get the WebRequest being used for this instance.
Definition: Action.php:198
const PRC_AUTOPATROLLED
static commentBlock( $comment, $title=null, $local=false, $wikiId=null, $useParentheses=true)
Wrap a comment in standard punctuation and formatting if it&#39;s non-empty, otherwise return empty strin...
Definition: Linker.php:1542
Exception representing a failure to serialize or unserialize a content object.