Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
88.89% |
184 / 207 |
|
66.67% |
6 / 9 |
CRAP | |
0.00% |
0 / 1 |
RollbackPage | |
88.89% |
184 / 207 |
|
66.67% |
6 / 9 |
53.43 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
setSummary | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
markAsBot | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
setChangeTags | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
authorizeRollback | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
rollbackIfAllowed | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
rollback | |
85.42% |
82 / 96 |
|
0.00% |
0 / 1 |
19.00 | |||
updateRecentChange | |
85.42% |
41 / 48 |
|
0.00% |
0 / 1 |
12.45 | |||
getSummary | |
93.33% |
28 / 30 |
|
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 | |
21 | namespace MediaWiki\Page; |
22 | |
23 | use ManualLogEntry; |
24 | use MediaWiki\CommentStore\CommentStoreComment; |
25 | use MediaWiki\Config\ServiceOptions; |
26 | use MediaWiki\HookContainer\HookContainer; |
27 | use MediaWiki\HookContainer\HookRunner; |
28 | use MediaWiki\Language\RawMessage; |
29 | use MediaWiki\MainConfigNames; |
30 | use MediaWiki\Message\Message; |
31 | use MediaWiki\Permissions\Authority; |
32 | use MediaWiki\Permissions\PermissionStatus; |
33 | use MediaWiki\Revision\RevisionRecord; |
34 | use MediaWiki\Revision\RevisionStore; |
35 | use MediaWiki\Revision\SlotRecord; |
36 | use MediaWiki\Storage\EditResult; |
37 | use MediaWiki\Title\TitleFormatter; |
38 | use MediaWiki\Title\TitleValue; |
39 | use MediaWiki\User\ActorMigration; |
40 | use MediaWiki\User\ActorNormalization; |
41 | use MediaWiki\User\UserFactory; |
42 | use MediaWiki\User\UserIdentity; |
43 | use RecentChange; |
44 | use StatusValue; |
45 | use Wikimedia\Message\MessageValue; |
46 | use Wikimedia\Rdbms\IConnectionProvider; |
47 | use Wikimedia\Rdbms\IDatabase; |
48 | use Wikimedia\Rdbms\IDBAccessObject; |
49 | use Wikimedia\Rdbms\ReadOnlyMode; |
50 | use Wikimedia\Rdbms\SelectQueryBuilder; |
51 | |
52 | /** |
53 | * Backend logic for performing a page rollback action. |
54 | * |
55 | * @since 1.37 |
56 | */ |
57 | class 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 | } |