Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.75% |
195 / 208 |
|
66.67% |
6 / 9 |
CRAP | |
0.00% |
0 / 1 |
RollbackPage | |
93.75% |
195 / 208 |
|
66.67% |
6 / 9 |
50.61 | |
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 | |
95.88% |
93 / 97 |
|
0.00% |
0 / 1 |
18 | |||
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 MediaWiki\CommentStore\CommentStoreComment; |
24 | use MediaWiki\Config\ServiceOptions; |
25 | use MediaWiki\HookContainer\HookContainer; |
26 | use MediaWiki\HookContainer\HookRunner; |
27 | use MediaWiki\Language\RawMessage; |
28 | use MediaWiki\Logging\ManualLogEntry; |
29 | use MediaWiki\MainConfigNames; |
30 | use MediaWiki\Message\Message; |
31 | use MediaWiki\Permissions\Authority; |
32 | use MediaWiki\Permissions\PermissionStatus; |
33 | use MediaWiki\RecentChanges\RecentChange; |
34 | use MediaWiki\Revision\RevisionRecord; |
35 | use MediaWiki\Revision\RevisionStore; |
36 | use MediaWiki\Revision\SlotRecord; |
37 | use MediaWiki\Storage\EditResult; |
38 | use MediaWiki\Storage\PageUpdateCauses; |
39 | use MediaWiki\Title\TitleFormatter; |
40 | use MediaWiki\Title\TitleValue; |
41 | use MediaWiki\User\ActorMigration; |
42 | use MediaWiki\User\ActorNormalization; |
43 | use MediaWiki\User\UserFactory; |
44 | use MediaWiki\User\UserIdentity; |
45 | use StatusValue; |
46 | use Wikimedia\Message\MessageValue; |
47 | use Wikimedia\Rdbms\IConnectionProvider; |
48 | use Wikimedia\Rdbms\IDatabase; |
49 | use Wikimedia\Rdbms\IDBAccessObject; |
50 | use Wikimedia\Rdbms\ReadOnlyMode; |
51 | use Wikimedia\Rdbms\SelectQueryBuilder; |
52 | |
53 | /** |
54 | * Backend logic for performing a page rollback action. |
55 | * |
56 | * @since 1.37 |
57 | */ |
58 | class 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 | } |