Factor out rollback logic from WikiPage

Change-Id: I95da91875fcf2f53143c315560e35ccd5ffbf4b3
This commit is contained in:
Petr Pchelko 2021-03-26 16:56:39 -06:00
parent 817a3c203b
commit 46db19ecdf
19 changed files with 1156 additions and 660 deletions

View file

@ -58,6 +58,7 @@ use MediaWiki\Page\MovePageFactory;
use MediaWiki\Page\PageStore;
use MediaWiki\Page\PageStoreFactory;
use MediaWiki\Page\ParserOutputAccess;
use MediaWiki\Page\RollbackPageFactory;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Parser\ParserCacheFactory;
use MediaWiki\Permissions\GroupPermissionsLookup;
@ -1328,6 +1329,14 @@ class MediaWikiServices extends ServiceContainer {
return $this->getService( 'RevisionStoreFactory' );
}
/**
* @since 1.37
* @return RollbackPageFactory
*/
public function getRollbackPageFactory() : RollbackPageFactory {
return $this->getService( 'RollbackPageFactory' );
}
/**
* @since 1.27
* @return SearchEngine

View file

@ -117,7 +117,7 @@ class MovePage {
private $userFactory;
/**
* @internal For use by MovePageTest
* @internal For use by PageCommandFactory
*/
public const CONSTRUCTOR_OPTIONS = [
'CategoryCollation',

View file

@ -91,6 +91,7 @@ use MediaWiki\Page\PageCommandFactory;
use MediaWiki\Page\PageStore;
use MediaWiki\Page\PageStoreFactory;
use MediaWiki\Page\ParserOutputAccess;
use MediaWiki\Page\RollbackPageFactory;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Parser\ParserCacheFactory;
use MediaWiki\Permissions\GroupPermissionsLookup;
@ -1262,6 +1263,10 @@ return [
return $store;
},
'RollbackPageFactory' => static function ( MediaWikiServices $services ) : RollbackPageFactory {
return $services->get( '_PageCommandFactory' );
},
'SearchEngineConfig' => static function ( MediaWikiServices $services ) : SearchEngineConfig {
// @todo This should not take a Config object, but it's not so easy to remove because it
// exposes it in a getter, which is actually used.
@ -1702,17 +1707,20 @@ return [
'_PageCommandFactory' => static function ( MediaWikiServices $services ) : PageCommandFactory {
return new PageCommandFactory(
new ServiceOptions( PageCommandFactory::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ),
$services->getMainConfig(),
$services->getDBLoadBalancer(),
$services->getNamespaceInfo(),
$services->getWatchedItemStore(),
$services->getRepoGroup(),
$services->getReadOnlyMode(),
$services->getContentHandlerFactory(),
$services->getRevisionStore(),
$services->getSpamChecker(),
$services->getTitleFormatter(),
$services->getHookContainer(),
$services->getWikiPageFactory(),
$services->getUserFactory()
$services->getUserFactory(),
$services->getActorMigration()
);
},

View file

@ -114,25 +114,28 @@ class RollbackAction extends FormAction {
] );
}
$data = null;
$errors = $this->getWikiPage()->doRollback(
$from,
$request->getText( 'summary' ),
$request->getVal( 'token' ),
$request->getBool( 'bot' ),
$data,
$this->getContext()->getAuthority()
);
// The revision has the user suppressed, so the rollback has empty 'from',
// so the check above would succeed in that case.
if ( !$revUser ) {
$revUser = $rev->getUser( RevisionRecord::RAW );
}
if ( in_array( [ 'actionthrottledtext' ], $errors ) ) {
$rollbackResult = MediaWikiServices::getInstance()
->getRollbackPageFactory()
->newRollbackPage( $this->getWikiPage(), $this->getContext()->getAuthority(), $revUser )
->setSummary( $request->getText( 'summary' ) )
->markAsBot( $request->getVal( 'token' ) )
->rollbackIfAllowed();
$data = $rollbackResult->getValue();
if ( $rollbackResult->hasMessage( 'actionthrottledtext' ) ) {
throw new ThrottledError;
}
if ( $this->hasRollbackRelatedErrors( $errors ) ) {
if ( $rollbackResult->hasMessage( 'alreadyrolled' ) || $rollbackResult->hasMessage( 'cantrollback' ) ) {
$this->getOutput()->setPageTitle( $this->msg( 'rollbackfailed' ) );
$errArray = $errors[0];
$errMsg = array_shift( $errArray );
$this->getOutput()->addWikiMsgArray( $errMsg, $errArray );
$errArray = $rollbackResult->getErrors()[0];
$this->getOutput()->addWikiMsgArray( $errArray['message'], $errArray['params'] );
if ( isset( $data['current-revision-record'] ) ) {
/** @var RevisionRecord $current */
@ -154,14 +157,14 @@ class RollbackAction extends FormAction {
}
# NOTE: Permission errors already handled by Action::checkExecute.
if ( $errors == [ [ 'readonlytext' ] ] ) {
if ( $rollbackResult->hasMessage( 'readonlytext' ) ) {
throw new ReadOnlyError;
}
# XXX: Would be nice if ErrorPageError could take multiple errors, and/or a status object.
# Right now, we only show the first error
foreach ( $errors as $error ) {
throw new ErrorPageError( 'rollbackfailed', $error[0], array_slice( $error, 1 ) );
foreach ( $rollbackResult->getErrors() as $error ) {
throw new ErrorPageError( 'rollbackfailed', $error['message'], $error['params'] );
}
/** @var RevisionRecord $current */
@ -251,11 +254,4 @@ class RollbackAction extends FormAction {
]
];
}
private function hasRollbackRelatedErrors( array $errors ) {
return isset( $errors[0][0] ) &&
( $errors[0][0] == 'alreadyrolled' ||
$errors[0][0] == 'cantrollback'
);
}
}

View file

@ -120,7 +120,12 @@ class ApiMain extends ApiBase {
'WatchedItemStore',
]
],
'rollback' => ApiRollback::class,
'rollback' => [
'class' => ApiRollback::class,
'services' => [
'RollbackPageFactory',
]
],
'delete' => ApiDelete::class,
'undelete' => ApiUndelete::class,
'protect' => ApiProtect::class,

View file

@ -20,6 +20,7 @@
* @file
*/
use MediaWiki\Page\RollbackPageFactory;
use MediaWiki\ParamValidator\TypeDef\UserDef;
use MediaWiki\User\UserIdentity;
@ -30,11 +31,19 @@ class ApiRollback extends ApiBase {
use ApiWatchlistTrait;
public function __construct( ApiMain $mainModule, $moduleName, $modulePrefix = '' ) {
parent::__construct( $mainModule, $moduleName, $modulePrefix );
/** @var RollbackPageFactory */
private $rollbackPageFactory;
public function __construct(
ApiMain $mainModule,
$moduleName,
RollbackPageFactory $rollbackPageFactory
) {
parent::__construct( $mainModule, $moduleName );
$this->watchlistExpiryEnabled = $this->getConfig()->get( 'WatchlistExpiry' );
$this->watchlistMaxDuration = $this->getConfig()->get( 'WatchlistExpiryMaxDuration' );
$this->rollbackPageFactory = $rollbackPageFactory;
}
/**
@ -54,12 +63,9 @@ class ApiRollback extends ApiBase {
$params = $this->extractRequestParams();
$titleObj = $this->getRbTitle( $params );
$pageObj = WikiPage::factory( $titleObj );
$summary = $params['summary'];
$details = [];
// If change tagging was requested, check that the user is allowed to tag,
// and the tags are valid
// and the tags are valid. TODO: move inside rollback command?
if ( $params['tags'] ) {
$tagStatus = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $this->getAuthority() );
if ( !$tagStatus->isOK() ) {
@ -76,18 +82,15 @@ class ApiRollback extends ApiBase {
$trxProfiler->redefineExpectations( $trxLimits['PostSend-POST'], $fname );
} );
$retval = $pageObj->doRollback(
$this->getRbUser( $params )->getName(),
$summary,
$params['token'],
$params['markbot'],
$details,
$this->getAuthority(),
$params['tags']
);
$rollbackResult = $this->rollbackPageFactory
->newRollbackPage( $titleObj, $this->getAuthority(), $this->getRbUser( $params ) )
->setSummary( $params['summary'] )
->markAsBot( $params['markbot'] )
->setChangeTags( $params['tags'] )
->rollbackIfAllowed();
if ( $retval ) {
$this->dieStatus( $this->errorArrayToStatus( $retval, $user ) );
if ( !$rollbackResult->isGood() ) {
$this->dieStatus( $rollbackResult );
}
$watch = $params['watchlist'] ?? 'preferences';
@ -96,6 +99,7 @@ class ApiRollback extends ApiBase {
// Watch pages
$this->setWatch( $watch, $titleObj, $user, 'watchrollback', $watchlistExpiry );
$details = $rollbackResult->getValue();
$currentRevisionRecord = $details['current-revision-record'];
$targetRevisionRecord = $details['target-revision-record'];

View file

@ -221,6 +221,7 @@ class WebInstallerOptions extends WebInstallerPage {
}
}
// @phan-suppress-next-line SecurityCheck-XSS
$text = wfMessage( 'config-extensions-requires' )
->rawParams( $ext, $wgLang->commaList( $links ) )
->escaped();

View file

@ -22,6 +22,8 @@
namespace MediaWiki\Page;
use ActorMigration;
use Config;
use ContentModelChange;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Content\IContentHandlerFactory;
@ -30,11 +32,14 @@ use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;
use MergeHistory;
use MovePage;
use NamespaceInfo;
use ReadOnlyMode;
use RepoGroup;
use Title;
use TitleFormatter;
use WatchedItemStoreInterface;
use Wikimedia\Rdbms\ILoadBalancer;
use WikiPage;
@ -44,9 +49,15 @@ use WikiPage;
*
* @since 1.35
*/
class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFactory, MovePageFactory {
/** @var ServiceOptions */
private $options;
class PageCommandFactory implements
ContentModelChangeFactory,
MergeHistoryFactory,
MovePageFactory,
RollbackPageFactory
{
/** @var Config */
private $config;
/** @var ILoadBalancer */
private $loadBalancer;
@ -60,6 +71,9 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto
/** @var RepoGroup */
private $repoGroup;
/** @var ReadOnlyMode */
private $readOnlyMode;
/** @var IContentHandlerFactory */
private $contentHandlerFactory;
@ -69,6 +83,9 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto
/** @var SpamChecker */
private $spamChecker;
/** @var TitleFormatter */
private $titleFormatter;
/** @var HookContainer */
private $hookContainer;
@ -78,40 +95,39 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto
/** @var UserFactory */
private $userFactory;
/**
* @internal For use by ServiceWiring
*/
public const CONSTRUCTOR_OPTIONS = [
'CategoryCollation',
'MaximumMovedPages',
];
/** @var ActorMigration */
private $actorMigration;
public function __construct(
ServiceOptions $options,
Config $config,
ILoadBalancer $loadBalancer,
NamespaceInfo $namespaceInfo,
WatchedItemStoreInterface $watchedItemStore,
RepoGroup $repoGroup,
ReadOnlyMode $readOnlyMode,
IContentHandlerFactory $contentHandlerFactory,
RevisionStore $revisionStore,
SpamChecker $spamChecker,
TitleFormatter $titleFormatter,
HookContainer $hookContainer,
WikiPageFactory $wikiPageFactory,
UserFactory $userFactory
UserFactory $userFactory,
ActorMigration $actorMigration
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->config = $config;
$this->loadBalancer = $loadBalancer;
$this->namespaceInfo = $namespaceInfo;
$this->watchedItemStore = $watchedItemStore;
$this->repoGroup = $repoGroup;
$this->readOnlyMode = $readOnlyMode;
$this->contentHandlerFactory = $contentHandlerFactory;
$this->revisionStore = $revisionStore;
$this->spamChecker = $spamChecker;
$this->titleFormatter = $titleFormatter;
$this->hookContainer = $hookContainer;
$this->wikiPageFactory = $wikiPageFactory;
$this->userFactory = $userFactory;
$this->actorMigration = $actorMigration;
}
/**
@ -175,7 +191,7 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto
return new MovePage(
$from,
$to,
$this->options,
new ServiceOptions( MovePage::CONSTRUCTOR_OPTIONS, $this->config ),
$this->loadBalancer,
$this->namespaceInfo,
$this->watchedItemStore,
@ -188,4 +204,33 @@ class PageCommandFactory implements ContentModelChangeFactory, MergeHistoryFacto
$this->userFactory
);
}
/**
* Create a new command instance for page rollback.
*
* @param PageIdentity $page
* @param Authority $performer
* @param UserIdentity $byUser
* @return RollbackPage
*/
public function newRollbackPage(
PageIdentity $page,
Authority $performer,
UserIdentity $byUser
) : RollbackPage {
return new RollbackPage(
new ServiceOptions( RollbackPage::CONSTRUCTOR_OPTIONS, $this->config ),
$this->loadBalancer,
$this->userFactory,
$this->readOnlyMode,
$this->revisionStore,
$this->titleFormatter,
$this->hookContainer,
$this->wikiPageFactory,
$this->actorMigration,
$page,
$performer,
$byUser
);
}
}

View file

@ -0,0 +1,518 @@
<?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\UserFactory;
use MediaWiki\User\UserIdentity;
use Message;
use RawMessage;
use ReadOnlyMode;
use RecentChange;
use Revision;
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 HookContainer */
private $hookContainer;
/** @var HookRunner */
private $hookRunner;
/** @var WikiPageFactory */
private $wikiPageFactory;
/** @var ActorMigration */
private $actorMigration;
/** @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 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,
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->hookContainer = $hookContainer;
$this->hookRunner = new HookRunner( $hookContainer );
$this->wikiPageFactory = $wikiPageFactory;
$this->actorMigration = $actorMigration;
$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_MASTER );
// 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' => 'page_timestamp' ],
'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 );
}
// Hook is hard deprecated since 1.35
$user = $this->userFactory->newFromAuthority( $this->performer );
$wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
if ( $this->hookContainer->isRegistered( 'ArticleRollbackComplete' ) ) {
// Only create the Revision objects if needed
$legacyCurrent = new Revision( $currentRevision );
$legacyTarget = new Revision( $targetRevision );
$this->hookRunner->onArticleRollbackComplete( $wikiPage, $user,
$legacyTarget, $legacyCurrent );
}
$this->hookRunner->onRollbackComplete( $wikiPage, $user, $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 ) {
$actorWhere = $this->actorMigration->getWhere(
$dbw,
'rc_user',
$current->getUser( RevisionRecord::RAW ),
false
);
$dbw->update(
'recentchanges',
$set,
[ /* WHERE */
'rc_cur_id' => $current->getPageId(),
'rc_timestamp > ' . $dbw->addQuotes( $dbw->timestamp( $target->getTimestamp() ) ),
$actorWhere['conds'], // No tables/joins are needed for rc_user
],
__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 );
}
}

View file

@ -0,0 +1,47 @@
<?php
/**
* Special handling for category pages.
*
* 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 MediaWiki\Permissions\Authority;
use MediaWiki\User\UserIdentity;
/**
* @since 1.37
* @package MediaWiki\Page
*/
interface RollbackPageFactory {
/**
* Create a new command instance for page rollback.
*
* @param PageIdentity $page
* @param Authority $performer
* @param UserIdentity $byUser
* @return RollbackPage
*/
public function newRollbackPage(
PageIdentity $page,
Authority $performer,
UserIdentity $byUser
): RollbackPage;
}

View file

@ -24,7 +24,6 @@ use MediaWiki\Config\ServiceOptions;
use MediaWiki\Content\ContentHandlerFactory;
use MediaWiki\Content\IContentHandlerFactory;
use MediaWiki\DAO\WikiAwareEntityTrait;
use MediaWiki\Debug\DeprecatablePropertyArray;
use MediaWiki\Edit\PreparedEdit;
use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
use MediaWiki\Logger\LoggerFactory;
@ -35,7 +34,6 @@ use MediaWiki\Page\PageRecord;
use MediaWiki\Page\PageStoreRecord;
use MediaWiki\Page\ParserOutputAccess;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\Revision\RevisionStore;
@ -3350,368 +3348,6 @@ class WikiPage implements Page, IDBAccessObject, PageRecord {
DeferredUpdates::addUpdate( new SearchUpdate( $id, $this->mTitle ) );
}
/**
* Roll back 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 on $user, then calls commitRollback()
* to do the dirty work
*
* @internal since 1.35
*
* @todo Separate the business/permission stuff out from backend code
* @todo Remove $token parameter. Already verified by RollbackAction and ApiRollback.
*
* @param string $fromP Name of the user whose edits to rollback.
* @param string $summary Custom summary. Set to default summary if empty.
* @param string $token Rollback token.
* @param bool $bot If true, mark all reverted edits as bot.
*
* @param array &$resultDetails Array contains result-specific array of additional values
* 'alreadyrolled' : 'current' (rev)
* success : 'summary' (str), 'current' (rev), 'target' (rev)
*
* @param Authority $performer doing the rollback
* @param array|null $tags Change tags to apply to the rollback
* Callers are responsible for permission checks
* (with ChangeTags::canAddTagsAccompanyingChange)
*
* @return array[] Array of errors, each error formatted as
* [ messagekey, param1, param2, ... ].
* On success, the array is empty. This array can also be passed to
* OutputPage::showPermissionsErrorPage().
*/
public function doRollback(
$fromP, $summary, $token, $bot, &$resultDetails, Authority $performer, $tags = null
) {
$this->assertProperPage();
$resultDetails = null;
// Check permissions
$permissionStatus = PermissionStatus::newEmpty();
$performer->authorizeWrite( 'edit', $this->getTitle(), $permissionStatus );
$performer->authorizeWrite( 'rollback', $this->getTitle(), $permissionStatus );
$user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer );
if ( !$user->matchEditToken( $token, 'rollback' ) ) {
$permissionStatus->fatal( 'sessionfailure' );
}
if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
$permissionStatus->fatal( 'actionthrottledtext' );
}
// If there were errors, bail out now
if ( !$permissionStatus->isGood() ) {
return $permissionStatus->toLegacyErrorArray();
}
return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $performer, $tags );
}
/**
* Backend implementation of doRollback(), please refer there for parameter
* and return value documentation
*
* @internal since 1.35
*
* NOTE: This function does NOT check ANY permissions, it just commits the
* rollback to the DB. Therefore, you should only call this function direct-
* ly if you want to use custom permissions checks. If you don't, use
* doRollback() instead.
*
* @param string $fromP Name of the user whose edits to rollback.
* @param string $summary Custom summary. Set to default summary if empty.
* @param bool $bot If true, mark all reverted edits as bot.
* @param array &$resultDetails Contains result-specific array of additional values
* @param Authority $performer The user performing the rollback
* @param array|null $tags Change tags to apply to the rollback
* Callers are responsible for permission checks
* (with ChangeTags::canAddTagsAccompanyingChange)
*
* @return array An array of error messages, as returned by Status::getErrorsArray()
*/
public function commitRollback( $fromP, $summary, $bot,
&$resultDetails, Authority $performer, $tags = null
) {
global $wgUseRCPatrol, $wgDisableAnonTalk;
$dbw = wfGetDB( DB_MASTER );
if ( wfReadOnly() ) {
return [ [ 'readonlytext' ] ];
}
// Begin revision creation cycle by creating a PageUpdater.
// If the page is changed concurrently after grabParentRevision(), the rollback will fail.
$user = MediaWikiServices::getInstance()->getUserFactory()->newFromAuthority( $performer );
$updater = $this->newPageUpdater( $user );
$current = $updater->grabParentRevision();
if ( $current === null ) {
// Something wrong... no page?
return [ [ 'notanarticle' ] ];
}
$currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
$legacyCurrentCallback = static function () use ( $current ) {
// Only created when needed
return new Revision( $current );
};
$from = str_replace( '_', ' ', $fromP );
// User name given should match up with the top revision.
// If the revision's user is not visible, then $from should be empty.
if ( $from !== ( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' ) ) {
$resultDetails = new DeprecatablePropertyArray(
[
'current' => $legacyCurrentCallback,
'current-revision-record' => $current,
],
[ 'current' => '1.35' ],
__METHOD__
);
return [ [ 'alreadyrolled',
htmlspecialchars( $this->mTitle->getPrefixedText() ),
htmlspecialchars( $fromP ),
htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
] ];
}
// Get the last edit not by this person...
// Note: these may not be public values
$actorWhere = ActorMigration::newMigration()->getWhere(
$dbw,
'rev_user',
$current->getUser( RevisionRecord::RAW )
);
$s = $dbw->selectRow(
[ 'revision' ] + $actorWhere['tables'],
[ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
[
'rev_page' => $current->getPageId(),
'NOT(' . $actorWhere['conds'] . ')',
],
__METHOD__,
[
'USE INDEX' => [ 'revision' => 'page_timestamp' ],
'ORDER BY' => [ 'rev_timestamp DESC', 'rev_id DESC' ]
],
$actorWhere['joins']
);
if ( $s === false ) {
// No one else ever edited this page
return [ [ 'cantrollback' ] ];
} elseif ( $s->rev_deleted & RevisionRecord::DELETED_TEXT
|| $s->rev_deleted & RevisionRecord::DELETED_USER
) {
// Only admins can see this text
return [ [ 'notvisiblerev' ] ];
}
// Generate the edit summary if necessary
$target = $this->getRevisionStore()->getRevisionById(
$s->rev_id,
RevisionStore::READ_LATEST
);
if ( empty( $summary ) ) {
if ( !$currentEditorForPublic ) { // no public user name
$summary = wfMessage( 'revertpage-nouser' );
} elseif ( $wgDisableAnonTalk && $current->getUser() === 0 ) {
$summary = wfMessage( 'revertpage-anon' );
} else {
$summary = wfMessage( 'revertpage' );
}
}
$targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
// Allow the custom summary to use the same args as the default message
$contLang = MediaWikiServices::getInstance()->getContentLanguage();
$args = [
$targetEditorForPublic ? $targetEditorForPublic->getName() : null,
$currentEditorForPublic ? $currentEditorForPublic->getName() : null,
$s->rev_id,
$contLang->timeanddate( MWTimestamp::convert( TS_MW, $s->rev_timestamp ) ),
$current->getId(),
$contLang->timeanddate( $current->getTimestamp() )
];
if ( $summary instanceof Message ) {
$summary = $summary->params( $args )->inContentLanguage()->text();
} else {
$summary = wfMsgReplaceArgs( $summary, $args );
}
// Trim spaces on user supplied text
$summary = trim( $summary );
// Save
$flags = EDIT_UPDATE | EDIT_INTERNAL;
if ( $performer->isAllowed( 'minoredit' ) ) {
$flags |= EDIT_MINOR;
}
if ( $bot && ( $performer->isAllowedAny( 'markbotedits', 'bot' ) ) ) {
$flags |= EDIT_FORCE_BOT;
}
// TODO: MCR: also log model changes in other slots, in case that becomes possible!
$currentContent = $current->getContent( SlotRecord::MAIN );
$targetContent = $target->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 ( $target->getSlots()->getSlots() as $slot ) {
$updater->inheritSlot( $slot );
}
// Remove extra slots
// TODO: MCR: test this once we can store multiple slots
foreach ( $current->getSlotRoles() as $role ) {
if ( !$target->hasSlot( $role ) ) {
$updater->removeSlot( $role );
}
}
$updater->setOriginalRevisionId( $target->getId() );
$oldestRevertedRevision = $this->getRevisionStore()->getNextRevision(
$target,
RevisionStore::READ_LATEST
);
if ( $oldestRevertedRevision !== null ) {
$updater->markAsRevert(
EditResult::REVERT_ROLLBACK,
$oldestRevertedRevision->getId(),
$current->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 ( $wgUseRCPatrol && $performer->authorizeWrite( 'autopatrol', $this->getTitle() ) ) {
$updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
}
// Actually store the rollback
$rev = $updater->saveRevision(
CommentStoreComment::newUnsavedComment( $summary ),
$flags
);
// Set patrolling and bot flag on the edits, which gets rollbacked.
// This is done even on edit failure to have patrolling in that case (T64157).
$set = [];
if ( $bot && $performer->isAllowed( 'markbotedits' ) ) {
// Mark all reverted edits as bot
$set['rc_bot'] = 1;
}
if ( $wgUseRCPatrol ) {
// Mark all reverted edits as patrolled
$set['rc_patrolled'] = RecentChange::PRC_AUTOPATROLLED;
}
if ( count( $set ) ) {
$actorWhere = ActorMigration::newMigration()->getWhere(
$dbw,
'rc_user',
$current->getUser( RevisionRecord::RAW ),
false
);
$dbw->update( 'recentchanges', $set,
[ /* WHERE */
'rc_cur_id' => $current->getPageId(),
'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ),
$actorWhere['conds'], // No tables/joins are needed for rc_user
],
__METHOD__
);
}
if ( !$updater->wasSuccessful() ) {
return $updater->getStatus()->getErrorsArray();
}
// Report if the edit was not created because it did not change the content.
if ( $updater->isUnchanged() ) {
$resultDetails = new DeprecatablePropertyArray(
[
'current' => $legacyCurrentCallback,
'current-revision-record' => $current,
],
[ 'current' => '1.35' ],
__METHOD__
);
return [ [ 'alreadyrolled',
htmlspecialchars( $this->mTitle->getPrefixedText() ),
htmlspecialchars( $fromP ),
htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
] ];
}
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( $performer->getUser() );
$log->setTarget( $this->mTitle );
$log->setComment( $summary );
$log->setParameters( [
'4::oldmodel' => $currentContent->getModel(),
'5::newmodel' => $targetContent->getModel(),
] );
$logId = $log->insert( $dbw );
$log->publish( $logId );
}
$revId = $rev->getId();
// Hook is hard deprecated since 1.35
if ( $this->getHookContainer()->isRegistered( 'ArticleRollbackComplete' ) ) {
// Only create the Revision objects if needed
$legacyCurrent = new Revision( $current );
$legacyTarget = new Revision( $target );
$this->getHookRunner()->onArticleRollbackComplete( $this, $user,
$legacyTarget, $legacyCurrent );
}
$this->getHookRunner()->onRollbackComplete( $this, $user, $target, $current );
$legacyTargetCallback = static function () use ( $target ) {
// Only create the Revision object if needed
return new Revision( $target );
};
$tags = array_merge(
$tags ?: [],
$updater->getEditResult()->getRevertTags()
);
$resultDetails = new DeprecatablePropertyArray(
[
'summary' => $summary,
'current' => $legacyCurrentCallback,
'current-revision-record' => $current,
'target' => $legacyTargetCallback,
'target-revision-record' => $target,
'newid' => $revId,
'tags' => $tags
],
[ 'current' => '1.35', 'target' => '1.35' ],
__METHOD__
);
// TODO: make this return a Status object and wrap $resultDetails in that.
return [];
}
/**
* The onArticle*() functions are supposed to be a kind of hooks
* which should be called whenever any of the specified actions

View file

@ -58,7 +58,6 @@ class RollbackEdits extends Maintenance {
$bot = $this->hasOption( 'bot' );
$summary = $this->getOption( 'summary', $this->mSelf . ' mass rollback' );
$titles = [];
$results = [];
if ( $this->hasOption( 'titles' ) ) {
foreach ( explode( '|', $this->getOption( 'titles' ) ) as $title ) {
$t = Title::newFromText( $title );
@ -81,10 +80,17 @@ class RollbackEdits extends Maintenance {
$doer = User::newSystemUser( 'Maintenance script', [ 'steal' => true ] );
$wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory();
$rollbackPageFactory = MediaWikiServices::getInstance()
->getRollbackPageFactory();
foreach ( $titles as $t ) {
$page = $wikiPageFactory->newFromTitle( $t );
$this->output( 'Processing ' . $t->getPrefixedText() . '... ' );
if ( !$page->commitRollback( $user, $summary, $bot, $results, $doer ) ) {
$rollbackResult = $rollbackPageFactory
->newRollbackPage( $page, $doer, $user )
->markAsBot( $bot )
->setSummary( $summary )
->rollback();
if ( $rollbackResult->isGood() ) {
$this->output( "Done!\n" );
} else {
$this->output( "Failed!\n" );

View file

@ -6,6 +6,7 @@ use MediaWiki\Logger\LegacySpi;
use MediaWiki\Logger\LogCapturingSpi;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\RevisionRecord;
use PHPUnit\Framework\ExpectationFailedException;
use PHPUnit\Framework\TestResult;
@ -2386,7 +2387,7 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase {
* @param string|Content $content the new content of the page
* @param string $summary Optional summary string for the revision
* @param int $defaultNs Optional namespace id
* @param User|null $user If null, static::getTestUser()->getUser() is used.
* @param Authority|null $performer If null, static::getTestUser()->getUser() is used.
* @return Status Object as returned by WikiPage::doEditContent()
* @throws MWException If this test cases's needsDB() method doesn't return true.
* Test cases can use "@group Database" to enable database test support,
@ -2398,7 +2399,7 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase {
$content,
$summary = '',
$defaultNs = NS_MAIN,
User $user = null
Authority $performer = null
) {
if ( !$this->needsDB() ) {
throw new MWException( 'When testing with pages, the test cases\'s needsDB()' .
@ -2415,8 +2416,8 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase {
$page = WikiPage::factory( $title );
}
if ( $user === null ) {
$user = static::getTestUser()->getUser();
if ( $performer === null ) {
$performer = static::getTestUser()->getUser();
}
if ( is_string( $content ) ) {
@ -2428,7 +2429,7 @@ abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase {
$summary,
0,
false,
$user
$performer
);
}

View file

@ -3,7 +3,6 @@
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Interwiki\InterwikiLookup;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageCommandFactory;
use MediaWiki\Revision\SlotRecord;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LoadBalancer;
@ -80,7 +79,7 @@ class MovePageTest extends MediaWikiIntegrationTestCase {
$old,
$new,
new ServiceOptions(
PageCommandFactory::CONSTRUCTOR_OPTIONS,
MovePage::CONSTRUCTOR_OPTIONS,
$params['options'] ?? [],
[
'CategoryCollation' => 'uppercase',

View file

@ -1378,229 +1378,6 @@ more stuff
);
}
/**
* @covers WikiPage::doRollback
* @covers WikiPage::commitRollback
*/
public function testDoRollback() {
$this->hideDeprecated( 'Revision::countByPageId' );
$this->hideDeprecated( 'Revision::getUserText' );
$this->hideDeprecated( 'Revision::__construct' );
$this->hideDeprecated( 'Revision::getRevisionRecord' );
$this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doCreate status get 'revision'" );
$this->hideDeprecated( "MediaWiki\Storage\PageUpdater::doModify status get 'revision'" );
$admin = $this->getTestSysop()->getUser();
$user1 = $this->getTestUser()->getUser();
// Use the confirmed group for user2 to make sure the user is different
$user2 = $this->getTestUser( [ 'confirmed' ] )->getUser();
// make sure we can test autopatrolling
$this->setMwGlobals( 'wgUseRCPatrol', true );
// TODO: MCR: test rollback of multiple slots!
$page = $this->newPage( __METHOD__ );
// Make some edits
$text = "one";
$status1 = $page->doUserEditContent( ContentHandler::makeContent( $text, $page->getTitle() ),
$admin, "section one", EDIT_NEW );
$text .= "\n\ntwo";
$status2 = $page->doUserEditContent( ContentHandler::makeContent( $text, $page->getTitle() ),
$user1, "adding section two" );
$text .= "\n\nthree";
$status3 = $page->doUserEditContent( ContentHandler::makeContent( $text, $page->getTitle() ),
$user2, "adding section three" );
/** @var Revision $rev1 */
/** @var Revision $rev2 */
/** @var Revision $rev3 */
$rev1 = $status1->getValue()['revision'];
$rev2 = $status2->getValue()['revision'];
$rev3 = $status3->getValue()['revision'];
/**
* We are having issues with doRollback spuriously failing. Apparently
* the last revision somehow goes missing or not committed under some
* circumstances. So, make sure the revisions have the correct usernames.
*/
$this->assertEquals( 3, Revision::countByPageId( wfGetDB( DB_REPLICA ), $page->getId() ) );
$this->assertEquals( $admin->getName(), $rev1->getUserText() );
$this->assertEquals( $user1->getName(), $rev2->getUserText() );
$this->assertEquals( $user2->getName(), $rev3->getUserText() );
// Now, try the actual rollback
$token = $admin->getEditToken( 'rollback' );
$rollbackErrors = $page->doRollback(
$user2->getName(),
"testing rollback",
$token,
false,
$resultDetails,
$admin
);
if ( $rollbackErrors ) {
$this->fail(
"Rollback failed:\n" .
print_r( $rollbackErrors, true ) . ";\n" .
print_r( $resultDetails, true )
);
}
$page = new WikiPage( $page->getTitle() );
$this->assertEquals(
$rev2->getRevisionRecord()->getSha1(),
$page->getRevisionRecord()->getSha1(),
"rollback did not revert to the correct revision" );
$this->assertEquals( "one\n\ntwo", $page->getContent()->getText() );
$rc = MediaWikiServices::getInstance()->getRevisionStore()->getRecentChange(
$page->getRevisionRecord()
);
$this->assertNotNull( $rc, 'RecentChanges entry' );
$this->assertEquals(
RecentChange::PRC_AUTOPATROLLED,
$rc->getAttribute( 'rc_patrolled' ),
'rc_patrolled'
);
// TODO: MCR: assert origin once we write slot data
// $mainSlot = $page->getRevision()->getRevisionRecord()->getSlot( SlotRecord::MAIN );
// $this->assertTrue( $mainSlot->isInherited(), 'isInherited' );
// $this->assertSame( $rev2->getId(), $mainSlot->getOrigin(), 'getOrigin' );
}
/**
* @covers WikiPage::doRollback
* @covers WikiPage::commitRollback
*/
public function testDoRollbackFailureSameContent() {
$this->hideDeprecated( 'Revision::getSha1' );
$this->hideDeprecated( 'Revision::__construct' );
$this->hideDeprecated( 'WikiPage::getRevision' );
$admin = $this->getTestSysop()->getUser();
$text = "one";
$page = $this->newPage( __METHOD__ );
$page->doUserEditContent(
ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
$admin,
"section one",
EDIT_NEW
);
$rev1 = $page->getRevision();
$user1 = $this->getTestUser( [ 'sysop' ] )->getUser();
$text .= "\n\ntwo";
$page = new WikiPage( $page->getTitle() );
$page->doUserEditContent(
ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
$user1,
"adding section two"
);
# now, do a the rollback from the same user was doing the edit before
$resultDetails = [];
$token = $user1->getEditToken( 'rollback' );
$errors = $page->doRollback(
$user1->getName(),
"testing revert same user",
$token,
false,
$resultDetails,
$admin
);
$this->assertEquals( [], $errors, "Rollback failed same user" );
# now, try the rollback
$resultDetails = [];
$token = $admin->getEditToken( 'rollback' );
$errors = $page->doRollback(
$user1->getName(),
"testing revert",
$token,
false,
$resultDetails,
$admin
);
$this->assertEquals(
[
[
'alreadyrolled',
__METHOD__,
$user1->getName(),
$admin->getName(),
],
],
$errors,
"Rollback not failed"
);
$page = new WikiPage( $page->getTitle() );
$this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(),
"rollback did not revert to the correct revision" );
$this->assertEquals( "one", $page->getContent()->getText() );
}
/**
* Tests tagging for edits that do rollback action
* @covers WikiPage::doRollback
*/
public function testDoRollbackTagging() {
if ( !in_array( 'mw-rollback', ChangeTags::getSoftwareTags() ) ) {
$this->markTestSkipped( 'Rollback tag deactivated, skipped the test.' );
}
$admin = new User();
$admin->setName( 'Administrator' );
$admin->addToDatabase();
$text = 'First line';
$page = $this->newPage( 'WikiPageTest_testDoRollbackTagging' );
$page->doUserEditContent(
ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
$admin,
'Added first line',
EDIT_NEW
);
$secondUser = new User();
$secondUser->setName( '92.65.217.32' );
$text .= '\n\nSecond line';
$page = new WikiPage( $page->getTitle() );
$page->doUserEditContent(
ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ),
$secondUser,
'Adding second line'
);
// Now, try the rollback
$admin->addGroup( 'sysop' ); // Make the test user a sysop
MediaWikiServices::getInstance()->getPermissionManager()->invalidateUsersRightsCache();
$token = $admin->getEditToken( 'rollback' );
$errors = $page->doRollback(
$secondUser->getName(),
'testing rollback',
$token,
false,
$resultDetails,
$admin
);
// If doRollback completed without errors
if ( $errors === [] ) {
$tags = $resultDetails[ 'tags' ];
$this->assertContains( 'mw-rollback', $tags );
}
}
public function provideGetAutoDeleteReason() {
return [
[

View file

@ -0,0 +1,380 @@
<?php
namespace MediaWiki\Tests\Page;
use ChangeTags;
use DatabaseLogEntry;
use JsonContent;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\RollbackPage;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\RevisionRecord;
use MediaWiki\Tests\Unit\MockServiceDependenciesTrait;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use ReadOnlyMode;
use RecentChange;
use Title;
use User;
use WikiPage;
use WikitextContent;
/**
* @group Database
* @covers \MediaWiki\Page\RollbackPage
* @coversDefaultClass \MediaWiki\Page\RollbackPage
* @package MediaWiki\Tests\Page
* @method RollbackPage newServiceInstance(string $serviceClass, array $parameterOverrides)
*/
class RollbackPageTest extends MediaWikiIntegrationTestCase {
use MockAuthorityTrait;
use MockServiceDependenciesTrait;
protected function setUp() : void {
parent::setUp();
$this->setMwGlobals( 'wgUseRCPatrol', true );
$this->tablesUsed = array_merge( $this->tablesUsed, [
'page',
'recentchanges',
'logging',
] );
}
public function provideAuthorize() {
yield 'Allowed' => [
'authority' => $this->mockRegisteredUltimateAuthority(),
'expect' => true,
];
yield 'No edit' => [
'authority' => $this->mockRegisteredAuthorityWithoutPermissions( [ 'edit' ] ),
'expect' => true,
];
yield 'No rollback' => [
'authority' => $this->mockRegisteredAuthorityWithoutPermissions( [ 'rollback' ] ),
'expect' => true,
];
}
/**
* @covers ::authorizeRollback
* @dataProvider provideAuthorize
*/
public function testAuthorize( Authority $authority, bool $expect ) {
$this->assertSame(
$expect,
$this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage(
new PageIdentityValue( 10, NS_MAIN, 'Test', PageIdentity::LOCAL ),
$authority,
new UserIdentityValue( 0, '127.0.0.1' )
)
->authorizeRollback()
->isGood()
);
}
public function testAuthorizeReadOnly() {
$mockReadOnly = $this->createMock( ReadOnlyMode::class );
$mockReadOnly->method( 'isReadOnly' )->willReturn( true );
$rollback = $this->newServiceInstance( RollbackPage::class, [
'readOnlyMode' => $mockReadOnly,
'performer' => $this->mockRegisteredUltimateAuthority()
] );
$this->assertFalse( $rollback->authorizeRollback()->isGood() );
}
/**
* @covers ::authorizeRollback
*/
public function testAuthorizePingLimiter() {
$performer = $this->mockRegisteredUltimateAuthority();
$userMock = $this->createMock( User::class );
$userMock->method( 'pingLimiter' )
->withConsecutive( [ 'rollback', 1 ], [ 'edit', 1 ] )
->willReturnOnConsecutiveCalls( false, false );
$userFactoryMock = $this->createMock( UserFactory::class );
$userFactoryMock->method( 'newFromAuthority' )
->with( $performer )
->willReturn( $userMock );
$rollbackPage = $this->newServiceInstance( RollbackPage::class, [
'performer' => $performer,
'userFactory' => $userFactoryMock
] );
$this->assertTrue( $rollbackPage->authorizeRollback()->isGood() );
}
public function testRollbackNotAllowed() {
$this->assertFalse( $this->newServiceInstance( RollbackPage::class, [
'performer' => $this->mockRegisteredNullAuthority()
] )->rollbackIfAllowed()->isGood() );
}
public function testRollback() {
$admin = $this->getTestSysop()->getUser();
$user1 = $this->getTestUser()->getUser();
// Use the confirmed group for user2 to make sure the user is different
$user2 = $this->getTestUser( [ 'confirmed' ] )->getUser();
$page = new WikiPage( Title::newFromText( __METHOD__ ) );
// Make some edits
$text = "one";
$status1 = $this->editPage( $page, $text, "section one", NS_MAIN, $admin );
$this->assertTrue( $status1->isGood(), 'Sanity: edit 1 success' );
$text .= "\n\ntwo";
$status2 = $this->editPage( $page, $text, "adding section two", NS_MAIN, $user1 );
$this->assertTrue( $status2->isGood(), 'Sanity: edit 2 success' );
$text .= "\n\nthree";
$status3 = $this->editPage( $page, $text, "adding section three", NS_MAIN, $user2 );
$this->assertTrue( $status3->isGood(), 'Sanity: edit 3 success' );
/** @var RevisionRecord $rev1 */
/** @var RevisionRecord $rev2 */
/** @var RevisionRecord $rev3 */
$rev1 = $status1->getValue()['revision-record'];
$rev2 = $status2->getValue()['revision-record'];
$rev3 = $status3->getValue()['revision-record'];
/**
* We are having issues with doRollback spuriously failing. Apparently
* the last revision somehow goes missing or not committed under some
* circumstances. So, make sure the revisions have the correct usernames.
*/
$this->assertEquals(
3,
$this->getServiceContainer()
->getRevisionStore()
->countRevisionsByPageId( $this->db, $page->getId() )
);
$this->assertEquals( $admin->getName(), $rev1->getUser()->getName() );
$this->assertEquals( $user1->getName(), $rev2->getUser()->getName() );
$this->assertEquals( $user2->getName(), $rev3->getUser()->getName() );
// Now, try the actual rollback
$rollbackStatus = $this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage( $page, $admin, $user2 )
->rollbackIfAllowed();
$this->assertTrue( $rollbackStatus->isGood() );
$this->assertEquals(
$rev2->getSha1(),
$page->getRevisionRecord()->getSha1(),
"rollback did not revert to the correct revision" );
$this->assertEquals( "one\n\ntwo", $page->getContent()->getText() );
$rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange(
$page->getRevisionRecord()
);
$this->assertNotNull( $rc, 'RecentChanges entry' );
$this->assertEquals(
RecentChange::PRC_AUTOPATROLLED,
$rc->getAttribute( 'rc_patrolled' ),
'rc_patrolled'
);
$mainSlot = $page->getRevisionRecord()->getSlot( SlotRecord::MAIN );
$this->assertTrue( $mainSlot->isInherited(), 'isInherited' );
$this->assertSame( $rev2->getId(), $mainSlot->getOrigin(), 'getOrigin' );
}
public function testRollbackFailSameContent() {
$admin = $this->getTestSysop()->getUser();
$page = new WikiPage( Title::newFromText( __METHOD__ ) );
$text = "one";
$status1 = $this->editPage( $page, $text, "section one", NS_MAIN, $admin );
$this->assertTrue( $status1->isGood(), 'Sanity: edit 1 success' );
$rev1 = $page->getRevisionRecord();
$user1 = $this->getTestUser( [ 'sysop' ] )->getUser();
$text .= "\n\ntwo";
$status1 = $this->editPage( $page, $text, "adding section two", NS_MAIN, $user1 );
$this->assertTrue( $status1->isGood(), 'Sanity: edit 2 success' );
$rollbackResult = $this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage( $page, $admin, $user1 )
->rollbackIfAllowed();
$this->assertTrue( $rollbackResult->isGood() );
# now, try the rollback again
$rollbackResult = $this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage( $page, $admin, $user1 )
->rollback();
$this->assertFalse( $rollbackResult->isGood() );
$this->assertTrue( $rollbackResult->hasMessage( 'alreadyrolled' ) );
$this->assertEquals( $rev1->getSha1(), $page->getRevisionRecord()->getSha1(),
"rollback did not revert to the correct revision" );
$this->assertEquals( "one", $page->getContent()->getText() );
}
public function testRollbackFailNotExisting() {
$rollbackStatus = $this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage(
new PageIdentityValue( 0, NS_MAIN, __METHOD__, PageIdentityValue::LOCAL ),
$this->mockRegisteredUltimateAuthority(),
new UserIdentityValue( 0, '127.0.0.1' )
)
->rollback();
$this->assertFalse( $rollbackStatus->isGood() );
$this->assertTrue( $rollbackStatus->hasMessage( 'notanarticle' ) );
}
/**
* @param Authority $user1
* @param Authority $user2
* @param WikiPage $page
* @return array with info about created page:
* 'revision-one' => RevisionRecord
* 'revision-two' => RevisionRecord
*/
private function prepareForRollback( Authority $user1, Authority $user2, WikiPage $page ): array {
$result = [];
$text = "one";
$status = $this->editPage( $page, $text, "section one", NS_MAIN, $user1 );
$this->assertTrue( $status->isGood(), 'Sanity: edit 1 success' );
$result['revision-one'] = $status->getValue()['revision-record'];
$text .= "\n\ntwo";
$status = $this->editPage( $page, $text, "adding section two", NS_MAIN, $user2 );
$this->assertTrue( $status->isGood(), 'Sanity: edit 2 success' );
$result['revision-two'] = $status->getValue()['revision-record'];
return $result;
}
public function testRollbackTagging() {
if ( !in_array( 'mw-rollback', ChangeTags::getSoftwareTags() ) ) {
$this->markTestSkipped( 'Rollback tag deactivated, skipped the test.' );
}
$page = new WikiPage( Title::newFromText( __METHOD__ ) );
$admin = $this->getTestSysop()->getUser();
$user1 = $this->getTestUser()->getUser();
$this->prepareForRollback( $admin, $user1, $page );
$rollbackResult = $this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage( $page, $admin, $user1 )
->setChangeTags( [ 'tag' ] )
->rollbackIfAllowed();
$this->assertTrue( $rollbackResult->isGood() );
$this->assertContains( 'mw-rollback', $rollbackResult->getValue()['tags'] );
$this->assertContains( 'tag', $rollbackResult->getValue()['tags'] );
}
public function testRollbackBot() {
$page = new WikiPage( Title::newFromText( __METHOD__ ) );
$admin = $this->getTestSysop()->getUser();
$user1 = $this->getTestUser()->getUser();
$this->prepareForRollback( $admin, $user1, $page );
$rollbackResult = $this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage( $page, $admin, $user1 )
->markAsBot( true )
->rollbackIfAllowed();
$this->assertTrue( $rollbackResult->isGood() );
$rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $page->getRevisionRecord() );
$this->assertNotNull( $rc );
$this->assertSame( '1', $rc->getAttribute( 'rc_bot' ) );
}
public function testRollbackBotNotAllowed() {
$page = new WikiPage( Title::newFromText( __METHOD__ ) );
$admin = $this->mockUserAuthorityWithoutPermissions(
$this->getTestSysop()->getUser(), [ 'markbotedits', 'bot' ] );
$user1 = $this->getTestUser()->getUser();
$this->prepareForRollback( $admin, $user1, $page );
$rollbackResult = $this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage( $page, $admin, $user1 )
->markAsBot( true )
->rollbackIfAllowed();
$this->assertTrue( $rollbackResult->isGood() );
$rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $page->getRevisionRecord() );
$this->assertNotNull( $rc );
$this->assertSame( '0', $rc->getAttribute( 'rc_bot' ) );
}
public function testRollbackCustomSummary() {
$page = new WikiPage( Title::newFromText( __METHOD__ ) );
$admin = $this->getTestSysop()->getUser();
$user1 = $this->getTestUser()->getUser();
$revisions = $this->prepareForRollback( $admin, $user1, $page );
$rollbackResult = $this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage( $page, $admin, $user1 )
->setSummary( 'TEST! $1 $2 $3 $4 $5 $6' )
->rollbackIfAllowed();
$this->assertTrue( $rollbackResult->isGood() );
$targetTimestamp = $this->getServiceContainer()
->getContentLanguage()
->timeanddate( $revisions['revision-one']->getTimestamp() );
$currentTimestamp = $this->getServiceContainer()
->getContentLanguage()
->timeanddate( $revisions['revision-two']->getTimestamp() );
$expectedSummary = "TEST! {$admin->getName()} {$user1->getName()}" .
" {$revisions['revision-one']->getId()}" .
" {$targetTimestamp}" .
" {$revisions['revision-two']->getId()}" .
" {$currentTimestamp}";
$this->assertSame( $expectedSummary, $page->getRevisionRecord()->getComment()->text );
$rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $page->getRevisionRecord() );
$this->assertNotNull( $rc );
$this->assertSame( $expectedSummary, $rc->getAttribute( 'rc_comment' ) );
}
public function testRollbackChangesContentModel() {
$page = new WikiPage( Title::newFromText( __METHOD__ ) );
$admin = $this->getTestSysop()->getUser();
$user1 = $this->getTestUser()->getUser();
$status1 = $this->editPage( $page, new JsonContent( '{}' ),
"it's json", NS_MAIN, $admin );
$this->assertTrue( $status1->isGood(), 'Sanity: edit 1 success' );
$status1 = $this->editPage( $page, new WikitextContent( 'bla' ),
"no, it's wikitext", NS_MAIN, $user1 );
$this->assertTrue( $status1->isGood(), 'Sanity: edit 2 success' );
$rollbackResult = $this->getServiceContainer()
->getRollbackPageFactory()
->newRollbackPage( $page, $admin, $user1 )
->setSummary( 'TESTING' )
->rollbackIfAllowed();
$this->assertTrue( $rollbackResult->isGood() );
$logQuery = DatabaseLogEntry::getSelectQueryData();
$logRow = $this->db->selectRow(
$logQuery['tables'],
$logQuery['fields'],
[
'log_namespace' => NS_MAIN,
'log_title' => __METHOD__,
'log_type' => 'contentmodel'
],
__METHOD__,
[],
$logQuery['join_conds']
);
$this->assertNotNull( $logRow );
$this->assertSame( $admin->getUser()->getName(), $logRow->user_name );
$this->assertSame( 'TESTING', $logRow->log_comment_text );
}
}

View file

@ -33,7 +33,7 @@ trait FactoryArgTestTrait {
* @return string
*/
protected function getFactoryMethodName() {
return 'new' . $this->getInstanceClass();
return 'new' . ( new ReflectionClass( $this->getInstanceClass() ) )->getShortName();
}
/**

View file

@ -17,7 +17,20 @@ class MovePageFactoryTest extends MediaWikiUnitTestCase {
}
protected static function getExtraClassArgCount() {
// $to and $from
return 2;
// $to + $from - $readOnlyMode - $titleFormatter - $actorMigration
return -1;
}
protected function getOverriddenMockValueForParam( ReflectionParameter $param ) {
if ( $param->getName() === 'config' ) {
return [ new HashConfig(
array_fill_keys( MovePage::CONSTRUCTOR_OPTIONS, 'test' )
) ];
}
return [];
}
protected function getIgnoredParamNames() {
return [ 'readOnlyMode', 'config', 'titleFormatter', 'actorMigration' ];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace MediaWiki\Tests\Unit\Page;
use FactoryArgTestTrait;
use HashConfig;
use MediaWiki\Page\PageCommandFactory;
use MediaWiki\Page\RollbackPage;
use MediaWikiUnitTestCase;
use ReflectionParameter;
/**
* @covers \MediaWiki\Page\PageCommandFactory
* @package MediaWiki\Tests\Unit\Page
*/
class RollbackPageFactoryTest extends MediaWikiUnitTestCase {
use FactoryArgTestTrait;
protected static function getFactoryClass() {
return PageCommandFactory::class;
}
protected static function getInstanceClass() {
return RollbackPage::class;
}
protected static function getExtraClassArgCount() {
// $page, $performer, $forUser - ignored params
return -2;
}
protected function getOverriddenMockValueForParam( ReflectionParameter $param ) {
if ( $param->getName() === 'config' ) {
return [ new HashConfig(
array_fill_keys( RollbackPage::CONSTRUCTOR_OPTIONS, 'test' )
) ];
}
return [];
}
protected function getIgnoredParamNames() {
return [
'config',
'namespaceInfo',
'watchedItemStore',
'repoGroup',
'contentHandlerFactory',
'spamChecker'
];
}
}