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