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