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            // 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' ) ?? '' ) ),
431                EDIT_AUTOSUMMARY | EDIT_UPDATE
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}