Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.75% covered (success)
93.75%
195 / 208
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
RollbackPage
93.75% covered (success)
93.75%
195 / 208
66.67% covered (warning)
66.67%
6 / 9
49.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 setSummary
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 markAsBot
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 setChangeTags
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 authorizeRollback
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 rollbackIfAllowed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 rollback
95.88% covered (success)
95.88%
93 / 97
0.00% covered (danger)
0.00%
0 / 1
18
 updateRecentChange
85.42% covered (warning)
85.42%
41 / 48
0.00% covered (danger)
0.00%
0 / 1
12.45
 getSummary
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
8.02
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Page;
8
9use MediaWiki\CommentStore\CommentStoreComment;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\HookContainer\HookContainer;
12use MediaWiki\HookContainer\HookRunner;
13use MediaWiki\Language\RawMessage;
14use MediaWiki\Logging\ManualLogEntry;
15use MediaWiki\MainConfigNames;
16use MediaWiki\Message\Message;
17use MediaWiki\Permissions\Authority;
18use MediaWiki\Permissions\PermissionStatus;
19use MediaWiki\RecentChanges\RecentChange;
20use MediaWiki\Revision\RevisionRecord;
21use MediaWiki\Revision\RevisionStore;
22use MediaWiki\Revision\SlotRecord;
23use MediaWiki\Storage\EditResult;
24use MediaWiki\Storage\PageUpdateCauses;
25use MediaWiki\Title\TitleFormatter;
26use MediaWiki\Title\TitleValue;
27use MediaWiki\User\ActorMigration;
28use MediaWiki\User\ActorNormalization;
29use MediaWiki\User\UserFactory;
30use MediaWiki\User\UserIdentity;
31use StatusValue;
32use Wikimedia\Message\MessageValue;
33use Wikimedia\Rdbms\IConnectionProvider;
34use Wikimedia\Rdbms\IDatabase;
35use Wikimedia\Rdbms\IDBAccessObject;
36use Wikimedia\Rdbms\ReadOnlyMode;
37use Wikimedia\Rdbms\SelectQueryBuilder;
38
39/**
40 * Backend logic for performing a page rollback action.
41 *
42 * @since 1.37
43 */
44class RollbackPage {
45
46    /**
47     * @internal For use in PageCommandFactory only
48     */
49    public const CONSTRUCTOR_OPTIONS = [
50        MainConfigNames::UseRCPatrol,
51        MainConfigNames::DisableAnonTalk,
52    ];
53
54    /** @var string */
55    private $summary = '';
56
57    /** @var bool */
58    private $bot = false;
59
60    /** @var string[] */
61    private $tags = [];
62
63    private ServiceOptions $options;
64    private IConnectionProvider $dbProvider;
65    private UserFactory $userFactory;
66    private ReadOnlyMode $readOnlyMode;
67    private RevisionStore $revisionStore;
68    private TitleFormatter $titleFormatter;
69    private HookRunner $hookRunner;
70    private WikiPageFactory $wikiPageFactory;
71    private ActorMigration $actorMigration;
72    private ActorNormalization $actorNormalization;
73    private PageIdentity $page;
74    private Authority $performer;
75    /** @var UserIdentity who made the edits we are rolling back */
76    private UserIdentity $byUser;
77
78    /**
79     * @internal Create via the RollbackPageFactory service.
80     */
81    public function __construct(
82        ServiceOptions $options,
83        IConnectionProvider $dbProvider,
84        UserFactory $userFactory,
85        ReadOnlyMode $readOnlyMode,
86        RevisionStore $revisionStore,
87        TitleFormatter $titleFormatter,
88        HookContainer $hookContainer,
89        WikiPageFactory $wikiPageFactory,
90        ActorMigration $actorMigration,
91        ActorNormalization $actorNormalization,
92        PageIdentity $page,
93        Authority $performer,
94        UserIdentity $byUser
95    ) {
96        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
97        $this->options = $options;
98        $this->dbProvider = $dbProvider;
99        $this->userFactory = $userFactory;
100        $this->readOnlyMode = $readOnlyMode;
101        $this->revisionStore = $revisionStore;
102        $this->titleFormatter = $titleFormatter;
103        $this->hookRunner = new HookRunner( $hookContainer );
104        $this->wikiPageFactory = $wikiPageFactory;
105        $this->actorMigration = $actorMigration;
106        $this->actorNormalization = $actorNormalization;
107
108        $this->page = $page;
109        $this->performer = $performer;
110        $this->byUser = $byUser;
111    }
112
113    /**
114     * Set custom edit summary.
115     *
116     * @param string|null $summary
117     * @return $this
118     */
119    public function setSummary( ?string $summary ): self {
120        $this->summary = $summary ?? '';
121        return $this;
122    }
123
124    /**
125     * Mark all reverted edits as bot.
126     *
127     * @param bool|null $bot
128     * @return $this
129     */
130    public function markAsBot( ?bool $bot ): self {
131        if ( $bot && $this->performer->isAllowedAny( 'markbotedits', 'bot' ) ) {
132            $this->bot = true;
133        } elseif ( !$bot ) {
134            $this->bot = false;
135        }
136        return $this;
137    }
138
139    /**
140     * Change tags to apply to the rollback.
141     *
142     * @note Callers are responsible for permission checks (with ChangeTags::canAddTagsAccompanyingChange)
143     *
144     * @param string[]|null $tags
145     * @return $this
146     */
147    public function setChangeTags( ?array $tags ): self {
148        $this->tags = $tags ?? [];
149        return $this;
150    }
151
152    public function authorizeRollback(): PermissionStatus {
153        $permissionStatus = PermissionStatus::newEmpty();
154        $this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus );
155        $this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus );
156
157        if ( $this->readOnlyMode->isReadOnly() ) {
158            $permissionStatus->fatal( 'readonlytext' );
159        }
160
161        return $permissionStatus;
162    }
163
164    /**
165     * Rollback the most recent consecutive set of edits to a page
166     * from the same user; fails if there are no eligible edits to
167     * roll back to, e.g. user is the sole contributor. This function
168     * performs permissions checks and executes ::rollback.
169     *
170     * @return StatusValue see ::rollback for return value documentation.
171     *   In case the rollback is not allowed, PermissionStatus is returned.
172     */
173    public function rollbackIfAllowed(): StatusValue {
174        $permissionStatus = $this->authorizeRollback();
175        if ( !$permissionStatus->isGood() ) {
176            return $permissionStatus;
177        }
178        return $this->rollback();
179    }
180
181    /**
182     * Backend implementation of rollbackIfAllowed().
183     *
184     * @note This function does NOT check ANY permissions, it just commits the
185     * rollback to the DB. Therefore, you should only call this function directly
186     * if you want to use custom permissions checks. If you don't, use
187     * ::rollbackIfAllowed() instead.
188     *
189     * @return StatusValue On success, wrapping the array with the following keys:
190     *   'summary' - rollback edit summary
191     *   'current-revision-record' - revision record that was current before rollback
192     *   'target-revision-record' - revision record we are rolling back to
193     *   'newid' => the id of the rollback revision
194     *   'tags' => the tags applied to the rollback
195     */
196    public function rollback() {
197        // Begin revision creation cycle by creating a PageUpdater.
198        // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
199        // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
200        $updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer );
201        $currentRevision = $updater->grabParentRevision();
202
203        if ( !$currentRevision ) {
204            // Something wrong... no page?
205            return StatusValue::newFatal( 'notanarticle' );
206        }
207
208        $currentEditor = $currentRevision->getUser( RevisionRecord::RAW );
209        $currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC );
210        // User name given should match up with the top revision.
211        if ( !$this->byUser->equals( $currentEditor ) ) {
212            $result = StatusValue::newGood( [
213                'current-revision-record' => $currentRevision
214            ] );
215            $result->fatal(
216                'alreadyrolled',
217                htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
218                htmlspecialchars( $this->byUser->getName() ),
219                htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
220            );
221            return $result;
222        }
223
224        $dbw = $this->dbProvider->getPrimaryDatabase();
225
226        // Get the last edit not by this person...
227        // Note: these may not be public values
228        $actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
229        $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $dbw )
230            ->where( [ 'rev_page' => $currentRevision->getPageId(), 'NOT(' . $actorWhere['conds'] . ')' ] )
231            ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
232            ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC );
233        $targetRevisionRow = $queryBuilder->caller( __METHOD__ )->fetchRow();
234
235        if ( $targetRevisionRow === false ) {
236            // No one else ever edited this page
237            return StatusValue::newFatal( 'cantrollback' );
238        } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
239            || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
240        ) {
241            // Only admins can see this text
242            return StatusValue::newFatal( 'notvisiblerev' );
243        }
244
245        // Generate the edit summary if necessary
246        $targetRevision = $this->revisionStore
247            ->getRevisionById( $targetRevisionRow->rev_id, IDBAccessObject::READ_LATEST );
248
249        // Save
250        $flags = EDIT_UPDATE | EDIT_INTERNAL;
251
252        if ( $this->performer->isAllowed( 'minoredit' ) ) {
253            $flags |= EDIT_MINOR;
254        }
255
256        if ( $this->bot ) {
257            $flags |= EDIT_FORCE_BOT;
258        }
259
260        // TODO: MCR: also log model changes in other slots, in case that becomes possible!
261        $currentContent = $currentRevision->getContent( SlotRecord::MAIN );
262        $targetContent = $targetRevision->getContent( SlotRecord::MAIN );
263        $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
264
265        // Build rollback revision:
266        // Restore old content
267        // TODO: MCR: test this once we can store multiple slots
268        foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
269            $updater->inheritSlot( $slot );
270        }
271
272        // Remove extra slots
273        // TODO: MCR: test this once we can store multiple slots
274        foreach ( $currentRevision->getSlotRoles() as $role ) {
275            if ( !$targetRevision->hasSlot( $role ) ) {
276                $updater->removeSlot( $role );
277            }
278        }
279
280        $updater->setCause( PageUpdateCauses::CAUSE_ROLLBACK );
281        $updater->markAsRevert(
282            EditResult::REVERT_ROLLBACK,
283            $currentRevision->getId(),
284            $targetRevision->getId()
285        );
286
287        // TODO: this logic should not be in the storage layer, it's here for compatibility
288        // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
289        // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
290        if ( $this->options->get( MainConfigNames::UseRCPatrol ) &&
291            $this->performer->authorizeWrite( 'autopatrol', $this->page )
292        ) {
293            $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
294        }
295
296        $summary = $this->getSummary( $currentRevision, $targetRevision );
297
298        // Actually store the rollback
299        $rev = $updater->addTags( $this->tags )->saveRevision(
300            CommentStoreComment::newUnsavedComment( $summary ),
301            $flags
302        );
303
304        // This is done even on edit failure to have patrolling in that case (T64157).
305        $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
306
307        if ( !$updater->wasSuccessful() ) {
308            return $updater->getStatus();
309        }
310
311        // Report if the edit was not created because it did not change the content.
312        if ( !$updater->wasRevisionCreated() ) {
313            $result = StatusValue::newGood( [
314                'current-revision-record' => $currentRevision
315            ] );
316            $result->fatal(
317                'rollback-nochange',
318                htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
319                htmlspecialchars( $this->byUser->getName() ),
320                htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
321            );
322            return $result;
323        }
324
325        if ( $changingContentModel ) {
326            // If the content model changed during the rollback,
327            // make sure it gets logged to Special:Log/contentmodel
328            $log = new ManualLogEntry( 'contentmodel', 'change' );
329            $log->setPerformer( $this->performer->getUser() );
330            $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
331            $log->setComment( $summary );
332            $log->setParameters( [
333                '4::oldmodel' => $currentContent->getModel(),
334                '5::newmodel' => $targetContent->getModel(),
335            ] );
336
337            $logId = $log->insert( $dbw );
338            $log->publish( $logId );
339        }
340
341        $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
342
343        $this->hookRunner->onRollbackComplete(
344            $wikiPage,
345            $this->performer->getUser(),
346            $targetRevision,
347            $currentRevision
348        );
349
350        return StatusValue::newGood( [
351            'summary' => $summary,
352            'current-revision-record' => $currentRevision,
353            'target-revision-record' => $targetRevision,
354            'newid' => $rev->getId(),
355            'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
356        ] );
357    }
358
359    /**
360     * Set patrolling and bot flag on the edits which get rolled back.
361     *
362     * @param IDatabase $dbw
363     * @param RevisionRecord $current
364     * @param RevisionRecord $target
365     */
366    private function updateRecentChange(
367        IDatabase $dbw,
368        RevisionRecord $current,
369        RevisionRecord $target
370    ) {
371        $useRCPatrol = $this->options->get( MainConfigNames::UseRCPatrol );
372        if ( !$this->bot && !$useRCPatrol ) {
373            return;
374        }
375
376        $actorId = $this->actorNormalization->findActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
377        $timestamp = $dbw->timestamp( $target->getTimestamp() );
378        $rows = $dbw->newSelectQueryBuilder()
379            ->select( [ 'rc_id', 'rc_patrolled' ] )
380            ->from( 'recentchanges' )
381            ->where( [ 'rc_cur_id' => $current->getPageId(), 'rc_actor' => $actorId, ] )
382            ->andWhere( $dbw->buildComparison( '>', [
383                'rc_timestamp' => $timestamp,
384                'rc_this_oldid' => $target->getId(),
385            ] ) )
386            ->caller( __METHOD__ )->fetchResultSet();
387
388        $all = [];
389        $patrolled = [];
390        $unpatrolled = [];
391        foreach ( $rows as $row ) {
392            $all[] = (int)$row->rc_id;
393            if ( $row->rc_patrolled ) {
394                $patrolled[] = (int)$row->rc_id;
395            } else {
396                $unpatrolled[] = (int)$row->rc_id;
397            }
398        }
399
400        if ( $useRCPatrol && $this->bot ) {
401            // Mark all reverted edits as if they were made by a bot
402            // Also mark only unpatrolled reverted edits as patrolled
403            if ( $unpatrolled ) {
404                $dbw->newUpdateQueryBuilder()
405                    ->update( 'recentchanges' )
406                    ->set( [ 'rc_bot' => 1, 'rc_patrolled' => RecentChange::PRC_PATROLLED ] )
407                    ->where( [ 'rc_id' => $unpatrolled ] )
408                    ->caller( __METHOD__ )->execute();
409            }
410            if ( $patrolled ) {
411                $dbw->newUpdateQueryBuilder()
412                    ->update( 'recentchanges' )
413                    ->set( [ 'rc_bot' => 1 ] )
414                    ->where( [ 'rc_id' => $patrolled ] )
415                    ->caller( __METHOD__ )->execute();
416            }
417        } elseif ( $useRCPatrol ) {
418            // Mark only unpatrolled reverted edits as patrolled
419            if ( $unpatrolled ) {
420                $dbw->newUpdateQueryBuilder()
421                    ->update( 'recentchanges' )
422                    ->set( [ 'rc_patrolled' => RecentChange::PRC_PATROLLED ] )
423                    ->where( [ 'rc_id' => $unpatrolled ] )
424                    ->caller( __METHOD__ )->execute();
425            }
426        } else {
427            // Edit is from a bot
428            if ( $all ) {
429                $dbw->newUpdateQueryBuilder()
430                    ->update( 'recentchanges' )
431                    ->set( [ 'rc_bot' => 1 ] )
432                    ->where( [ 'rc_id' => $all ] )
433                    ->caller( __METHOD__ )->execute();
434            }
435        }
436    }
437
438    /**
439     * Generate and format summary for the rollback.
440     *
441     * @param RevisionRecord $current
442     * @param RevisionRecord $target
443     * @return string
444     */
445    private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
446        $revisionsBetween = $this->revisionStore->countRevisionsBetween(
447            $current->getPageId(),
448            $target,
449            $current,
450            1000,
451            RevisionStore::INCLUDE_NEW
452        );
453        $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
454        if ( $this->summary === '' ) {
455            if ( !$currentEditorForPublic ) { // no public user name
456                $summary = MessageValue::new( 'revertpage-nouser' );
457            } elseif ( $this->options->get( MainConfigNames::DisableAnonTalk ) &&
458            !$currentEditorForPublic->isRegistered() ) {
459                $summary = MessageValue::new( 'revertpage-anon' );
460            } else {
461                $summary = MessageValue::new( 'revertpage' );
462            }
463        } else {
464            $summary = $this->summary;
465        }
466
467        $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
468        // Allow the custom summary to use the same args as the default message
469        $args = [
470            $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
471            $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
472            $target->getId(),
473            Message::dateTimeParam( $target->getTimestamp() ),
474            $current->getId(),
475            Message::dateTimeParam( $current->getTimestamp() ),
476            $revisionsBetween,
477        ];
478        if ( $summary instanceof MessageValue ) {
479            $summary = Message::newFromSpecifier( $summary )->params( $args )->inContentLanguage()->text();
480        } else {
481            $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
482        }
483
484        // Trim spaces on user supplied text
485        return trim( $summary );
486    }
487}