Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 191 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
AutoModeratorRollback | |
0.00% |
0 / 191 |
|
0.00% |
0 / 5 |
1560 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
setSummary | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
rollback | |
0.00% |
0 / 97 |
|
0.00% |
0 / 1 |
306 | |||
updateRecentChange | |
0.00% |
0 / 48 |
|
0.00% |
0 / 1 |
156 | |||
getSummary | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
72 |
1 | <?php |
2 | |
3 | namespace AutoModerator\Services; |
4 | |
5 | use ManualLogEntry; |
6 | use MediaWiki\CommentStore\CommentStoreComment; |
7 | use MediaWiki\Config\Config; |
8 | use MediaWiki\Config\ServiceOptions; |
9 | use MediaWiki\HookContainer\HookContainer; |
10 | use MediaWiki\HookContainer\HookRunner; |
11 | use MediaWiki\Language\RawMessage; |
12 | use MediaWiki\MainConfigNames; |
13 | use MediaWiki\Message\Message; |
14 | use MediaWiki\Page\PageIdentity; |
15 | use MediaWiki\Page\WikiPageFactory; |
16 | use MediaWiki\Revision\RevisionRecord; |
17 | use MediaWiki\Revision\RevisionStore; |
18 | use MediaWiki\Revision\SlotRecord; |
19 | use MediaWiki\Storage\EditResult; |
20 | use MediaWiki\Title\TitleFormatter; |
21 | use MediaWiki\Title\TitleValue; |
22 | use MediaWiki\User\ActorMigration; |
23 | use MediaWiki\User\ActorNormalization; |
24 | use MediaWiki\User\UserIdentity; |
25 | use RecentChange; |
26 | use StatusValue; |
27 | use Wikimedia\Message\MessageValue; |
28 | use Wikimedia\Rdbms\IConnectionProvider; |
29 | use Wikimedia\Rdbms\IDatabase; |
30 | use Wikimedia\Rdbms\IDBAccessObject; |
31 | use 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 | */ |
38 | class 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 | } |