3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
21 namespace MediaWiki\Page
;
24 use MediaWiki\CommentStore\CommentStoreComment
;
25 use MediaWiki\Config\ServiceOptions
;
26 use MediaWiki\HookContainer\HookContainer
;
27 use MediaWiki\HookContainer\HookRunner
;
28 use MediaWiki\Language\RawMessage
;
29 use MediaWiki\MainConfigNames
;
30 use MediaWiki\Message\Message
;
31 use MediaWiki\Permissions\Authority
;
32 use MediaWiki\Permissions\PermissionStatus
;
33 use MediaWiki\Revision\RevisionRecord
;
34 use MediaWiki\Revision\RevisionStore
;
35 use MediaWiki\Revision\SlotRecord
;
36 use MediaWiki\Storage\EditResult
;
37 use MediaWiki\Title\TitleFormatter
;
38 use MediaWiki\Title\TitleValue
;
39 use MediaWiki\User\ActorMigration
;
40 use MediaWiki\User\ActorNormalization
;
41 use MediaWiki\User\UserFactory
;
42 use MediaWiki\User\UserIdentity
;
45 use Wikimedia\Message\MessageValue
;
46 use Wikimedia\Rdbms\IConnectionProvider
;
47 use Wikimedia\Rdbms\IDatabase
;
48 use Wikimedia\Rdbms\IDBAccessObject
;
49 use Wikimedia\Rdbms\ReadOnlyMode
;
50 use Wikimedia\Rdbms\SelectQueryBuilder
;
53 * Backend logic for performing a page rollback action.
60 * @internal For use in PageCommandFactory only
62 public const CONSTRUCTOR_OPTIONS
= [
63 MainConfigNames
::UseRCPatrol
,
64 MainConfigNames
::DisableAnonTalk
,
68 private $summary = '';
76 private ServiceOptions
$options;
77 private IConnectionProvider
$dbProvider;
78 private UserFactory
$userFactory;
79 private ReadOnlyMode
$readOnlyMode;
80 private RevisionStore
$revisionStore;
81 private TitleFormatter
$titleFormatter;
82 private HookRunner
$hookRunner;
83 private WikiPageFactory
$wikiPageFactory;
84 private ActorMigration
$actorMigration;
85 private ActorNormalization
$actorNormalization;
86 private PageIdentity
$page;
87 private Authority
$performer;
88 /** @var UserIdentity who made the edits we are rolling back */
89 private UserIdentity
$byUser;
92 * @internal Create via the RollbackPageFactory service.
94 public function __construct(
95 ServiceOptions
$options,
96 IConnectionProvider
$dbProvider,
97 UserFactory
$userFactory,
98 ReadOnlyMode
$readOnlyMode,
99 RevisionStore
$revisionStore,
100 TitleFormatter
$titleFormatter,
101 HookContainer
$hookContainer,
102 WikiPageFactory
$wikiPageFactory,
103 ActorMigration
$actorMigration,
104 ActorNormalization
$actorNormalization,
106 Authority
$performer,
109 $options->assertRequiredOptions( self
::CONSTRUCTOR_OPTIONS
);
110 $this->options
= $options;
111 $this->dbProvider
= $dbProvider;
112 $this->userFactory
= $userFactory;
113 $this->readOnlyMode
= $readOnlyMode;
114 $this->revisionStore
= $revisionStore;
115 $this->titleFormatter
= $titleFormatter;
116 $this->hookRunner
= new HookRunner( $hookContainer );
117 $this->wikiPageFactory
= $wikiPageFactory;
118 $this->actorMigration
= $actorMigration;
119 $this->actorNormalization
= $actorNormalization;
122 $this->performer
= $performer;
123 $this->byUser
= $byUser;
127 * Set custom edit summary.
129 * @param string|null $summary
132 public function setSummary( ?
string $summary ): self
{
133 $this->summary
= $summary ??
'';
138 * Mark all reverted edits as bot.
140 * @param bool|null $bot
143 public function markAsBot( ?
bool $bot ): self
{
144 if ( $bot && $this->performer
->isAllowedAny( 'markbotedits', 'bot' ) ) {
153 * Change tags to apply to the rollback.
155 * @note Callers are responsible for permission checks (with ChangeTags::canAddTagsAccompanyingChange)
157 * @param string[]|null $tags
160 public function setChangeTags( ?
array $tags ): self
{
161 $this->tags
= $tags ?
: [];
166 * Authorize the rollback.
168 * @return PermissionStatus
170 public function authorizeRollback(): PermissionStatus
{
171 $permissionStatus = PermissionStatus
::newEmpty();
172 $this->performer
->authorizeWrite( 'edit', $this->page
, $permissionStatus );
173 $this->performer
->authorizeWrite( 'rollback', $this->page
, $permissionStatus );
175 if ( $this->readOnlyMode
->isReadOnly() ) {
176 $permissionStatus->fatal( 'readonlytext' );
179 return $permissionStatus;
183 * Rollback the most recent consecutive set of edits to a page
184 * from the same user; fails if there are no eligible edits to
185 * roll back to, e.g. user is the sole contributor. This function
186 * performs permissions checks and executes ::rollback.
188 * @return StatusValue see ::rollback for return value documentation.
189 * In case the rollback is not allowed, PermissionStatus is returned.
191 public function rollbackIfAllowed(): StatusValue
{
192 $permissionStatus = $this->authorizeRollback();
193 if ( !$permissionStatus->isGood() ) {
194 return $permissionStatus;
196 return $this->rollback();
200 * Backend implementation of rollbackIfAllowed().
202 * @note This function does NOT check ANY permissions, it just commits the
203 * rollback to the DB. Therefore, you should only call this function directly
204 * if you want to use custom permissions checks. If you don't, use
205 * ::rollbackIfAllowed() instead.
207 * @return StatusValue On success, wrapping the array with the following keys:
208 * 'summary' - rollback edit summary
209 * 'current-revision-record' - revision record that was current before rollback
210 * 'target-revision-record' - revision record we are rolling back to
211 * 'newid' => the id of the rollback revision
212 * 'tags' => the tags applied to the rollback
214 public function rollback() {
215 // Begin revision creation cycle by creating a PageUpdater.
216 // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
217 // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
218 $updater = $this->wikiPageFactory
->newFromTitle( $this->page
)->newPageUpdater( $this->performer
);
219 $currentRevision = $updater->grabParentRevision();
221 if ( !$currentRevision ) {
222 // Something wrong... no page?
223 return StatusValue
::newFatal( 'notanarticle' );
226 $currentEditor = $currentRevision->getUser( RevisionRecord
::RAW
);
227 $currentEditorForPublic = $currentRevision->getUser( RevisionRecord
::FOR_PUBLIC
);
228 // User name given should match up with the top revision.
229 if ( !$this->byUser
->equals( $currentEditor ) ) {
230 $result = StatusValue
::newGood( [
231 'current-revision-record' => $currentRevision
235 htmlspecialchars( $this->titleFormatter
->getPrefixedText( $this->page
) ),
236 htmlspecialchars( $this->byUser
->getName() ),
237 htmlspecialchars( $currentEditorForPublic ?
$currentEditorForPublic->getName() : '' )
242 $dbw = $this->dbProvider
->getPrimaryDatabase();
244 // Get the last edit not by this person...
245 // Note: these may not be public values
246 $actorWhere = $this->actorMigration
->getWhere( $dbw, 'rev_user', $currentEditor );
247 $queryBuilder = $this->revisionStore
->newSelectQueryBuilder( $dbw )
248 ->where( [ 'rev_page' => $currentRevision->getPageId(), 'NOT(' . $actorWhere['conds'] . ')' ] )
249 ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
250 ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder
::SORT_DESC
);
251 $targetRevisionRow = $queryBuilder->caller( __METHOD__
)->fetchRow();
253 if ( $targetRevisionRow === false ) {
254 // No one else ever edited this page
255 return StatusValue
::newFatal( 'cantrollback' );
256 } elseif ( $targetRevisionRow->rev_deleted
& RevisionRecord
::DELETED_TEXT
257 ||
$targetRevisionRow->rev_deleted
& RevisionRecord
::DELETED_USER
259 // Only admins can see this text
260 return StatusValue
::newFatal( 'notvisiblerev' );
263 // Generate the edit summary if necessary
264 $targetRevision = $this->revisionStore
265 ->getRevisionById( $targetRevisionRow->rev_id
, IDBAccessObject
::READ_LATEST
);
268 $flags = EDIT_UPDATE | EDIT_INTERNAL
;
270 if ( $this->performer
->isAllowed( 'minoredit' ) ) {
271 $flags |
= EDIT_MINOR
;
275 $flags |
= EDIT_FORCE_BOT
;
278 // TODO: MCR: also log model changes in other slots, in case that becomes possible!
279 $currentContent = $currentRevision->getContent( SlotRecord
::MAIN
);
280 $targetContent = $targetRevision->getContent( SlotRecord
::MAIN
);
281 $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
283 // Build rollback revision:
284 // Restore old content
285 // TODO: MCR: test this once we can store multiple slots
286 foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
287 $updater->inheritSlot( $slot );
290 // Remove extra slots
291 // TODO: MCR: test this once we can store multiple slots
292 foreach ( $currentRevision->getSlotRoles() as $role ) {
293 if ( !$targetRevision->hasSlot( $role ) ) {
294 $updater->removeSlot( $role );
298 $updater->markAsRevert(
299 EditResult
::REVERT_ROLLBACK
,
300 $currentRevision->getId(),
301 $targetRevision->getId()
304 // TODO: this logic should not be in the storage layer, it's here for compatibility
305 // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
306 // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
307 if ( $this->options
->get( MainConfigNames
::UseRCPatrol
) &&
308 $this->performer
->authorizeWrite( 'autopatrol', $this->page
)
310 $updater->setRcPatrolStatus( RecentChange
::PRC_AUTOPATROLLED
);
313 $summary = $this->getSummary( $currentRevision, $targetRevision );
315 // Actually store the rollback
316 $rev = $updater->addTags( $this->tags
)->saveRevision(
317 CommentStoreComment
::newUnsavedComment( $summary ),
321 // This is done even on edit failure to have patrolling in that case (T64157).
322 $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
324 if ( !$updater->wasSuccessful() ) {
325 return $updater->getStatus();
328 // Report if the edit was not created because it did not change the content.
329 if ( !$updater->wasRevisionCreated() ) {
330 $result = StatusValue
::newGood( [
331 'current-revision-record' => $currentRevision
335 htmlspecialchars( $this->titleFormatter
->getPrefixedText( $this->page
) ),
336 htmlspecialchars( $this->byUser
->getName() ),
337 htmlspecialchars( $currentEditorForPublic ?
$currentEditorForPublic->getName() : '' )
342 if ( $changingContentModel ) {
343 // If the content model changed during the rollback,
344 // make sure it gets logged to Special:Log/contentmodel
345 $log = new ManualLogEntry( 'contentmodel', 'change' );
346 $log->setPerformer( $this->performer
->getUser() );
347 $log->setTarget( new TitleValue( $this->page
->getNamespace(), $this->page
->getDBkey() ) );
348 $log->setComment( $summary );
349 $log->setParameters( [
350 '4::oldmodel' => $currentContent->getModel(),
351 '5::newmodel' => $targetContent->getModel(),
354 $logId = $log->insert( $dbw );
355 $log->publish( $logId );
358 $wikiPage = $this->wikiPageFactory
->newFromTitle( $this->page
);
360 $this->hookRunner
->onRollbackComplete(
362 $this->performer
->getUser(),
367 return StatusValue
::newGood( [
368 'summary' => $summary,
369 'current-revision-record' => $currentRevision,
370 'target-revision-record' => $targetRevision,
371 'newid' => $rev->getId(),
372 'tags' => array_merge( $this->tags
, $updater->getEditResult()->getRevertTags() )
377 * Set patrolling and bot flag on the edits which get rolled back.
379 * @param IDatabase $dbw
380 * @param RevisionRecord $current
381 * @param RevisionRecord $target
383 private function updateRecentChange(
385 RevisionRecord
$current,
386 RevisionRecord
$target
388 $useRCPatrol = $this->options
->get( MainConfigNames
::UseRCPatrol
);
389 if ( !$this->bot
&& !$useRCPatrol ) {
393 $actorId = $this->actorNormalization
->findActorId( $current->getUser( RevisionRecord
::RAW
), $dbw );
394 $timestamp = $dbw->timestamp( $target->getTimestamp() );
395 $rows = $dbw->newSelectQueryBuilder()
396 ->select( [ 'rc_id', 'rc_patrolled' ] )
397 ->from( 'recentchanges' )
398 ->where( [ 'rc_cur_id' => $current->getPageId(), 'rc_actor' => $actorId, ] )
399 ->andWhere( $dbw->buildComparison( '>', [
400 'rc_timestamp' => $timestamp,
401 'rc_this_oldid' => $target->getId(),
403 ->caller( __METHOD__
)->fetchResultSet();
408 foreach ( $rows as $row ) {
409 $all[] = (int)$row->rc_id
;
410 if ( $row->rc_patrolled
) {
411 $patrolled[] = (int)$row->rc_id
;
413 $unpatrolled[] = (int)$row->rc_id
;
417 if ( $useRCPatrol && $this->bot
) {
418 // Mark all reverted edits as if they were made by a bot
419 // Also mark only unpatrolled reverted edits as patrolled
420 if ( $unpatrolled ) {
421 $dbw->newUpdateQueryBuilder()
422 ->update( 'recentchanges' )
423 ->set( [ 'rc_bot' => 1, 'rc_patrolled' => RecentChange
::PRC_AUTOPATROLLED
] )
424 ->where( [ 'rc_id' => $unpatrolled ] )
425 ->caller( __METHOD__
)->execute();
428 $dbw->newUpdateQueryBuilder()
429 ->update( 'recentchanges' )
430 ->set( [ 'rc_bot' => 1 ] )
431 ->where( [ 'rc_id' => $patrolled ] )
432 ->caller( __METHOD__
)->execute();
434 } elseif ( $useRCPatrol ) {
435 // Mark only unpatrolled reverted edits as patrolled
436 if ( $unpatrolled ) {
437 $dbw->newUpdateQueryBuilder()
438 ->update( 'recentchanges' )
439 ->set( [ 'rc_patrolled' => RecentChange
::PRC_AUTOPATROLLED
] )
440 ->where( [ 'rc_id' => $unpatrolled ] )
441 ->caller( __METHOD__
)->execute();
444 // Edit is from a bot
446 $dbw->newUpdateQueryBuilder()
447 ->update( 'recentchanges' )
448 ->set( [ 'rc_bot' => 1 ] )
449 ->where( [ 'rc_id' => $all ] )
450 ->caller( __METHOD__
)->execute();
456 * Generate and format summary for the rollback.
458 * @param RevisionRecord $current
459 * @param RevisionRecord $target
462 private function getSummary( RevisionRecord
$current, RevisionRecord
$target ): string {
463 $revisionsBetween = $this->revisionStore
->countRevisionsBetween(
464 $current->getPageId(),
468 RevisionStore
::INCLUDE_NEW
470 $currentEditorForPublic = $current->getUser( RevisionRecord
::FOR_PUBLIC
);
471 if ( $this->summary
=== '' ) {
472 if ( !$currentEditorForPublic ) { // no public user name
473 $summary = MessageValue
::new( 'revertpage-nouser' );
474 } elseif ( $this->options
->get( MainConfigNames
::DisableAnonTalk
) &&
475 !$currentEditorForPublic->isRegistered() ) {
476 $summary = MessageValue
::new( 'revertpage-anon' );
478 $summary = MessageValue
::new( 'revertpage' );
481 $summary = $this->summary
;
484 $targetEditorForPublic = $target->getUser( RevisionRecord
::FOR_PUBLIC
);
485 // Allow the custom summary to use the same args as the default message
487 $targetEditorForPublic ?
$targetEditorForPublic->getName() : null,
488 $currentEditorForPublic ?
$currentEditorForPublic->getName() : null,
490 Message
::dateTimeParam( $target->getTimestamp() ),
492 Message
::dateTimeParam( $current->getTimestamp() ),
495 if ( $summary instanceof MessageValue
) {
496 $summary = Message
::newFromSpecifier( $summary )->params( $args )->inContentLanguage()->text();
498 $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
501 // Trim spaces on user supplied text
502 return trim( $summary );