MediaWiki master
McrUndoAction.php
Go to the documentation of this file.
1<?php
28
45
46 protected int $undo = 0;
47 protected int $undoafter = 0;
48 protected int $cur = 0;
49
51 protected $curRev = null;
52
53 private ReadOnlyMode $readOnlyMode;
54 private RevisionLookup $revisionLookup;
55 private RevisionRenderer $revisionRenderer;
56 private CommentFormatter $commentFormatter;
57 private bool $useRCPatrol;
58
68 public function __construct(
69 Article $article,
70 IContextSource $context,
71 ReadOnlyMode $readOnlyMode,
72 RevisionLookup $revisionLookup,
73 RevisionRenderer $revisionRenderer,
74 CommentFormatter $commentFormatter,
75 Config $config
76 ) {
77 parent::__construct( $article, $context );
78 $this->readOnlyMode = $readOnlyMode;
79 $this->revisionLookup = $revisionLookup;
80 $this->revisionRenderer = $revisionRenderer;
81 $this->commentFormatter = $commentFormatter;
82 $this->useRCPatrol = $config->get( MainConfigNames::UseRCPatrol );
83 }
84
85 public function getName() {
86 return 'mcrundo';
87 }
88
89 public function getDescription() {
90 return '';
91 }
92
93 public function getRestriction() {
94 // Require 'edit' permission to even see this action (T297322)
95 return 'edit';
96 }
97
98 public function show() {
99 // Send a cookie so anons get talk message notifications
100 // (copied from SubmitAction)
101 MediaWiki\Session\SessionManager::getGlobalSession()->persist();
102
103 // Some stuff copied from EditAction
105
106 $out = $this->getOutput();
107 $out->setRobotPolicy( 'noindex,nofollow' );
108
109 // IP warning headers copied from EditPage
110 // (should more be copied?)
111 if ( $this->readOnlyMode->isReadOnly() ) {
112 $out->wrapWikiMsg(
113 "<div id=\"mw-read-only-warning\">\n$1\n</div>",
114 [ 'readonlywarning', $this->readOnlyMode->getReason() ]
115 );
116 } elseif ( $this->context->getUser()->isAnon() ) {
117 // Note: EditPage has a special message for temp user creation intent here.
118 // But McrUndoAction doesn't support user creation.
119 if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
120 $out->addHTML(
121 Html::warningBox(
122 $out->msg(
123 'anoneditwarning',
124 // Log-in link
125 SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
126 'returnto' => $this->getTitle()->getPrefixedDBkey()
127 ] ),
128 // Sign-up link
129 SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
130 'returnto' => $this->getTitle()->getPrefixedDBkey()
131 ] )
132 )->parse(),
133 'mw-anon-edit-warning'
134 )
135 );
136 } else {
137 $out->addHTML(
138 Html::warningBox(
139 $out->msg( 'anonpreviewwarning' )->parse(),
140 'mw-anon-preview-warning'
141 )
142 );
143 }
144 }
145
146 parent::show();
147 }
148
149 protected function initFromParameters() {
150 $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
151 $this->undo = $this->getRequest()->getInt( 'undo' );
152
153 if ( $this->undo == 0 || $this->undoafter == 0 ) {
154 throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
155 }
156
157 $curRev = $this->getWikiPage()->getRevisionRecord();
158 if ( !$curRev ) {
159 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
160 }
161 $this->curRev = $curRev;
162 $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
163 }
164
165 protected function checkCanExecute( User $user ) {
166 parent::checkCanExecute( $user );
167
168 $this->initFromParameters();
169
170 // We use getRevisionByTitle to verify the revisions belong to this page (T297322)
171 $title = $this->getTitle();
172 $undoRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undo );
173 $oldRev = $this->revisionLookup->getRevisionByTitle( $title, $this->undoafter );
174
175 if ( $undoRev === null || $oldRev === null ||
176 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
177 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
178 ) {
179 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
180 }
181
182 return true;
183 }
184
188 private function getNewRevision() {
189 $undoRev = $this->revisionLookup->getRevisionById( $this->undo );
190 $oldRev = $this->revisionLookup->getRevisionById( $this->undoafter );
191 $curRev = $this->curRev;
192
193 $isLatest = $curRev->getId() === $undoRev->getId();
194
195 if ( $undoRev === null || $oldRev === null ||
196 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
197 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
198 ) {
199 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
200 }
201
202 if ( $isLatest ) {
203 // Short cut! Undoing the current revision means we just restore the old.
204 return MutableRevisionRecord::newFromParentRevision( $oldRev );
205 }
206
207 $newRev = MutableRevisionRecord::newFromParentRevision( $curRev );
208
209 // Figure out the roles that need merging by first collecting all roles
210 // and then removing the ones that don't.
211 $rolesToMerge = array_unique( array_merge(
212 $oldRev->getSlotRoles(),
213 $undoRev->getSlotRoles(),
215 ) );
216
217 // Any roles with the same content in $oldRev and $undoRev can be
218 // inherited because undo won't change them.
219 $rolesToMerge = array_intersect(
220 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
221 );
222 if ( !$rolesToMerge ) {
223 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
224 }
225
226 // Any roles with the same content in $oldRev and $curRev were already reverted
227 // and so can be inherited.
228 $rolesToMerge = array_intersect(
229 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
230 );
231 if ( !$rolesToMerge ) {
232 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
233 }
234
235 // Any roles with the same content in $undoRev and $curRev weren't
236 // changed since and so can be reverted to $oldRev.
237 $diffRoles = array_intersect(
238 $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
239 );
240 foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
241 if ( $oldRev->hasSlot( $role ) ) {
242 $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
243 } else {
244 $newRev->removeSlot( $role );
245 }
246 }
247 $rolesToMerge = $diffRoles;
248
249 // Any slot additions or removals not handled by the above checks can't be undone.
250 // There will be only one of the three revisions missing the slot:
251 // - !old means it was added in the undone revisions and modified after.
252 // Should it be removed entirely for the undo, or should the modified version be kept?
253 // - !undo means it was removed in the undone revisions and then readded with different content.
254 // Which content is should be kept, the old or the new?
255 // - !cur means it was changed in the undone revisions and then deleted after.
256 // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
257 // it), or should it stay gone?
258 foreach ( $rolesToMerge as $role ) {
259 if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
260 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
261 }
262 }
263
264 // Try to merge anything that's left.
265 foreach ( $rolesToMerge as $role ) {
266 $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
267 $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
268 $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
269 $newContent = $undoContent->getContentHandler()
270 ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
271 if ( !$newContent ) {
272 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
273 }
274 $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
275 }
276
277 return $newRev;
278 }
279
280 private function generateDiffOrPreview() {
281 $newRev = $this->getNewRevision();
282 if ( $newRev->hasSameContent( $this->curRev ) ) {
283 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
284 }
285
286 $diffEngine = new DifferenceEngine( $this->context );
287 $diffEngine->setRevisions( $this->curRev, $newRev );
288
289 $oldtitle = $this->context->msg( 'currentrev' )->parse();
290 $newtitle = $this->context->msg( 'yourtext' )->parse();
291
292 if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
293 $this->showPreview( $newRev );
294 return '';
295 } else {
296 $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
297 $diffEngine->showDiffStyle();
298 return '<div id="wikiDiff">' . $diffText . '</div>';
299 }
300 }
301
302 private function showPreview( RevisionRecord $rev ) {
303 // Mostly copied from EditPage::getPreviewText()
304 $out = $this->getOutput();
305
306 try {
307 # provide a anchor link to the form
308 $continueEditing = '<span class="mw-continue-editing">' .
309 '[[#mw-mcrundo-form|' .
310 $this->context->getLanguage()->getArrow() . ' ' .
311 $this->context->msg( 'continue-editing' )->text() . ']]</span>';
312
313 $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
314
315 $parserOptions = $this->getWikiPage()->makeParserOptions( $this->context );
316 $parserOptions->setRenderReason( 'page-preview' );
317 $parserOptions->setIsPreview( true );
318 $parserOptions->setIsSectionPreview( false );
319
320 $parserOutput = $this->revisionRenderer
321 ->getRenderedRevision( $rev, $parserOptions, $this->getAuthority() )
322 ->getRevisionParserOutput();
323 // TODO T371004 move runOutputPipeline out of $parserOutput
324 $previewHTML = $parserOutput->runOutputPipeline( $parserOptions, [
325 'enableSectionEditLinks' => false,
326 'includeDebugInfo' => true,
327 ] )->getContentHolderText();
328
329 $out->addParserOutputMetadata( $parserOutput );
330 if ( count( $parserOutput->getWarnings() ) ) {
331 $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
332 }
333 } catch ( MWContentSerializationException $ex ) {
334 $m = $this->context->msg(
335 'content-failed-to-parse',
336 $ex->getMessage()
337 );
338 $note .= "\n\n" . $m->parse();
339 $previewHTML = '';
340 }
341
342 $previewhead = Html::rawElement(
343 'div', [ 'class' => 'previewnote' ],
344 Html::element(
345 'h2', [ 'id' => 'mw-previewheader' ],
346 $this->context->msg( 'preview' )->text()
347 ) .
348 Html::warningBox(
349 $out->parseAsInterface( $note )
350 )
351 );
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 = $this->getHookRunner();
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->getMessages() ) {
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'] = Html::rawElement(
474 'div',
475 [ 'class' => 'mw-summary-preview' ],
476 $this->commentFormatter->formatBlock(
477 trim( $request->getVal( 'wpSummary' ) ),
478 $this->getTitle(),
479 false
480 )
481 );
482 } else {
483 unset( $ret['summarypreview'] );
484 }
485
486 return $ret;
487 }
488
489 protected function alterForm( HTMLForm $form ) {
490 $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
491
492 $labelAsPublish = $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
493
494 $form->setId( 'mw-mcrundo-form' );
495 $form->setSubmitName( 'wpSave' );
496 $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
497 $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
498 $form->showCancel( true );
499 $form->setCancelTarget( $this->getTitle() );
500 $form->addButton( [
501 'name' => 'wpPreview',
502 'value' => '1',
503 'label-message' => 'showpreview',
504 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
505 ] );
506 $form->addButton( [
507 'name' => 'wpDiff',
508 'value' => '1',
509 'label-message' => 'showdiff',
510 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
511 ] );
512
513 $this->addStatePropagationFields( $form );
514 }
515
516 protected function addStatePropagationFields( HTMLForm $form ) {
517 $form->addHiddenField( 'undo', $this->undo );
518 $form->addHiddenField( 'undoafter', $this->undoafter );
519 $form->addHiddenField( 'cur', $this->curRev->getId() );
520 }
521
522 public function onSuccess() {
523 $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
524 }
525
526 protected function preText() {
527 return '<div style="clear:both"></div>';
528 }
529}
const EDIT_UPDATE
Definition Defines.php:128
const EDIT_AUTOSUMMARY
Definition Defines.php:133
getWikiPage()
Get a WikiPage object.
Definition Action.php:191
getHookRunner()
Definition Action.php:256
getContext()
Get the IContextSource in use here.
Definition Action.php:118
getOutput()
Get the OutputPage being used for this instance.
Definition Action.php:142
getUser()
Shortcut to get the User being used for this instance.
Definition Action.php:152
useTransactionalTimeLimit()
Call wfTransactionalTimeLimit() if this request was POSTed.
Definition Action.php:478
getAuthority()
Shortcut to get the Authority executing this instance.
Definition Action.php:162
getRequest()
Get the WebRequest being used for this instance.
Definition Action.php:132
Legacy class representing an editable page and handling UI for some page actions.
Definition Article.php:70
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.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition HTMLForm.php:208
setWrapperLegendMsg( $msg)
Prompt the whole form to be wrapped in a "<fieldset>", with this message as its "<legend>" element.
showCancel( $show=true)
Show a cancel button (or prevent it).
addHiddenField( $name, $value, array $attribs=[])
Add a hidden field to the output Array values are discarded for security reasons (per WebRequest::get...
setSubmitTextMsg( $msg)
Set the text for the submit button to a message.
addButton( $data)
Add a button to the form.
setCancelTarget( $target)
Sets the target where the user is redirected to after clicking cancel.
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:63
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.