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