MediaWiki  master
RollbackPage.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Page;
22 
23 use ManualLogEntry;
43 use Message;
44 use RecentChange;
45 use StatusValue;
51 
57 class RollbackPage {
58 
63  public const CONSTRUCTOR_OPTIONS = [
66  ];
67 
69  private $options;
70 
72  private $dbProvider;
73 
75  private $userFactory;
76 
78  private $readOnlyMode;
79 
81  private $titleFormatter;
82 
84  private $revisionStore;
85 
87  private $hookRunner;
88 
90  private $wikiPageFactory;
91 
93  private $actorMigration;
94 
96  private $actorNormalization;
97 
99  private $page;
100 
102  private $performer;
103 
105  private $byUser;
106 
108  private $summary = '';
109 
111  private $bot = false;
112 
114  private $tags = [];
115 
132  public function __construct(
133  ServiceOptions $options,
134  IConnectionProvider $dbProvider,
135  UserFactory $userFactory,
136  ReadOnlyMode $readOnlyMode,
137  RevisionStore $revisionStore,
138  TitleFormatter $titleFormatter,
139  HookContainer $hookContainer,
140  WikiPageFactory $wikiPageFactory,
141  ActorMigration $actorMigration,
142  ActorNormalization $actorNormalization,
143  PageIdentity $page,
144  Authority $performer,
145  UserIdentity $byUser
146  ) {
147  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
148  $this->options = $options;
149  $this->dbProvider = $dbProvider;
150  $this->userFactory = $userFactory;
151  $this->readOnlyMode = $readOnlyMode;
152  $this->revisionStore = $revisionStore;
153  $this->titleFormatter = $titleFormatter;
154  $this->hookRunner = new HookRunner( $hookContainer );
155  $this->wikiPageFactory = $wikiPageFactory;
156  $this->actorMigration = $actorMigration;
157  $this->actorNormalization = $actorNormalization;
158 
159  $this->page = $page;
160  $this->performer = $performer;
161  $this->byUser = $byUser;
162  }
163 
170  public function setSummary( ?string $summary ): self {
171  $this->summary = $summary ?? '';
172  return $this;
173  }
174 
181  public function markAsBot( ?bool $bot ): self {
182  if ( $bot && $this->performer->isAllowedAny( 'markbotedits', 'bot' ) ) {
183  $this->bot = true;
184  } elseif ( !$bot ) {
185  $this->bot = false;
186  }
187  return $this;
188  }
189 
198  public function setChangeTags( ?array $tags ): self {
199  $this->tags = $tags ?: [];
200  return $this;
201  }
202 
208  public function authorizeRollback(): PermissionStatus {
209  $permissionStatus = PermissionStatus::newEmpty();
210  $this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus );
211  $this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus );
212 
213  if ( $this->readOnlyMode->isReadOnly() ) {
214  $permissionStatus->fatal( 'readonlytext' );
215  }
216 
217  $user = $this->userFactory->newFromAuthority( $this->performer );
218  if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
219  $permissionStatus->fatal( 'actionthrottledtext' );
220  }
221  return $permissionStatus;
222  }
223 
233  public function rollbackIfAllowed(): StatusValue {
234  $permissionStatus = $this->authorizeRollback();
235  if ( !$permissionStatus->isGood() ) {
236  return $permissionStatus;
237  }
238  return $this->rollback();
239  }
240 
256  public function rollback() {
257  // Begin revision creation cycle by creating a PageUpdater.
258  // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
259  // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
260  $updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer );
261  $currentRevision = $updater->grabParentRevision();
262 
263  if ( !$currentRevision ) {
264  // Something wrong... no page?
265  return StatusValue::newFatal( 'notanarticle' );
266  }
267 
268  $currentEditor = $currentRevision->getUser( RevisionRecord::RAW );
269  $currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC );
270  // User name given should match up with the top revision.
271  if ( !$this->byUser->equals( $currentEditor ) ) {
272  $result = StatusValue::newGood( [
273  'current-revision-record' => $currentRevision
274  ] );
275  $result->fatal(
276  'alreadyrolled',
277  htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
278  htmlspecialchars( $this->byUser->getName() ),
279  htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
280  );
281  return $result;
282  }
283 
284  $dbw = $this->dbProvider->getPrimaryDatabase();
285 
286  // Get the last edit not by this person...
287  // Note: these may not be public values
288  $actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
289  $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $dbw )
290  ->where( [ 'rev_page' => $currentRevision->getPageId(), 'NOT(' . $actorWhere['conds'] . ')' ] )
291  ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
292  ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC );
293  $targetRevisionRow = $queryBuilder->caller( __METHOD__ )->fetchRow();
294 
295  if ( $targetRevisionRow === false ) {
296  // No one else ever edited this page
297  return StatusValue::newFatal( 'cantrollback' );
298  } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
299  || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
300  ) {
301  // Only admins can see this text
302  return StatusValue::newFatal( 'notvisiblerev' );
303  }
304 
305  // Generate the edit summary if necessary
306  $targetRevision = $this->revisionStore
307  ->getRevisionById( $targetRevisionRow->rev_id, RevisionStore::READ_LATEST );
308 
309  // Save
310  $flags = EDIT_UPDATE | EDIT_INTERNAL;
311 
312  if ( $this->performer->isAllowed( 'minoredit' ) ) {
313  $flags |= EDIT_MINOR;
314  }
315 
316  if ( $this->bot ) {
317  $flags |= EDIT_FORCE_BOT;
318  }
319 
320  // TODO: MCR: also log model changes in other slots, in case that becomes possible!
321  $currentContent = $currentRevision->getContent( SlotRecord::MAIN );
322  $targetContent = $targetRevision->getContent( SlotRecord::MAIN );
323  $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
324 
325  // Build rollback revision:
326  // Restore old content
327  // TODO: MCR: test this once we can store multiple slots
328  foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
329  $updater->inheritSlot( $slot );
330  }
331 
332  // Remove extra slots
333  // TODO: MCR: test this once we can store multiple slots
334  foreach ( $currentRevision->getSlotRoles() as $role ) {
335  if ( !$targetRevision->hasSlot( $role ) ) {
336  $updater->removeSlot( $role );
337  }
338  }
339 
340  $updater->markAsRevert(
341  EditResult::REVERT_ROLLBACK,
342  $currentRevision->getId(),
343  $targetRevision->getId()
344  );
345 
346  // TODO: this logic should not be in the storage layer, it's here for compatibility
347  // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
348  // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
349  if ( $this->options->get( MainConfigNames::UseRCPatrol ) &&
350  $this->performer->authorizeWrite( 'autopatrol', $this->page )
351  ) {
352  $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
353  }
354 
355  $summary = $this->getSummary( $currentRevision, $targetRevision );
356 
357  // Actually store the rollback
358  $rev = $updater->addTags( $this->tags )->saveRevision(
359  CommentStoreComment::newUnsavedComment( $summary ),
360  $flags
361  );
362 
363  // This is done even on edit failure to have patrolling in that case (T64157).
364  $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
365 
366  if ( !$updater->wasSuccessful() ) {
367  return $updater->getStatus();
368  }
369 
370  // Report if the edit was not created because it did not change the content.
371  if ( !$updater->wasRevisionCreated() ) {
372  $result = StatusValue::newGood( [
373  'current-revision-record' => $currentRevision
374  ] );
375  $result->fatal(
376  'alreadyrolled',
377  htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
378  htmlspecialchars( $this->byUser->getName() ),
379  htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
380  );
381  return $result;
382  }
383 
384  if ( $changingContentModel ) {
385  // If the content model changed during the rollback,
386  // make sure it gets logged to Special:Log/contentmodel
387  $log = new ManualLogEntry( 'contentmodel', 'change' );
388  $log->setPerformer( $this->performer->getUser() );
389  $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
390  $log->setComment( $summary );
391  $log->setParameters( [
392  '4::oldmodel' => $currentContent->getModel(),
393  '5::newmodel' => $targetContent->getModel(),
394  ] );
395 
396  $logId = $log->insert( $dbw );
397  $log->publish( $logId );
398  }
399 
400  $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
401 
402  $this->hookRunner->onRollbackComplete(
403  $wikiPage,
404  $this->performer->getUser(),
405  $targetRevision,
406  $currentRevision
407  );
408 
409  return StatusValue::newGood( [
410  'summary' => $summary,
411  'current-revision-record' => $currentRevision,
412  'target-revision-record' => $targetRevision,
413  'newid' => $rev->getId(),
414  'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
415  ] );
416  }
417 
425  private function updateRecentChange(
426  IDatabase $dbw,
427  RevisionRecord $current,
428  RevisionRecord $target
429  ) {
430  $useRCPatrol = $this->options->get( MainConfigNames::UseRCPatrol );
431  if ( !$this->bot && !$useRCPatrol ) {
432  return;
433  }
434 
435  $actorId = $this->actorNormalization
436  ->acquireActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
437  $timestamp = $dbw->timestamp( $target->getTimestamp() );
438  $rows = $dbw->newSelectQueryBuilder()
439  ->select( [ 'rc_id', 'rc_patrolled' ] )
440  ->from( 'recentchanges' )
441  ->where( [ 'rc_cur_id' => $current->getPageId(), 'rc_actor' => $actorId, ] )
442  ->andWhere( $dbw->buildComparison( '>', [
443  'rc_timestamp' => $timestamp,
444  'rc_this_oldid' => $target->getId(),
445  ] ) )
446  ->caller( __METHOD__ )->fetchResultSet();
447 
448  $all = [];
449  $patrolled = [];
450  $unpatrolled = [];
451  foreach ( $rows as $row ) {
452  $all[] = (int)$row->rc_id;
453  if ( $row->rc_patrolled ) {
454  $patrolled[] = (int)$row->rc_id;
455  } else {
456  $unpatrolled[] = (int)$row->rc_id;
457  }
458  }
459 
460  if ( $useRCPatrol && $this->bot ) {
461  // Mark all reverted edits as if they were made by a bot
462  // Also mark only unpatrolled reverted edits as patrolled
463  if ( $unpatrolled ) {
464  $dbw->newUpdateQueryBuilder()
465  ->update( 'recentchanges' )
466  ->set( [ 'rc_bot' => 1, 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ] )
467  ->where( [ 'rc_id' => $unpatrolled ] )
468  ->caller( __METHOD__ )->execute();
469  }
470  if ( $patrolled ) {
471  $dbw->newUpdateQueryBuilder()
472  ->update( 'recentchanges' )
473  ->set( [ 'rc_bot' => 1 ] )
474  ->where( [ 'rc_id' => $patrolled ] )
475  ->caller( __METHOD__ )->execute();
476  }
477  } elseif ( $useRCPatrol ) {
478  // Mark only unpatrolled reverted edits as patrolled
479  if ( $unpatrolled ) {
480  $dbw->newUpdateQueryBuilder()
481  ->update( 'recentchanges' )
482  ->set( [ 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ] )
483  ->where( [ 'rc_id' => $unpatrolled ] )
484  ->caller( __METHOD__ )->execute();
485  }
486  } else {
487  // Edit is from a bot
488  if ( $all ) {
489  $dbw->newUpdateQueryBuilder()
490  ->update( 'recentchanges' )
491  ->set( [ 'rc_bot' => 1 ] )
492  ->where( [ 'rc_id' => $all ] )
493  ->caller( __METHOD__ )->execute();
494  }
495  }
496  }
497 
505  private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
506  $revisionsBetween = $this->revisionStore->countRevisionsBetween(
507  $current->getPageId(),
508  $target,
509  $current,
510  1000,
511  RevisionStore::INCLUDE_NEW
512  );
513  $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
514  if ( $this->summary === '' ) {
515  if ( !$currentEditorForPublic ) { // no public user name
516  $summary = MessageValue::new( 'revertpage-nouser' );
517  } elseif ( $this->options->get( MainConfigNames::DisableAnonTalk ) &&
518  !$currentEditorForPublic->isRegistered() ) {
519  $summary = MessageValue::new( 'revertpage-anon' );
520  } else {
521  $summary = MessageValue::new( 'revertpage' );
522  }
523  } else {
524  $summary = $this->summary;
525  }
526 
527  $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
528  // Allow the custom summary to use the same args as the default message
529  $args = [
530  $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
531  $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
532  $target->getId(),
533  Message::dateTimeParam( $target->getTimestamp() ),
534  $current->getId(),
535  Message::dateTimeParam( $current->getTimestamp() ),
536  $revisionsBetween,
537  ];
538  if ( $summary instanceof MessageValue ) {
539  $summary = ( new Converter() )->convertMessageValue( $summary );
540  $summary = $summary->params( $args )->inContentLanguage()->text();
541  } else {
542  $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
543  }
544 
545  // Trim spaces on user supplied text
546  return trim( $summary );
547  }
548 }
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:88
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...
Definition: HookRunner.php:568
Variant of the Message class.
Definition: RawMessage.php:40
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
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.
Definition: SlotRecord.php:40
Object for storing information about the effects of an edit.
Definition: EditResult.php:35
Represents the target of a wiki link.
Definition: TitleValue.php:44
This is not intended to be a long-term part of MediaWiki; it will be deprecated and removed once acto...
Creates User objects.
Definition: UserFactory.php:41
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition: Message.php:144
static dateTimeParam(string $dateTime)
Definition: Message.php:1187
Utility class for creating new RC entries.
const PRC_AUTOPATROLLED
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: StatusValue.php:46
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:73
fatal( $message,... $parameters)
Add an error and set OK to false, indicating that the operation as a whole was fatal.
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:85
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 objects (potentially) representing an editable wiki page.
This interface represents the authority associated the current execution context, such as a web reque...
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,...