MediaWiki master
McrUndoAction.php
Go to the documentation of this file.
1<?php
27
44
45 protected $undo = 0, $undoafter = 0, $cur = 0;
46
48 protected $curRev = null;
49
50 private ReadOnlyMode $readOnlyMode;
51 private RevisionLookup $revisionLookup;
52 private RevisionRenderer $revisionRenderer;
53 private CommentFormatter $commentFormatter;
54 private bool $useRCPatrol;
55
65 public function __construct(
66 Article $article,
67 IContextSource $context,
68 ReadOnlyMode $readOnlyMode,
69 RevisionLookup $revisionLookup,
70 RevisionRenderer $revisionRenderer,
71 CommentFormatter $commentFormatter,
72 Config $config
73 ) {
74 parent::__construct( $article, $context );
75 $this->readOnlyMode = $readOnlyMode;
76 $this->revisionLookup = $revisionLookup;
77 $this->revisionRenderer = $revisionRenderer;
78 $this->commentFormatter = $commentFormatter;
79 $this->useRCPatrol = $config->get( MainConfigNames::UseRCPatrol );
80 }
81
82 public function getName() {
83 return 'mcrundo';
84 }
85
86 public function getDescription() {
87 return '';
88 }
89
90 public function getRestriction() {
91 // Require 'edit' permission to even see this action (T297322)
92 return 'edit';
93 }
94
95 public function show() {
96 // Send a cookie so anons get talk message notifications
97 // (copied from SubmitAction)
98 MediaWiki\Session\SessionManager::getGlobalSession()->persist();
99
100 // Some stuff copied from EditAction
102
103 $out = $this->getOutput();
104 $out->setRobotPolicy( 'noindex,nofollow' );
105
106 // IP warning headers copied from EditPage
107 // (should more be copied?)
108 if ( $this->readOnlyMode->isReadOnly() ) {
109 $out->wrapWikiMsg(
110 "<div id=\"mw-read-only-warning\">\n$1\n</div>",
111 [ 'readonlywarning', $this->readOnlyMode->getReason() ]
112 );
113 } elseif ( $this->context->getUser()->isAnon() ) {
114 // Note: EditPage has a special message for temp user creation intent here.
115 // But McrUndoAction doesn't support user creation.
116 if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
117 $out->addHTML(
118 Html::warningBox(
119 $out->msg(
120 'anoneditwarning',
121 // Log-in link
122 SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
123 'returnto' => $this->getTitle()->getPrefixedDBkey()
124 ] ),
125 // Sign-up link
126 SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
127 'returnto' => $this->getTitle()->getPrefixedDBkey()
128 ] )
129 )->parse(),
130 'mw-anon-edit-warning'
131 )
132 );
133 } else {
134 $out->addHTML(
135 Html::warningBox(
136 $out->msg( 'anonpreviewwarning' )->parse(),
137 'mw-anon-preview-warning'
138 )
139 );
140 }
141 }
142
143 parent::show();
144 }
145
146 protected function initFromParameters() {
147 $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
148 $this->undo = $this->getRequest()->getInt( 'undo' );
149
150 if ( $this->undo == 0 || $this->undoafter == 0 ) {
151 throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
152 }
153
154 $curRev = $this->getWikiPage()->getRevisionRecord();
155 if ( !$curRev ) {
156 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
157 }
158 $this->curRev = $curRev;
159 $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
160 }
161
162 protected function checkCanExecute( User $user ) {
163 parent::checkCanExecute( $user );
164
165 $this->initFromParameters();
166
167 // We use getRevisionByTitle to verify the revisions belong to this page (T297322)
168 $title = $this->getTitle();
169 $undoRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undo );
170 $oldRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undoafter );
171
172 if ( $undoRev === null || $oldRev === null ||
173 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
174 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
175 ) {
176 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
177 }
178
179 return true;
180 }
181
185 private function getNewRevision() {
186 $undoRev = $this->revisionLookup->getRevisionById( $this->undo );
187 $oldRev = $this->revisionLookup->getRevisionById( $this->undoafter );
188 $curRev = $this->curRev;
189
190 $isLatest = $curRev->getId() === $undoRev->getId();
191
192 if ( $undoRev === null || $oldRev === null ||
193 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
194 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
195 ) {
196 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
197 }
198
199 if ( $isLatest ) {
200 // Short cut! Undoing the current revision means we just restore the old.
201 return MutableRevisionRecord::newFromParentRevision( $oldRev );
202 }
203
204 $newRev = MutableRevisionRecord::newFromParentRevision( $curRev );
205
206 // Figure out the roles that need merging by first collecting all roles
207 // and then removing the ones that don't.
208 $rolesToMerge = array_unique( array_merge(
209 $oldRev->getSlotRoles(),
210 $undoRev->getSlotRoles(),
212 ) );
213
214 // Any roles with the same content in $oldRev and $undoRev can be
215 // inherited because undo won't change them.
216 $rolesToMerge = array_intersect(
217 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
218 );
219 if ( !$rolesToMerge ) {
220 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
221 }
222
223 // Any roles with the same content in $oldRev and $curRev were already reverted
224 // and so can be inherited.
225 $rolesToMerge = array_intersect(
226 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
227 );
228 if ( !$rolesToMerge ) {
229 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
230 }
231
232 // Any roles with the same content in $undoRev and $curRev weren't
233 // changed since and so can be reverted to $oldRev.
234 $diffRoles = array_intersect(
235 $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
236 );
237 foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
238 if ( $oldRev->hasSlot( $role ) ) {
239 $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
240 } else {
241 $newRev->removeSlot( $role );
242 }
243 }
244 $rolesToMerge = $diffRoles;
245
246 // Any slot additions or removals not handled by the above checks can't be undone.
247 // There will be only one of the three revisions missing the slot:
248 // - !old means it was added in the undone revisions and modified after.
249 // Should it be removed entirely for the undo, or should the modified version be kept?
250 // - !undo means it was removed in the undone revisions and then readded with different content.
251 // Which content is should be kept, the old or the new?
252 // - !cur means it was changed in the undone revisions and then deleted after.
253 // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
254 // it), or should it stay gone?
255 foreach ( $rolesToMerge as $role ) {
256 if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
257 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
258 }
259 }
260
261 // Try to merge anything that's left.
262 foreach ( $rolesToMerge as $role ) {
263 $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
264 $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
265 $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
266 $newContent = $undoContent->getContentHandler()
267 ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
268 if ( !$newContent ) {
269 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
270 }
271 $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
272 }
273
274 return $newRev;
275 }
276
277 private function generateDiffOrPreview() {
278 $newRev = $this->getNewRevision();
279 if ( $newRev->hasSameContent( $this->curRev ) ) {
280 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
281 }
282
283 $diffEngine = new DifferenceEngine( $this->context );
284 $diffEngine->setRevisions( $this->curRev, $newRev );
285
286 $oldtitle = $this->context->msg( 'currentrev' )->parse();
287 $newtitle = $this->context->msg( 'yourtext' )->parse();
288
289 if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
290 $this->showPreview( $newRev );
291 return '';
292 } else {
293 $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
294 $diffEngine->showDiffStyle();
295 return '<div id="wikiDiff">' . $diffText . '</div>';
296 }
297 }
298
299 private function showPreview( RevisionRecord $rev ) {
300 // Mostly copied from EditPage::getPreviewText()
301 $out = $this->getOutput();
302
303 try {
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->setRenderReason( 'page-preview' );
314 $parserOptions->setIsPreview( true );
315 $parserOptions->setIsSectionPreview( false );
316
317 $parserOutput = $this->revisionRenderer
318 ->getRenderedRevision( $rev, $parserOptions, $this->getAuthority() )
319 ->getRevisionParserOutput();
320 $previewHTML = $parserOutput->getText( [
321 'enableSectionEditLinks' => false,
322 'includeDebugInfo' => true,
323 ] );
324
325 $out->addParserOutputMetadata( $parserOutput );
326 if ( count( $parserOutput->getWarnings() ) ) {
327 $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
328 }
329 } catch ( MWContentSerializationException $ex ) {
330 $m = $this->context->msg(
331 'content-failed-to-parse',
332 $ex->getMessage()
333 );
334 $note .= "\n\n" . $m->parse();
335 $previewHTML = '';
336 }
337
338 $previewhead = Html::rawElement(
339 'div', [ 'class' => 'previewnote' ],
340 Html::element(
341 'h2', [ 'id' => 'mw-previewheader' ],
342 $this->context->msg( 'preview' )->text()
343 ) .
344 Html::warningBox(
345 $out->parseAsInterface( $note )
346 )
347 );
348
349 $out->addHTML( $previewhead . $previewHTML );
350 }
351
352 public function onSubmit( $data ) {
353 if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
354 // Diff or preview
355 return false;
356 }
357
358 $updater = $this->getWikiPage()->newPageUpdater( $this->context->getUser() );
359 $curRev = $updater->grabParentRevision();
360 if ( !$curRev ) {
361 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
362 }
363
364 if ( $this->cur !== $curRev->getId() ) {
365 return Status::newFatal( 'mcrundo-changed' );
366 }
367
368 $status = new PermissionStatus();
369 $this->getAuthority()->authorizeWrite( 'edit', $this->getTitle(), $status );
370 if ( !$status->isOK() ) {
371 throw new PermissionsError( 'edit', $status );
372 }
373
374 $newRev = $this->getNewRevision();
375 if ( !$newRev->hasSameContent( $curRev ) ) {
376 $hookRunner = $this->getHookRunner();
377 foreach ( $newRev->getSlotRoles() as $slotRole ) {
378 $slot = $newRev->getSlot( $slotRole, RevisionRecord::RAW );
379
380 $status = new Status();
381 $hookResult = $hookRunner->onEditFilterMergedContent(
382 $this->getContext(),
383 $slot->getContent(),
384 $status,
385 trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ),
386 $this->getUser(),
387 false
388 );
389
390 if ( !$hookResult ) {
391 if ( $status->isGood() ) {
392 $status->error( 'hookaborted' );
393 }
394
395 return $status;
396 } elseif ( !$status->isOK() ) {
397 if ( !$status->getErrors() ) {
398 $status->error( 'hookaborted' );
399 }
400 return $status;
401 }
402 }
403
404 // Copy new slots into the PageUpdater, and remove any removed slots.
405 // TODO: This interface is awful, there should be a way to just pass $newRev.
406 // TODO: MCR: test this once we can store multiple slots
407 foreach ( $newRev->getSlots()->getSlots() as $slot ) {
408 $updater->setSlot( $slot );
409 }
410 foreach ( $curRev->getSlotRoles() as $role ) {
411 if ( !$newRev->hasSlot( $role ) ) {
412 $updater->removeSlot( $role );
413 }
414 }
415
416 $updater->markAsRevert( EditResult::REVERT_UNDO, $this->undo, $this->undoafter );
417
418 if ( $this->useRCPatrol && $this->getAuthority()
419 ->authorizeWrite( 'autopatrol', $this->getTitle() )
420 ) {
421 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
422 }
423
424 $updater->saveRevision(
425 CommentStoreComment::newUnsavedComment(
426 trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ) ),
428 );
429
430 return $updater->getStatus();
431 }
432
433 return Status::newGood();
434 }
435
436 protected function usesOOUI() {
437 return true;
438 }
439
440 protected function getFormFields() {
441 $request = $this->getRequest();
442 $ret = [
443 'diff' => [
444 'type' => 'info',
445 'raw' => true,
446 'default' => function () {
447 return $this->generateDiffOrPreview();
448 }
449 ],
450 'summary' => [
451 'type' => 'text',
452 'id' => 'wpSummary',
453 'name' => 'wpSummary',
454 'cssclass' => 'mw-summary',
455 'label-message' => 'summary',
456 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
457 'value' => $request->getVal( 'wpSummary', '' ),
458 'size' => 60,
459 'spellcheck' => 'true',
460 ],
461 'summarypreview' => [
462 'type' => 'info',
463 'label-message' => 'summary-preview',
464 'raw' => true,
465 ],
466 ];
467
468 if ( $request->getCheck( 'wpSummary' ) ) {
469 $ret['summarypreview']['default'] = Html::rawElement(
470 'div',
471 [ 'class' => 'mw-summary-preview' ],
472 $this->commentFormatter->formatBlock(
473 trim( $request->getVal( 'wpSummary' ) ),
474 $this->getTitle(),
475 false
476 )
477 );
478 } else {
479 unset( $ret['summarypreview'] );
480 }
481
482 return $ret;
483 }
484
485 protected function alterForm( HTMLForm $form ) {
486 $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
487
488 $labelAsPublish = $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
489
490 $form->setId( 'mw-mcrundo-form' );
491 $form->setSubmitName( 'wpSave' );
492 $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
493 $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
494 $form->showCancel( true );
495 $form->setCancelTarget( $this->getTitle() );
496 $form->addButton( [
497 'name' => 'wpPreview',
498 'value' => '1',
499 'label-message' => 'showpreview',
500 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
501 ] );
502 $form->addButton( [
503 'name' => 'wpDiff',
504 'value' => '1',
505 'label-message' => 'showdiff',
506 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
507 ] );
508
509 $this->addStatePropagationFields( $form );
510 }
511
512 protected function addStatePropagationFields( HTMLForm $form ) {
513 $form->addHiddenField( 'undo', $this->undo );
514 $form->addHiddenField( 'undoafter', $this->undoafter );
515 $form->addHiddenField( 'cur', $this->curRev->getId() );
516 }
517
518 public function onSuccess() {
519 $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
520 }
521
522 protected function preText() {
523 return '<div style="clear:both"></div>';
524 }
525}
getUser()
getRequest()
getAuthority()
const EDIT_UPDATE
Definition Defines.php:127
const EDIT_AUTOSUMMARY
Definition Defines.php:132
getContext()
getWikiPage()
Get a WikiPage object.
Definition Action.php:190
getHookRunner()
Definition Action.php:255
getOutput()
Get the OutputPage being used for this instance.
Definition Action.php:141
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition Action.php:471
Legacy class representing an editable page and handling UI for some page actions.
Definition Article.php:67
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.
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:56
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:54
Object for storing information about the effects of an edit.
internal since 1.36
Definition User.php:93
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 configuration instances.
Definition Config.php:32
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.