MediaWiki REL1_39
McrUndoAction.php
Go to the documentation of this file.
1<?php
16
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,
61 IContextSource $context,
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)
90 MediaWiki\Session\SessionManager::getGlobalSession()->persist();
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(
116 Html::warningBox(
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(
133 Html::warningBox(
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 );
186 $curRev = $this->curRev;
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' ],
339 Html::element(
340 'h2', [ 'id' => 'mw-previewheader' ],
341 $this->context->msg( 'preview' )->text()
342 ) .
343 Html::warningBox(
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(
429 CommentStoreComment::newUnsavedComment(
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',
460 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
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}
getUser()
getAuthority()
const EDIT_UPDATE
Definition Defines.php:127
const EDIT_AUTOSUMMARY
Definition Defines.php:132
getContext()
getWikiPage()
Get a WikiPage object.
Definition Action.php:209
getOutput()
Get the OutputPage being used for this instance.
Definition Action.php:160
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition Action.php:485
getRequest()
Get the WebRequest being used for this instance.
Definition Action.php:150
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:150
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.
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 rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition Html.php:214
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:1585
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:2299
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.
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.
A service class for fetching the wiki's current read-only mode.
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
internal since 1.36
Definition User.php:70
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