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