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