The RollbackComplete hook only needs a UserIdentity Change-Id: If8034bc69aae49475840758e36e0b3696bc0c8fd
516 lines
16 KiB
PHP
516 lines
16 KiB
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 ActorMigration;
|
|
use CommentStoreComment;
|
|
use ManualLogEntry;
|
|
use MediaWiki\Config\ServiceOptions;
|
|
use MediaWiki\HookContainer\HookContainer;
|
|
use MediaWiki\HookContainer\HookRunner;
|
|
use MediaWiki\Message\Converter;
|
|
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\User\ActorNormalization;
|
|
use MediaWiki\User\UserFactory;
|
|
use MediaWiki\User\UserIdentity;
|
|
use Message;
|
|
use RawMessage;
|
|
use ReadOnlyMode;
|
|
use RecentChange;
|
|
use StatusValue;
|
|
use TitleFormatter;
|
|
use TitleValue;
|
|
use Wikimedia\Message\MessageValue;
|
|
use Wikimedia\Rdbms\IDatabase;
|
|
use Wikimedia\Rdbms\ILoadBalancer;
|
|
|
|
/**
|
|
* Logic for page rollbacks.
|
|
* @since 1.37
|
|
* @package MediaWiki\Page
|
|
*/
|
|
class RollbackPage {
|
|
|
|
/**
|
|
* @internal for use in PageCommandFactory only
|
|
* @var array
|
|
*/
|
|
public const CONSTRUCTOR_OPTIONS = [
|
|
'UseRCPatrol',
|
|
'DisableAnonTalk',
|
|
];
|
|
|
|
/** @var ServiceOptions */
|
|
private $options;
|
|
|
|
/** @var ILoadBalancer */
|
|
private $loadBalancer;
|
|
|
|
/** @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 = [];
|
|
|
|
/**
|
|
* @param ServiceOptions $options
|
|
* @param ILoadBalancer $loadBalancer
|
|
* @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,
|
|
ILoadBalancer $loadBalancer,
|
|
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->loadBalancer = $loadBalancer;
|
|
$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' );
|
|
}
|
|
|
|
$user = $this->userFactory->newFromAuthority( $this->performer );
|
|
if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
|
|
$permissionStatus->fatal( 'actionthrottledtext' );
|
|
}
|
|
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->loadBalancer->getConnectionRef( DB_PRIMARY );
|
|
// T270033 Index renaming
|
|
$revIndex = $dbw->indexExists( 'revision', 'page_timestamp', __METHOD__ )
|
|
? 'page_timestamp'
|
|
: 'rev_page_timestamp';
|
|
|
|
// TODO: move this query to RevisionSelectQueryBuilder when it's available
|
|
// Get the last edit not by this person...
|
|
// Note: these may not be public values
|
|
$actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
|
|
$targetRevisionRow = $dbw->selectRow(
|
|
[ 'revision' ] + $actorWhere['tables'],
|
|
[ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
|
|
[
|
|
'rev_page' => $currentRevision->getPageId(),
|
|
'NOT(' . $actorWhere['conds'] . ')',
|
|
],
|
|
__METHOD__,
|
|
[
|
|
'USE INDEX' => [ 'revision' => $revIndex ],
|
|
'ORDER BY' => [ 'rev_timestamp DESC', 'rev_id DESC' ]
|
|
],
|
|
$actorWhere['joins']
|
|
);
|
|
|
|
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, RevisionStore::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->setOriginalRevisionId( $targetRevision->getId() );
|
|
$oldestRevertedRevision = $this->revisionStore->getNextRevision(
|
|
$targetRevision,
|
|
RevisionStore::READ_LATEST
|
|
);
|
|
if ( $oldestRevertedRevision !== null ) {
|
|
$updater->markAsRevert(
|
|
EditResult::REVERT_ROLLBACK,
|
|
$oldestRevertedRevision->getId(),
|
|
$currentRevision->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( 'UseRCPatrol' ) &&
|
|
$this->performer->authorizeWrite( 'autopatrol', $this->page )
|
|
) {
|
|
$updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
|
|
}
|
|
|
|
$summary = $this->getSummary( $currentRevision, $targetRevision );
|
|
|
|
// Actually store the rollback
|
|
$rev = $updater->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->isUnchanged() ) {
|
|
$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 gets rolled back.
|
|
*
|
|
* @param IDatabase $dbw
|
|
* @param RevisionRecord $current
|
|
* @param RevisionRecord $target
|
|
*/
|
|
private function updateRecentChange(
|
|
IDatabase $dbw,
|
|
RevisionRecord $current,
|
|
RevisionRecord $target
|
|
) {
|
|
$set = [];
|
|
if ( $this->bot ) {
|
|
// Mark all reverted edits as bot
|
|
$set['rc_bot'] = 1;
|
|
}
|
|
|
|
if ( $this->options->get( 'UseRCPatrol' ) ) {
|
|
// Mark all reverted edits as patrolled
|
|
$set['rc_patrolled'] = RecentChange::PRC_AUTOPATROLLED;
|
|
}
|
|
|
|
if ( $set ) {
|
|
$actorId = $this->actorNormalization
|
|
->acquireActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
|
|
$dbw->update(
|
|
'recentchanges',
|
|
$set,
|
|
[ /* WHERE */
|
|
'rc_cur_id' => $current->getPageId(),
|
|
'rc_timestamp > ' . $dbw->addQuotes( $dbw->timestamp( $target->getTimestamp() ) ),
|
|
'rc_actor' => $actorId
|
|
],
|
|
__METHOD__
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate and format summary for the rollback.
|
|
*
|
|
* @param RevisionRecord $current
|
|
* @param RevisionRecord $target
|
|
* @return string
|
|
*/
|
|
private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
|
|
$currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
|
|
if ( !$this->summary ) {
|
|
if ( !$currentEditorForPublic ) { // no public user name
|
|
$summary = MessageValue::new( 'revertpage-nouser' );
|
|
} elseif ( $this->options->get( '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() ),
|
|
];
|
|
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 );
|
|
}
|
|
}
|