Merge ".mailmap: Correct two contributor names"
[mediawiki.git] / includes / page / RollbackPage.php
blob4619f1488dd3dfafe44ad82fbc438d6c29118ee4
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.
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.
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
18 * @file
21 namespace MediaWiki\Page;
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;
52 /**
53 * Backend logic for performing a page rollback action.
55 * @since 1.37
57 class RollbackPage {
59 /**
60 * @internal For use in PageCommandFactory only
62 public const CONSTRUCTOR_OPTIONS = [
63 MainConfigNames::UseRCPatrol,
64 MainConfigNames::DisableAnonTalk,
67 /** @var string */
68 private $summary = '';
70 /** @var bool */
71 private $bot = false;
73 /** @var string[] */
74 private $tags = [];
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;
91 /**
92 * @internal Create via the RollbackPageFactory service.
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
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;
121 $this->page = $page;
122 $this->performer = $performer;
123 $this->byUser = $byUser;
127 * Set custom edit summary.
129 * @param string|null $summary
130 * @return $this
132 public function setSummary( ?string $summary ): self {
133 $this->summary = $summary ?? '';
134 return $this;
138 * Mark all reverted edits as bot.
140 * @param bool|null $bot
141 * @return $this
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;
149 return $this;
153 * Change tags to apply to the rollback.
155 * @note Callers are responsible for permission checks (with ChangeTags::canAddTagsAccompanyingChange)
157 * @param string[]|null $tags
158 * @return $this
160 public function setChangeTags( ?array $tags ): self {
161 $this->tags = $tags ?: [];
162 return $this;
166 * Authorize the rollback.
168 * @return PermissionStatus
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 );
175 if ( $this->readOnlyMode->isReadOnly() ) {
176 $permissionStatus->fatal( 'readonlytext' );
179 return $permissionStatus;
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.
188 * @return StatusValue see ::rollback for return value documentation.
189 * In case the rollback is not allowed, PermissionStatus is returned.
191 public function rollbackIfAllowed(): StatusValue {
192 $permissionStatus = $this->authorizeRollback();
193 if ( !$permissionStatus->isGood() ) {
194 return $permissionStatus;
196 return $this->rollback();
200 * Backend implementation of rollbackIfAllowed().
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.
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
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();
221 if ( !$currentRevision ) {
222 // Something wrong... no page?
223 return StatusValue::newFatal( 'notanarticle' );
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() : '' )
239 return $result;
242 $dbw = $this->dbProvider->getPrimaryDatabase();
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();
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
259 // Only admins can see this text
260 return StatusValue::newFatal( 'notvisiblerev' );
263 // Generate the edit summary if necessary
264 $targetRevision = $this->revisionStore
265 ->getRevisionById( $targetRevisionRow->rev_id, IDBAccessObject::READ_LATEST );
267 // Save
268 $flags = EDIT_UPDATE | EDIT_INTERNAL;
270 if ( $this->performer->isAllowed( 'minoredit' ) ) {
271 $flags |= EDIT_MINOR;
274 if ( $this->bot ) {
275 $flags |= EDIT_FORCE_BOT;
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();
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 );
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 );
298 $updater->markAsRevert(
299 EditResult::REVERT_ROLLBACK,
300 $currentRevision->getId(),
301 $targetRevision->getId()
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 )
310 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
313 $summary = $this->getSummary( $currentRevision, $targetRevision );
315 // Actually store the rollback
316 $rev = $updater->addTags( $this->tags )->saveRevision(
317 CommentStoreComment::newUnsavedComment( $summary ),
318 $flags
321 // This is done even on edit failure to have patrolling in that case (T64157).
322 $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
324 if ( !$updater->wasSuccessful() ) {
325 return $updater->getStatus();
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() : '' )
339 return $result;
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 ] );
354 $logId = $log->insert( $dbw );
355 $log->publish( $logId );
358 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
360 $this->hookRunner->onRollbackComplete(
361 $wikiPage,
362 $this->performer->getUser(),
363 $targetRevision,
364 $currentRevision
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 ] );
377 * Set patrolling and bot flag on the edits which get rolled back.
379 * @param IDatabase $dbw
380 * @param RevisionRecord $current
381 * @param RevisionRecord $target
383 private function updateRecentChange(
384 IDatabase $dbw,
385 RevisionRecord $current,
386 RevisionRecord $target
388 $useRCPatrol = $this->options->get( MainConfigNames::UseRCPatrol );
389 if ( !$this->bot && !$useRCPatrol ) {
390 return;
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();
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;
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();
427 if ( $patrolled ) {
428 $dbw->newUpdateQueryBuilder()
429 ->update( 'recentchanges' )
430 ->set( [ 'rc_bot' => 1 ] )
431 ->where( [ 'rc_id' => $patrolled ] )
432 ->caller( __METHOD__ )->execute();
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();
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();
456 * Generate and format summary for the rollback.
458 * @param RevisionRecord $current
459 * @param RevisionRecord $target
460 * @return string
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
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' );
480 } else {
481 $summary = $this->summary;
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,
495 if ( $summary instanceof MessageValue ) {
496 $summary = Message::newFromSpecifier( $summary )->params( $args )->inContentLanguage()->text();
497 } else {
498 $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
501 // Trim spaces on user supplied text
502 return trim( $summary );