includes/page/RollbackPage.php
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\Page;
use IDBAccessObject;
use ManualLogEntry;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Language\RawMessage;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Converter;
use MediaWiki\Message\Message;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\EditResult;
use MediaWiki\Title\TitleFormatter;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\ActorMigration;
use MediaWiki\User\ActorNormalization;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;
use RecentChange;
use StatusValue;
use Wikimedia\Message\MessageValue;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ReadOnlyMode;
use Wikimedia\Rdbms\SelectQueryBuilder;
/**
* Backend logic for performing a page rollback action.
*
* @since 1.37
*/
class RollbackPage {
/**
* @internal For use in PageCommandFactory only
* @var array
*/
public const CONSTRUCTOR_OPTIONS = [
MainConfigNames::UseRCPatrol,
MainConfigNames::DisableAnonTalk,
];
/** @var ServiceOptions */
private $options;
/** @var IConnectionProvider */
private $dbProvider;
/** @var UserFactory */
private $userFactory;
/** @var ReadOnlyMode */
private $readOnlyMode;
/** @var TitleFormatter */
private $titleFormatter;
/** @var RevisionStore */
private $revisionStore;
/** @var HookRunner */
private $hookRunner;
/** @var WikiPageFactory */
private $wikiPageFactory;
/** @var ActorMigration */
private $actorMigration;
/** @var ActorNormalization */
private $actorNormalization;
/** @var PageIdentity */
private $page;
/** @var Authority */
private $performer;
/** @var UserIdentity who made the edits we are rolling back */
private $byUser;
/** @var string */
private $summary = '';
/** @var bool */
private $bot = false;
/** @var string[] */
private $tags = [];
/**
* @internal Create via the RollbackPageFactory service.
* @param ServiceOptions $options
* @param IConnectionProvider $dbProvider
* @param UserFactory $userFactory
* @param ReadOnlyMode $readOnlyMode
* @param RevisionStore $revisionStore
* @param TitleFormatter $titleFormatter
* @param HookContainer $hookContainer
* @param WikiPageFactory $wikiPageFactory
* @param ActorMigration $actorMigration
* @param ActorNormalization $actorNormalization
* @param PageIdentity $page
* @param Authority $performer
* @param UserIdentity $byUser who made the edits we are rolling back
*/
public function __construct(
ServiceOptions $options,
IConnectionProvider $dbProvider,
UserFactory $userFactory,
ReadOnlyMode $readOnlyMode,
RevisionStore $revisionStore,
TitleFormatter $titleFormatter,
HookContainer $hookContainer,
WikiPageFactory $wikiPageFactory,
ActorMigration $actorMigration,
ActorNormalization $actorNormalization,
PageIdentity $page,
Authority $performer,
UserIdentity $byUser
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->dbProvider = $dbProvider;
$this->userFactory = $userFactory;
$this->readOnlyMode = $readOnlyMode;
$this->revisionStore = $revisionStore;
$this->titleFormatter = $titleFormatter;
$this->hookRunner = new HookRunner( $hookContainer );
$this->wikiPageFactory = $wikiPageFactory;
$this->actorMigration = $actorMigration;
$this->actorNormalization = $actorNormalization;
$this->page = $page;
$this->performer = $performer;
$this->byUser = $byUser;
}
/**
* Set custom edit summary.
*
* @param string|null $summary
* @return $this
*/
public function setSummary( ?string $summary ): self {
$this->summary = $summary ?? '';
return $this;
}
/**
* Mark all reverted edits as bot.
*
* @param bool|null $bot
* @return $this
*/
public function markAsBot( ?bool $bot ): self {
if ( $bot && $this->performer->isAllowedAny( 'markbotedits', 'bot' ) ) {
$this->bot = true;
} elseif ( !$bot ) {
$this->bot = false;
}
return $this;
}
/**
* Change tags to apply to the rollback.
*
* @note Callers are responsible for permission checks (with ChangeTags::canAddTagsAccompanyingChange)
*
* @param string[]|null $tags
* @return $this
*/
public function setChangeTags( ?array $tags ): self {
$this->tags = $tags ?: [];
return $this;
}
/**
* Authorize the rollback.
*
* @return PermissionStatus
*/
public function authorizeRollback(): PermissionStatus {
$permissionStatus = PermissionStatus::newEmpty();
$this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus );
$this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus );
if ( $this->readOnlyMode->isReadOnly() ) {
$permissionStatus->fatal( 'readonlytext' );
}
return $permissionStatus;
}
/**
* Rollback the most recent consecutive set of edits to a page
* from the same user; fails if there are no eligible edits to
* roll back to, e.g. user is the sole contributor. This function
* performs permissions checks and executes ::rollback.
*
* @return StatusValue see ::rollback for return value documentation.
* In case the rollback is not allowed, PermissionStatus is returned.
*/
public function rollbackIfAllowed(): StatusValue {
$permissionStatus = $this->authorizeRollback();
if ( !$permissionStatus->isGood() ) {
return $permissionStatus;
}
return $this->rollback();
}
/**
* Backend implementation of rollbackIfAllowed().
*
* @note This function does NOT check ANY permissions, it just commits the
* rollback to the DB. Therefore, you should only call this function directly
* if you want to use custom permissions checks. If you don't, use
* ::rollbackIfAllowed() instead.
*
* @return StatusValue On success, wrapping the array with the following keys:
* 'summary' - rollback edit summary
* 'current-revision-record' - revision record that was current before rollback
* 'target-revision-record' - revision record we are rolling back to
* 'newid' => the id of the rollback revision
* 'tags' => the tags applied to the rollback
*/
public function rollback() {
// Begin revision creation cycle by creating a PageUpdater.
// If the page is changed concurrently after grabParentRevision(), the rollback will fail.
// TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
$updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer );
$currentRevision = $updater->grabParentRevision();
if ( !$currentRevision ) {
// Something wrong... no page?
return StatusValue::newFatal( 'notanarticle' );
}
$currentEditor = $currentRevision->getUser( RevisionRecord::RAW );
$currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC );
// User name given should match up with the top revision.
if ( !$this->byUser->equals( $currentEditor ) ) {
$result = StatusValue::newGood( [
'current-revision-record' => $currentRevision
] );
$result->fatal(
'alreadyrolled',
htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
htmlspecialchars( $this->byUser->getName() ),
htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
);
return $result;
}
$dbw = $this->dbProvider->getPrimaryDatabase();
// Get the last edit not by this person...
// Note: these may not be public values
$actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
$queryBuilder = $this->revisionStore->newSelectQueryBuilder( $dbw )
->where( [ 'rev_page' => $currentRevision->getPageId(), 'NOT(' . $actorWhere['conds'] . ')' ] )
->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC );
$targetRevisionRow = $queryBuilder->caller( __METHOD__ )->fetchRow();
if ( $targetRevisionRow === false ) {
// No one else ever edited this page
return StatusValue::newFatal( 'cantrollback' );
} elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
|| $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
) {
// Only admins can see this text
return StatusValue::newFatal( 'notvisiblerev' );
}
// Generate the edit summary if necessary
$targetRevision = $this->revisionStore
->getRevisionById( $targetRevisionRow->rev_id, IDBAccessObject::READ_LATEST );
// Save
$flags = EDIT_UPDATE | EDIT_INTERNAL;
if ( $this->performer->isAllowed( 'minoredit' ) ) {
$flags |= EDIT_MINOR;
}
if ( $this->bot ) {
$flags |= EDIT_FORCE_BOT;
}
// TODO: MCR: also log model changes in other slots, in case that becomes possible!
$currentContent = $currentRevision->getContent( SlotRecord::MAIN );
$targetContent = $targetRevision->getContent( SlotRecord::MAIN );
$changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
// Build rollback revision:
// Restore old content
// TODO: MCR: test this once we can store multiple slots
foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
$updater->inheritSlot( $slot );
}
// Remove extra slots
// TODO: MCR: test this once we can store multiple slots
foreach ( $currentRevision->getSlotRoles() as $role ) {
if ( !$targetRevision->hasSlot( $role ) ) {
$updater->removeSlot( $role );
}
}
$updater->markAsRevert(
EditResult::REVERT_ROLLBACK,
$currentRevision->getId(),
$targetRevision->getId()
);
// TODO: this logic should not be in the storage layer, it's here for compatibility
// with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
// place the 'bot' right is handled, which is currently in EditPage::attemptSave.
if ( $this->options->get( MainConfigNames::UseRCPatrol ) &&
$this->performer->authorizeWrite( 'autopatrol', $this->page )
) {
$updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
}
$summary = $this->getSummary( $currentRevision, $targetRevision );
// Actually store the rollback
$rev = $updater->addTags( $this->tags )->saveRevision(
CommentStoreComment::newUnsavedComment( $summary ),
$flags
);
// This is done even on edit failure to have patrolling in that case (T64157).
$this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
if ( !$updater->wasSuccessful() ) {
return $updater->getStatus();
}
// Report if the edit was not created because it did not change the content.
if ( !$updater->wasRevisionCreated() ) {
$result = StatusValue::newGood( [
'current-revision-record' => $currentRevision
] );
$result->fatal(
'alreadyrolled',
htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
htmlspecialchars( $this->byUser->getName() ),
htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
);
return $result;
}
if ( $changingContentModel ) {
// If the content model changed during the rollback,
// make sure it gets logged to Special:Log/contentmodel
$log = new ManualLogEntry( 'contentmodel', 'change' );
$log->setPerformer( $this->performer->getUser() );
$log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
$log->setComment( $summary );
$log->setParameters( [
'4::oldmodel' => $currentContent->getModel(),
'5::newmodel' => $targetContent->getModel(),
] );
$logId = $log->insert( $dbw );
$log->publish( $logId );
}
$wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
$this->hookRunner->onRollbackComplete(
$wikiPage,
$this->performer->getUser(),
$targetRevision,
$currentRevision
);
return StatusValue::newGood( [
'summary' => $summary,
'current-revision-record' => $currentRevision,
'target-revision-record' => $targetRevision,
'newid' => $rev->getId(),
'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
] );
}
/**
* Set patrolling and bot flag on the edits which get rolled back.
*
* @param IDatabase $dbw
* @param RevisionRecord $current
* @param RevisionRecord $target
*/
private function updateRecentChange(
IDatabase $dbw,
RevisionRecord $current,
RevisionRecord $target
) {
$useRCPatrol = $this->options->get( MainConfigNames::UseRCPatrol );
if ( !$this->bot && !$useRCPatrol ) {
return;
}
$actorId = $this->actorNormalization
->acquireActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
$timestamp = $dbw->timestamp( $target->getTimestamp() );
$rows = $dbw->newSelectQueryBuilder()
->select( [ 'rc_id', 'rc_patrolled' ] )
->from( 'recentchanges' )
->where( [ 'rc_cur_id' => $current->getPageId(), 'rc_actor' => $actorId, ] )
->andWhere( $dbw->buildComparison( '>', [
'rc_timestamp' => $timestamp,
'rc_this_oldid' => $target->getId(),
] ) )
->caller( __METHOD__ )->fetchResultSet();
$all = [];
$patrolled = [];
$unpatrolled = [];
foreach ( $rows as $row ) {
$all[] = (int)$row->rc_id;
if ( $row->rc_patrolled ) {
$patrolled[] = (int)$row->rc_id;
} else {
$unpatrolled[] = (int)$row->rc_id;
}
}
if ( $useRCPatrol && $this->bot ) {
// Mark all reverted edits as if they were made by a bot
// Also mark only unpatrolled reverted edits as patrolled
if ( $unpatrolled ) {
$dbw->newUpdateQueryBuilder()
->update( 'recentchanges' )
->set( [ 'rc_bot' => 1, 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ] )
->where( [ 'rc_id' => $unpatrolled ] )
->caller( __METHOD__ )->execute();
}
if ( $patrolled ) {
$dbw->newUpdateQueryBuilder()
->update( 'recentchanges' )
->set( [ 'rc_bot' => 1 ] )
->where( [ 'rc_id' => $patrolled ] )
->caller( __METHOD__ )->execute();
}
} elseif ( $useRCPatrol ) {
// Mark only unpatrolled reverted edits as patrolled
if ( $unpatrolled ) {
$dbw->newUpdateQueryBuilder()
->update( 'recentchanges' )
->set( [ 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ] )
->where( [ 'rc_id' => $unpatrolled ] )
->caller( __METHOD__ )->execute();
}
} else {
// Edit is from a bot
if ( $all ) {
$dbw->newUpdateQueryBuilder()
->update( 'recentchanges' )
->set( [ 'rc_bot' => 1 ] )
->where( [ 'rc_id' => $all ] )
->caller( __METHOD__ )->execute();
}
}
}
/**
* Generate and format summary for the rollback.
*
* @param RevisionRecord $current
* @param RevisionRecord $target
* @return string
*/
private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
$revisionsBetween = $this->revisionStore->countRevisionsBetween(
$current->getPageId(),
$target,
$current,
1000,
RevisionStore::INCLUDE_NEW
);
$currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
if ( $this->summary === '' ) {
if ( !$currentEditorForPublic ) { // no public user name
$summary = MessageValue::new( 'revertpage-nouser' );
} elseif ( $this->options->get( MainConfigNames::DisableAnonTalk ) &&
!$currentEditorForPublic->isRegistered() ) {
$summary = MessageValue::new( 'revertpage-anon' );
} else {
$summary = MessageValue::new( 'revertpage' );
}
} else {
$summary = $this->summary;
}
$targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
// Allow the custom summary to use the same args as the default message
$args = [
$targetEditorForPublic ? $targetEditorForPublic->getName() : null,
$currentEditorForPublic ? $currentEditorForPublic->getName() : null,
$target->getId(),
Message::dateTimeParam( $target->getTimestamp() ),
$current->getId(),
Message::dateTimeParam( $current->getTimestamp() ),
$revisionsBetween,
];
if ( $summary instanceof MessageValue ) {
$summary = ( new Converter() )->convertMessageValue( $summary );
$summary = $summary->params( $args )->inContentLanguage()->text();
} else {
$summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
}
// Trim spaces on user supplied text
return trim( $summary );
}
}