Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.63% covered (danger)
36.63%
100 / 273
11.76% covered (danger)
11.76%
2 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
McrUndoAction
36.63% covered (danger)
36.63%
100 / 273
11.76% covered (danger)
11.76%
2 / 17
1106.34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRestriction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 show
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
20
 initFromParameters
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 checkCanExecute
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 getNewRevision
82.98% covered (warning)
82.98%
39 / 47
0.00% covered (danger)
0.00%
0 / 1
17.26
 generateDiffOrPreview
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 showPreview
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
12
 onSubmit
76.00% covered (warning)
76.00%
38 / 50
0.00% covered (danger)
0.00%
0 / 1
19.54
 usesOOUI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFormFields
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
6
 alterForm
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 addStatePropagationFields
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onSuccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 preText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Temporary action for MCR undos
4 * @file
5 * @ingroup Actions
6 */
7
8use MediaWiki\CommentFormatter\CommentFormatter;
9use MediaWiki\CommentStore\CommentStore;
10use MediaWiki\CommentStore\CommentStoreComment;
11use MediaWiki\Config\Config;
12use MediaWiki\Context\IContextSource;
13use MediaWiki\Html\Html;
14use MediaWiki\HTMLForm\HTMLForm;
15use MediaWiki\Linker\Linker;
16use MediaWiki\MainConfigNames;
17use MediaWiki\Permissions\PermissionStatus;
18use MediaWiki\Revision\MutableRevisionRecord;
19use MediaWiki\Revision\RevisionLookup;
20use MediaWiki\Revision\RevisionRecord;
21use MediaWiki\Revision\RevisionRenderer;
22use MediaWiki\Revision\SlotRecord;
23use MediaWiki\SpecialPage\SpecialPage;
24use MediaWiki\Status\Status;
25use MediaWiki\Storage\EditResult;
26use MediaWiki\User\User;
27use Wikimedia\Rdbms\ReadOnlyMode;
28
29/**
30 * Temporary action for MCR undos
31 *
32 * This is intended to go away when real MCR support is added to EditPage and
33 * the standard undo-with-edit behavior can be implemented there instead.
34 *
35 * If this were going to be kept, we'd probably want to figure out a good way
36 * to reuse the same code for generating the headers, summary box, and buttons
37 * on EditPage and here, and to better share the diffing and preview logic
38 * between the two. But doing that now would require much of the rewriting of
39 * EditPage that we're trying to put off by doing this instead.
40 *
41 * @ingroup Actions
42 * @since 1.32
43 */
44class McrUndoAction extends FormAction {
45
46    protected int $undo = 0;
47    protected int $undoafter = 0;
48    protected int $cur = 0;
49
50    /** @var RevisionRecord|null */
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
59    /**
60     * @param Article $article
61     * @param IContextSource $context
62     * @param ReadOnlyMode $readOnlyMode
63     * @param RevisionLookup $revisionLookup
64     * @param RevisionRenderer $revisionRenderer
65     * @param CommentFormatter $commentFormatter
66     * @param Config $config
67     */
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
104        $this->useTransactionalTimeLimit();
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
185    /**
186     * @return MutableRevisionRecord
187     */
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(),
214            $curRev->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            $previewHTML = $parserOutput->getText( [
324                'enableSectionEditLinks' => false,
325                'includeDebugInfo' => true,
326            ] );
327
328            $out->addParserOutputMetadata( $parserOutput );
329            if ( count( $parserOutput->getWarnings() ) ) {
330                $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
331            }
332        } catch ( MWContentSerializationException $ex ) {
333            $m = $this->context->msg(
334                'content-failed-to-parse',
335                $ex->getMessage()
336            );
337            $note .= "\n\n" . $m->parse();
338            $previewHTML = '';
339        }
340
341        $previewhead = Html::rawElement(
342            'div', [ 'class' => 'previewnote' ],
343            Html::element(
344                'h2', [ 'id' => 'mw-previewheader' ],
345                $this->context->msg( 'preview' )->text()
346            ) .
347            Html::warningBox(
348                $out->parseAsInterface( $note )
349            )
350        );
351
352        $out->addHTML( $previewhead . $previewHTML );
353    }
354
355    public function onSubmit( $data ) {
356        if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
357            // Diff or preview
358            return false;
359        }
360
361        $updater = $this->getWikiPage()->newPageUpdater( $this->context->getUser() );
362        $curRev = $updater->grabParentRevision();
363        if ( !$curRev ) {
364            throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
365        }
366
367        if ( $this->cur !== $curRev->getId() ) {
368            return Status::newFatal( 'mcrundo-changed' );
369        }
370
371        $status = new PermissionStatus();
372        $this->getAuthority()->authorizeWrite( 'edit', $this->getTitle(), $status );
373        if ( !$status->isOK() ) {
374            throw new PermissionsError( 'edit', $status );
375        }
376
377        $newRev = $this->getNewRevision();
378        if ( !$newRev->hasSameContent( $curRev ) ) {
379            $hookRunner = $this->getHookRunner();
380            foreach ( $newRev->getSlotRoles() as $slotRole ) {
381                $slot = $newRev->getSlot( $slotRole, RevisionRecord::RAW );
382
383                $status = new Status();
384                $hookResult = $hookRunner->onEditFilterMergedContent(
385                    $this->getContext(),
386                    $slot->getContent(),
387                    $status,
388                    trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ),
389                    $this->getUser(),
390                    false
391                );
392
393                if ( !$hookResult ) {
394                    if ( $status->isGood() ) {
395                        $status->error( 'hookaborted' );
396                    }
397
398                    return $status;
399                } elseif ( !$status->isOK() ) {
400                    if ( !$status->getMessages() ) {
401                        $status->error( 'hookaborted' );
402                    }
403                    return $status;
404                }
405            }
406
407            // Copy new slots into the PageUpdater, and remove any removed slots.
408            // TODO: This interface is awful, there should be a way to just pass $newRev.
409            // TODO: MCR: test this once we can store multiple slots
410            foreach ( $newRev->getSlots()->getSlots() as $slot ) {
411                $updater->setSlot( $slot );
412            }
413            foreach ( $curRev->getSlotRoles() as $role ) {
414                if ( !$newRev->hasSlot( $role ) ) {
415                    $updater->removeSlot( $role );
416                }
417            }
418
419            $updater->markAsRevert( EditResult::REVERT_UNDO, $this->undo, $this->undoafter );
420
421            if ( $this->useRCPatrol && $this->getAuthority()
422                    ->authorizeWrite( 'autopatrol', $this->getTitle() )
423            ) {
424                $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
425            }
426
427            $updater->saveRevision(
428                CommentStoreComment::newUnsavedComment(
429                    trim( $this->getRequest()->getVal( 'wpSummary' ) ?? '' ) ),
430                EDIT_AUTOSUMMARY | EDIT_UPDATE
431            );
432
433            return $updater->getStatus();
434        }
435
436        return Status::newGood();
437    }
438
439    protected function usesOOUI() {
440        return true;
441    }
442
443    protected function getFormFields() {
444        $request = $this->getRequest();
445        $ret = [
446            'diff' => [
447                'type' => 'info',
448                'raw' => true,
449                'default' => function () {
450                    return $this->generateDiffOrPreview();
451                }
452            ],
453            'summary' => [
454                'type' => 'text',
455                'id' => 'wpSummary',
456                'name' => 'wpSummary',
457                'cssclass' => 'mw-summary',
458                'label-message' => 'summary',
459                'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
460                'value' => $request->getVal( 'wpSummary', '' ),
461                'size' => 60,
462                'spellcheck' => 'true',
463            ],
464            'summarypreview' => [
465                'type' => 'info',
466                'label-message' => 'summary-preview',
467                'raw' => true,
468            ],
469        ];
470
471        if ( $request->getCheck( 'wpSummary' ) ) {
472            $ret['summarypreview']['default'] = Html::rawElement(
473                'div',
474                [ 'class' => 'mw-summary-preview' ],
475                $this->commentFormatter->formatBlock(
476                    trim( $request->getVal( 'wpSummary' ) ),
477                    $this->getTitle(),
478                    false
479                )
480            );
481        } else {
482            unset( $ret['summarypreview'] );
483        }
484
485        return $ret;
486    }
487
488    protected function alterForm( HTMLForm $form ) {
489        $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
490
491        $labelAsPublish = $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
492
493        $form->setId( 'mw-mcrundo-form' );
494        $form->setSubmitName( 'wpSave' );
495        $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
496        $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
497        $form->showCancel( true );
498        $form->setCancelTarget( $this->getTitle() );
499        $form->addButton( [
500            'name' => 'wpPreview',
501            'value' => '1',
502            'label-message' => 'showpreview',
503            'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
504        ] );
505        $form->addButton( [
506            'name' => 'wpDiff',
507            'value' => '1',
508            'label-message' => 'showdiff',
509            'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
510        ] );
511
512        $this->addStatePropagationFields( $form );
513    }
514
515    protected function addStatePropagationFields( HTMLForm $form ) {
516        $form->addHiddenField( 'undo', $this->undo );
517        $form->addHiddenField( 'undoafter', $this->undoafter );
518        $form->addHiddenField( 'cur', $this->curRev->getId() );
519    }
520
521    public function onSuccess() {
522        $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
523    }
524
525    protected function preText() {
526        return '<div style="clear:both"></div>';
527    }
528}