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