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