MediaWiki 1.41.2
McrUndoAction.php
Go to the documentation of this file.
1<?php
26
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,
66 IContextSource $context,
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)
97 MediaWiki\Session\SessionManager::getGlobalSession()->persist();
98
99 // Some stuff copied from EditAction
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 );
193 $curRev = $this->curRev;
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}
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:188
getHookRunner()
Definition Action.php:253
getOutput()
Get the OutputPage being used for this instance.
Definition Action.php:139
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition Action.php:469
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.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition HTMLForm.php:158
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 Array values are discarded for security reasons (per WebRequest::get...
setCancelTarget( $target)
Sets the target where the user is redirected to after clicking cancel.
showCancel( $show=true)
Show a cancel button (or prevent it).
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.
Parent class for all special pages.
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.
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.
Determine whether a site is currently in read-only mode.
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.