MediaWiki master
RollbackPage.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Page;
22
45use RecentChange;
46use StatusValue;
52
59
64 public const CONSTRUCTOR_OPTIONS = [
67 ];
68
70 private $options;
71
73 private $dbProvider;
74
76 private $userFactory;
77
79 private $readOnlyMode;
80
82 private $titleFormatter;
83
85 private $revisionStore;
86
88 private $hookRunner;
89
91 private $wikiPageFactory;
92
94 private $actorMigration;
95
97 private $actorNormalization;
98
100 private $page;
101
103 private $performer;
104
106 private $byUser;
107
109 private $summary = '';
110
112 private $bot = false;
113
115 private $tags = [];
116
133 public function __construct(
134 ServiceOptions $options,
135 IConnectionProvider $dbProvider,
136 UserFactory $userFactory,
137 ReadOnlyMode $readOnlyMode,
138 RevisionStore $revisionStore,
139 TitleFormatter $titleFormatter,
140 HookContainer $hookContainer,
141 WikiPageFactory $wikiPageFactory,
142 ActorMigration $actorMigration,
143 ActorNormalization $actorNormalization,
144 PageIdentity $page,
145 Authority $performer,
146 UserIdentity $byUser
147 ) {
148 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
149 $this->options = $options;
150 $this->dbProvider = $dbProvider;
151 $this->userFactory = $userFactory;
152 $this->readOnlyMode = $readOnlyMode;
153 $this->revisionStore = $revisionStore;
154 $this->titleFormatter = $titleFormatter;
155 $this->hookRunner = new HookRunner( $hookContainer );
156 $this->wikiPageFactory = $wikiPageFactory;
157 $this->actorMigration = $actorMigration;
158 $this->actorNormalization = $actorNormalization;
159
160 $this->page = $page;
161 $this->performer = $performer;
162 $this->byUser = $byUser;
163 }
164
171 public function setSummary( ?string $summary ): self {
172 $this->summary = $summary ?? '';
173 return $this;
174 }
175
182 public function markAsBot( ?bool $bot ): self {
183 if ( $bot && $this->performer->isAllowedAny( 'markbotedits', 'bot' ) ) {
184 $this->bot = true;
185 } elseif ( !$bot ) {
186 $this->bot = false;
187 }
188 return $this;
189 }
190
199 public function setChangeTags( ?array $tags ): self {
200 $this->tags = $tags ?: [];
201 return $this;
202 }
203
210 $permissionStatus = PermissionStatus::newEmpty();
211 $this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus );
212 $this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus );
213
214 if ( $this->readOnlyMode->isReadOnly() ) {
215 $permissionStatus->fatal( 'readonlytext' );
216 }
217
218 return $permissionStatus;
219 }
220
230 public function rollbackIfAllowed(): StatusValue {
231 $permissionStatus = $this->authorizeRollback();
232 if ( !$permissionStatus->isGood() ) {
233 return $permissionStatus;
234 }
235 return $this->rollback();
236 }
237
253 public function rollback() {
254 // Begin revision creation cycle by creating a PageUpdater.
255 // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
256 // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
257 $updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer );
258 $currentRevision = $updater->grabParentRevision();
259
260 if ( !$currentRevision ) {
261 // Something wrong... no page?
262 return StatusValue::newFatal( 'notanarticle' );
263 }
264
265 $currentEditor = $currentRevision->getUser( RevisionRecord::RAW );
266 $currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC );
267 // User name given should match up with the top revision.
268 if ( !$this->byUser->equals( $currentEditor ) ) {
269 $result = StatusValue::newGood( [
270 'current-revision-record' => $currentRevision
271 ] );
272 $result->fatal(
273 'alreadyrolled',
274 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
275 htmlspecialchars( $this->byUser->getName() ),
276 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
277 );
278 return $result;
279 }
280
281 $dbw = $this->dbProvider->getPrimaryDatabase();
282
283 // Get the last edit not by this person...
284 // Note: these may not be public values
285 $actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
286 $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $dbw )
287 ->where( [ 'rev_page' => $currentRevision->getPageId(), 'NOT(' . $actorWhere['conds'] . ')' ] )
288 ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
289 ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC );
290 $targetRevisionRow = $queryBuilder->caller( __METHOD__ )->fetchRow();
291
292 if ( $targetRevisionRow === false ) {
293 // No one else ever edited this page
294 return StatusValue::newFatal( 'cantrollback' );
295 } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
296 || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
297 ) {
298 // Only admins can see this text
299 return StatusValue::newFatal( 'notvisiblerev' );
300 }
301
302 // Generate the edit summary if necessary
303 $targetRevision = $this->revisionStore
304 ->getRevisionById( $targetRevisionRow->rev_id, IDBAccessObject::READ_LATEST );
305
306 // Save
307 $flags = EDIT_UPDATE | EDIT_INTERNAL;
308
309 if ( $this->performer->isAllowed( 'minoredit' ) ) {
310 $flags |= EDIT_MINOR;
311 }
312
313 if ( $this->bot ) {
314 $flags |= EDIT_FORCE_BOT;
315 }
316
317 // TODO: MCR: also log model changes in other slots, in case that becomes possible!
318 $currentContent = $currentRevision->getContent( SlotRecord::MAIN );
319 $targetContent = $targetRevision->getContent( SlotRecord::MAIN );
320 $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
321
322 // Build rollback revision:
323 // Restore old content
324 // TODO: MCR: test this once we can store multiple slots
325 foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
326 $updater->inheritSlot( $slot );
327 }
328
329 // Remove extra slots
330 // TODO: MCR: test this once we can store multiple slots
331 foreach ( $currentRevision->getSlotRoles() as $role ) {
332 if ( !$targetRevision->hasSlot( $role ) ) {
333 $updater->removeSlot( $role );
334 }
335 }
336
337 $updater->markAsRevert(
338 EditResult::REVERT_ROLLBACK,
339 $currentRevision->getId(),
340 $targetRevision->getId()
341 );
342
343 // TODO: this logic should not be in the storage layer, it's here for compatibility
344 // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
345 // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
346 if ( $this->options->get( MainConfigNames::UseRCPatrol ) &&
347 $this->performer->authorizeWrite( 'autopatrol', $this->page )
348 ) {
349 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
350 }
351
352 $summary = $this->getSummary( $currentRevision, $targetRevision );
353
354 // Actually store the rollback
355 $rev = $updater->addTags( $this->tags )->saveRevision(
356 CommentStoreComment::newUnsavedComment( $summary ),
357 $flags
358 );
359
360 // This is done even on edit failure to have patrolling in that case (T64157).
361 $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
362
363 if ( !$updater->wasSuccessful() ) {
364 return $updater->getStatus();
365 }
366
367 // Report if the edit was not created because it did not change the content.
368 if ( !$updater->wasRevisionCreated() ) {
369 $result = StatusValue::newGood( [
370 'current-revision-record' => $currentRevision
371 ] );
372 $result->fatal(
373 'alreadyrolled',
374 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
375 htmlspecialchars( $this->byUser->getName() ),
376 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
377 );
378 return $result;
379 }
380
381 if ( $changingContentModel ) {
382 // If the content model changed during the rollback,
383 // make sure it gets logged to Special:Log/contentmodel
384 $log = new ManualLogEntry( 'contentmodel', 'change' );
385 $log->setPerformer( $this->performer->getUser() );
386 $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
387 $log->setComment( $summary );
388 $log->setParameters( [
389 '4::oldmodel' => $currentContent->getModel(),
390 '5::newmodel' => $targetContent->getModel(),
391 ] );
392
393 $logId = $log->insert( $dbw );
394 $log->publish( $logId );
395 }
396
397 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
398
399 $this->hookRunner->onRollbackComplete(
400 $wikiPage,
401 $this->performer->getUser(),
402 $targetRevision,
403 $currentRevision
404 );
405
406 return StatusValue::newGood( [
407 'summary' => $summary,
408 'current-revision-record' => $currentRevision,
409 'target-revision-record' => $targetRevision,
410 'newid' => $rev->getId(),
411 'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
412 ] );
413 }
414
422 private function updateRecentChange(
423 IDatabase $dbw,
424 RevisionRecord $current,
425 RevisionRecord $target
426 ) {
427 $useRCPatrol = $this->options->get( MainConfigNames::UseRCPatrol );
428 if ( !$this->bot && !$useRCPatrol ) {
429 return;
430 }
431
432 $actorId = $this->actorNormalization
433 ->acquireActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
434 $timestamp = $dbw->timestamp( $target->getTimestamp() );
435 $rows = $dbw->newSelectQueryBuilder()
436 ->select( [ 'rc_id', 'rc_patrolled' ] )
437 ->from( 'recentchanges' )
438 ->where( [ 'rc_cur_id' => $current->getPageId(), 'rc_actor' => $actorId, ] )
439 ->andWhere( $dbw->buildComparison( '>', [
440 'rc_timestamp' => $timestamp,
441 'rc_this_oldid' => $target->getId(),
442 ] ) )
443 ->caller( __METHOD__ )->fetchResultSet();
444
445 $all = [];
446 $patrolled = [];
447 $unpatrolled = [];
448 foreach ( $rows as $row ) {
449 $all[] = (int)$row->rc_id;
450 if ( $row->rc_patrolled ) {
451 $patrolled[] = (int)$row->rc_id;
452 } else {
453 $unpatrolled[] = (int)$row->rc_id;
454 }
455 }
456
457 if ( $useRCPatrol && $this->bot ) {
458 // Mark all reverted edits as if they were made by a bot
459 // Also mark only unpatrolled reverted edits as patrolled
460 if ( $unpatrolled ) {
462 ->update( 'recentchanges' )
463 ->set( [ 'rc_bot' => 1, 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ] )
464 ->where( [ 'rc_id' => $unpatrolled ] )
465 ->caller( __METHOD__ )->execute();
466 }
467 if ( $patrolled ) {
469 ->update( 'recentchanges' )
470 ->set( [ 'rc_bot' => 1 ] )
471 ->where( [ 'rc_id' => $patrolled ] )
472 ->caller( __METHOD__ )->execute();
473 }
474 } elseif ( $useRCPatrol ) {
475 // Mark only unpatrolled reverted edits as patrolled
476 if ( $unpatrolled ) {
478 ->update( 'recentchanges' )
479 ->set( [ 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ] )
480 ->where( [ 'rc_id' => $unpatrolled ] )
481 ->caller( __METHOD__ )->execute();
482 }
483 } else {
484 // Edit is from a bot
485 if ( $all ) {
487 ->update( 'recentchanges' )
488 ->set( [ 'rc_bot' => 1 ] )
489 ->where( [ 'rc_id' => $all ] )
490 ->caller( __METHOD__ )->execute();
491 }
492 }
493 }
494
502 private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
503 $revisionsBetween = $this->revisionStore->countRevisionsBetween(
504 $current->getPageId(),
505 $target,
506 $current,
507 1000,
508 RevisionStore::INCLUDE_NEW
509 );
510 $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
511 if ( $this->summary === '' ) {
512 if ( !$currentEditorForPublic ) { // no public user name
513 $summary = MessageValue::new( 'revertpage-nouser' );
514 } elseif ( $this->options->get( MainConfigNames::DisableAnonTalk ) &&
515 !$currentEditorForPublic->isRegistered() ) {
516 $summary = MessageValue::new( 'revertpage-anon' );
517 } else {
518 $summary = MessageValue::new( 'revertpage' );
519 }
520 } else {
521 $summary = $this->summary;
522 }
523
524 $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
525 // Allow the custom summary to use the same args as the default message
526 $args = [
527 $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
528 $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
529 $target->getId(),
530 Message::dateTimeParam( $target->getTimestamp() ),
531 $current->getId(),
532 Message::dateTimeParam( $current->getTimestamp() ),
533 $revisionsBetween,
534 ];
535 if ( $summary instanceof MessageValue ) {
536 $summary = ( new Converter() )->convertMessageValue( $summary );
537 $summary = $summary->params( $args )->inContentLanguage()->text();
538 } else {
539 $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
540 }
541
542 // Trim spaces on user supplied text
543 return trim( $summary );
544 }
545}
const EDIT_FORCE_BOT
Definition Defines.php:130
const EDIT_INTERNAL
Definition Defines.php:133
const EDIT_UPDATE
Definition Defines.php:127
const EDIT_MINOR
Definition Defines.php:128
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
Class for creating new log entries and inserting them into the database.
Value object for a comment stored by CommentStore.
A class for passing options to services.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Variant of the Message class.
A class containing constants representing the names of configuration variables.
const UseRCPatrol
Name constant for the UseRCPatrol setting, for use with Config::get()
const DisableAnonTalk
Name constant for the DisableAnonTalk setting, for use with Config::get()
Converter between Message and MessageValue.
Definition Converter.php:18
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:158
Backend logic for performing a page rollback action.
rollback()
Backend implementation of rollbackIfAllowed().
setSummary(?string $summary)
Set custom edit summary.
__construct(ServiceOptions $options, IConnectionProvider $dbProvider, UserFactory $userFactory, ReadOnlyMode $readOnlyMode, RevisionStore $revisionStore, TitleFormatter $titleFormatter, HookContainer $hookContainer, WikiPageFactory $wikiPageFactory, ActorMigration $actorMigration, ActorNormalization $actorNormalization, PageIdentity $page, Authority $performer, UserIdentity $byUser)
authorizeRollback()
Authorize the rollback.
setChangeTags(?array $tags)
Change tags to apply to the rollback.
rollbackIfAllowed()
Rollback the most recent consecutive set of edits to a page from the same user; fails if there are no...
markAsBot(?bool $bot)
Mark all reverted edits as bot.
Service for creating WikiPage objects.
A StatusValue for permission errors.
Page revision base class.
getUser( $audience=self::FOR_PUBLIC, Authority $performer=null)
Fetch revision's author's user identity, if it's available to the specified audience.
getPageId( $wikiId=self::LOCAL)
Get the page ID.
getTimestamp()
MCR migration note: this replaced Revision::getTimestamp.
getId( $wikiId=self::LOCAL)
Get revision ID.
Service for looking up page revisions.
Value object representing a content slot associated with a page revision.
Object for storing information about the effects of an edit.
Represents the target of a wiki link.
This is not intended to be a long-term part of MediaWiki; it will be deprecated and removed once acto...
Creates User objects.
Utility class for creating new RC entries.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
fatal( $message,... $parameters)
Add an error and set OK to false, indicating that the operation as a whole was fatal.
Value object representing a message for i18n.
Determine whether a site is currently in read-only mode.
Build SELECT queries with a fluent interface.
Interface for database access objects.
Interface for objects (potentially) representing an editable wiki page.
This interface represents the authority associated with the current execution context,...
Definition Authority.php:37
A title formatter service for MediaWiki.
Service for dealing with the actor table.
Interface for objects representing user identity.
Provide primary and replica IDatabase connections.
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:36
newUpdateQueryBuilder()
Get an UpdateQueryBuilder bound to this connection.
newSelectQueryBuilder()
Create an empty SelectQueryBuilder which can be used to run queries against this connection.
timestamp( $ts=0)
Convert a timestamp in one of the formats accepted by ConvertibleTimestamp to the format used for ins...
buildComparison(string $op, array $conds)
Build a condition comparing multiple values, for use with indexes that cover multiple fields,...