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\Linker\Linker;
15use MediaWiki\MainConfigNames;
16use MediaWiki\Permissions\PermissionStatus;
17use MediaWiki\Revision\MutableRevisionRecord;
18use MediaWiki\Revision\RevisionLookup;
19use MediaWiki\Revision\RevisionRecord;
20use MediaWiki\Revision\RevisionRenderer;
21use MediaWiki\Revision\SlotRecord;
22use MediaWiki\SpecialPage\SpecialPage;
23use MediaWiki\Status\Status;
24use MediaWiki\Storage\EditResult;
25use MediaWiki\User\User;
26use Wikimedia\Rdbms\ReadOnlyMode;
27
28/**
29 * Temporary action for MCR undos
30 *
31 * This is intended to go away when real MCR support is added to EditPage and
32 * the standard undo-with-edit behavior can be implemented there instead.
33 *
34 * If this were going to be kept, we'd probably want to figure out a good way
35 * to reuse the same code for generating the headers, summary box, and buttons
36 * on EditPage and here, and to better share the diffing and preview logic
37 * between the two. But doing that now would require much of the rewriting of
38 * EditPage that we're trying to put off by doing this instead.
39 *
40 * @ingroup Actions
41 * @since 1.32
42 */
43class McrUndoAction extends FormAction {
44
45    protected int $undo = 0;
46    protected int $undoafter = 0;
47    protected int $cur = 0;
48
49    /** @var RevisionRecord|null */
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
58    /**
59     * @param Article $article
60     * @param IContextSource $context
61     * @param ReadOnlyMode $readOnlyMode
62     * @param RevisionLookup $revisionLookup
63     * @param RevisionRenderer $revisionRenderer
64     * @param CommentFormatter $commentFormatter
65     * @param Config $config
66     */
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
103        $this->useTransactionalTimeLimit();
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
184    /**
185     * @return MutableRevisionRecord
186     */
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(),
213            $curRev->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' ) ?? '' ) ),
429                EDIT_AUTOSUMMARY | EDIT_UPDATE
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}