MediaWiki  master
RollbackPage.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Page;
22 
23 use ManualLogEntry;
41 use Message;
42 use ReadOnlyMode;
43 use RecentChange;
44 use StatusValue;
45 use TitleFormatter;
46 use TitleValue;
50 
56 class RollbackPage {
57 
62  public const CONSTRUCTOR_OPTIONS = [
65  ];
66 
68  private $options;
69 
71  private $loadBalancer;
72 
74  private $userFactory;
75 
77  private $readOnlyMode;
78 
80  private $titleFormatter;
81 
83  private $revisionStore;
84 
86  private $hookRunner;
87 
89  private $wikiPageFactory;
90 
92  private $actorMigration;
93 
95  private $actorNormalization;
96 
98  private $page;
99 
101  private $performer;
102 
104  private $byUser;
105 
107  private $summary = '';
108 
110  private $bot = false;
111 
113  private $tags = [];
114 
131  public function __construct(
132  ServiceOptions $options,
133  ILoadBalancer $loadBalancer,
134  UserFactory $userFactory,
135  ReadOnlyMode $readOnlyMode,
136  RevisionStore $revisionStore,
137  TitleFormatter $titleFormatter,
138  HookContainer $hookContainer,
139  WikiPageFactory $wikiPageFactory,
140  ActorMigration $actorMigration,
141  ActorNormalization $actorNormalization,
142  PageIdentity $page,
143  Authority $performer,
144  UserIdentity $byUser
145  ) {
146  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
147  $this->options = $options;
148  $this->loadBalancer = $loadBalancer;
149  $this->userFactory = $userFactory;
150  $this->readOnlyMode = $readOnlyMode;
151  $this->revisionStore = $revisionStore;
152  $this->titleFormatter = $titleFormatter;
153  $this->hookRunner = new HookRunner( $hookContainer );
154  $this->wikiPageFactory = $wikiPageFactory;
155  $this->actorMigration = $actorMigration;
156  $this->actorNormalization = $actorNormalization;
157 
158  $this->page = $page;
159  $this->performer = $performer;
160  $this->byUser = $byUser;
161  }
162 
169  public function setSummary( ?string $summary ): self {
170  $this->summary = $summary ?? '';
171  return $this;
172  }
173 
180  public function markAsBot( ?bool $bot ): self {
181  if ( $bot && $this->performer->isAllowedAny( 'markbotedits', 'bot' ) ) {
182  $this->bot = true;
183  } elseif ( !$bot ) {
184  $this->bot = false;
185  }
186  return $this;
187  }
188 
197  public function setChangeTags( ?array $tags ): self {
198  $this->tags = $tags ?: [];
199  return $this;
200  }
201 
207  public function authorizeRollback(): PermissionStatus {
208  $permissionStatus = PermissionStatus::newEmpty();
209  $this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus );
210  $this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus );
211 
212  if ( $this->readOnlyMode->isReadOnly() ) {
213  $permissionStatus->fatal( 'readonlytext' );
214  }
215 
216  $user = $this->userFactory->newFromAuthority( $this->performer );
217  if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
218  $permissionStatus->fatal( 'actionthrottledtext' );
219  }
220  return $permissionStatus;
221  }
222 
232  public function rollbackIfAllowed(): StatusValue {
233  $permissionStatus = $this->authorizeRollback();
234  if ( !$permissionStatus->isGood() ) {
235  return $permissionStatus;
236  }
237  return $this->rollback();
238  }
239 
255  public function rollback() {
256  // Begin revision creation cycle by creating a PageUpdater.
257  // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
258  // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
259  $updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer );
260  $currentRevision = $updater->grabParentRevision();
261 
262  if ( !$currentRevision ) {
263  // Something wrong... no page?
264  return StatusValue::newFatal( 'notanarticle' );
265  }
266 
267  $currentEditor = $currentRevision->getUser( RevisionRecord::RAW );
268  $currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC );
269  // User name given should match up with the top revision.
270  if ( !$this->byUser->equals( $currentEditor ) ) {
271  $result = StatusValue::newGood( [
272  'current-revision-record' => $currentRevision
273  ] );
274  $result->fatal(
275  'alreadyrolled',
276  htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
277  htmlspecialchars( $this->byUser->getName() ),
278  htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
279  );
280  return $result;
281  }
282 
283  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
284 
285  // TODO: move this query to RevisionSelectQueryBuilder when it's available
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  $targetRevisionRow = $dbw->selectRow(
290  [ 'revision' ] + $actorWhere['tables'],
291  [ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
292  [
293  'rev_page' => $currentRevision->getPageId(),
294  'NOT(' . $actorWhere['conds'] . ')',
295  ],
296  __METHOD__,
297  [
298  'USE INDEX' => [ 'revision' => 'rev_page_timestamp' ],
299  'ORDER BY' => [ 'rev_timestamp DESC', 'rev_id DESC' ]
300  ],
301  $actorWhere['joins']
302  );
303 
304  if ( $targetRevisionRow === false ) {
305  // No one else ever edited this page
306  return StatusValue::newFatal( 'cantrollback' );
307  } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
308  || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
309  ) {
310  // Only admins can see this text
311  return StatusValue::newFatal( 'notvisiblerev' );
312  }
313 
314  // Generate the edit summary if necessary
315  $targetRevision = $this->revisionStore
316  ->getRevisionById( $targetRevisionRow->rev_id, RevisionStore::READ_LATEST );
317 
318  // Save
319  $flags = EDIT_UPDATE | EDIT_INTERNAL;
320 
321  if ( $this->performer->isAllowed( 'minoredit' ) ) {
322  $flags |= EDIT_MINOR;
323  }
324 
325  if ( $this->bot ) {
326  $flags |= EDIT_FORCE_BOT;
327  }
328 
329  // TODO: MCR: also log model changes in other slots, in case that becomes possible!
330  $currentContent = $currentRevision->getContent( SlotRecord::MAIN );
331  $targetContent = $targetRevision->getContent( SlotRecord::MAIN );
332  $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
333 
334  // Build rollback revision:
335  // Restore old content
336  // TODO: MCR: test this once we can store multiple slots
337  foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
338  $updater->inheritSlot( $slot );
339  }
340 
341  // Remove extra slots
342  // TODO: MCR: test this once we can store multiple slots
343  foreach ( $currentRevision->getSlotRoles() as $role ) {
344  if ( !$targetRevision->hasSlot( $role ) ) {
345  $updater->removeSlot( $role );
346  }
347  }
348 
349  $updater->markAsRevert(
350  EditResult::REVERT_ROLLBACK,
351  $currentRevision->getId(),
352  $targetRevision->getId()
353  );
354 
355  // TODO: this logic should not be in the storage layer, it's here for compatibility
356  // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
357  // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
358  if ( $this->options->get( MainConfigNames::UseRCPatrol ) &&
359  $this->performer->authorizeWrite( 'autopatrol', $this->page )
360  ) {
361  $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
362  }
363 
364  $summary = $this->getSummary( $currentRevision, $targetRevision );
365 
366  // Actually store the rollback
367  $rev = $updater->saveRevision(
368  CommentStoreComment::newUnsavedComment( $summary ),
369  $flags
370  );
371 
372  // This is done even on edit failure to have patrolling in that case (T64157).
373  $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
374 
375  if ( !$updater->wasSuccessful() ) {
376  return $updater->getStatus();
377  }
378 
379  // Report if the edit was not created because it did not change the content.
380  if ( !$updater->wasRevisionCreated() ) {
381  $result = StatusValue::newGood( [
382  'current-revision-record' => $currentRevision
383  ] );
384  $result->fatal(
385  'alreadyrolled',
386  htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
387  htmlspecialchars( $this->byUser->getName() ),
388  htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
389  );
390  return $result;
391  }
392 
393  if ( $changingContentModel ) {
394  // If the content model changed during the rollback,
395  // make sure it gets logged to Special:Log/contentmodel
396  $log = new ManualLogEntry( 'contentmodel', 'change' );
397  $log->setPerformer( $this->performer->getUser() );
398  $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
399  $log->setComment( $summary );
400  $log->setParameters( [
401  '4::oldmodel' => $currentContent->getModel(),
402  '5::newmodel' => $targetContent->getModel(),
403  ] );
404 
405  $logId = $log->insert( $dbw );
406  $log->publish( $logId );
407  }
408 
409  $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
410 
411  $this->hookRunner->onRollbackComplete(
412  $wikiPage,
413  $this->performer->getUser(),
414  $targetRevision,
415  $currentRevision
416  );
417 
418  return StatusValue::newGood( [
419  'summary' => $summary,
420  'current-revision-record' => $currentRevision,
421  'target-revision-record' => $targetRevision,
422  'newid' => $rev->getId(),
423  'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
424  ] );
425  }
426 
434  private function updateRecentChange(
435  IDatabase $dbw,
436  RevisionRecord $current,
437  RevisionRecord $target
438  ) {
439  $useRCPatrol = $this->options->get( MainConfigNames::UseRCPatrol );
440  if ( !$this->bot && !$useRCPatrol ) {
441  return;
442  }
443 
444  $actorId = $this->actorNormalization
445  ->acquireActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
446  $timestamp = $dbw->timestamp( $target->getTimestamp() );
447  $rows = $dbw->select(
448  'recentchanges',
449  [ 'rc_id', 'rc_patrolled' ],
450  [
451  'rc_cur_id' => $current->getPageId(),
452  $dbw->buildComparison( '>', [
453  'rc_timestamp' => $timestamp,
454  'rc_this_oldid' => $target->getId(),
455  ] ),
456  'rc_actor' => $actorId,
457  ],
458  __METHOD__
459  );
460 
461  $all = [];
462  $patrolled = [];
463  $unpatrolled = [];
464  foreach ( $rows as $row ) {
465  $all[] = (int)$row->rc_id;
466  if ( $row->rc_patrolled ) {
467  $patrolled[] = (int)$row->rc_id;
468  } else {
469  $unpatrolled[] = (int)$row->rc_id;
470  }
471  }
472 
473  if ( $useRCPatrol && $this->bot ) {
474  // Mark all reverted edits as if they were made by a bot
475  // Also mark only unpatrolled reverted edits as patrolled
476  if ( $unpatrolled ) {
477  $dbw->update(
478  'recentchanges',
479  [ 'rc_bot' => 1, 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ],
480  [ 'rc_id' => $unpatrolled ],
481  __METHOD__
482  );
483  }
484  if ( $patrolled ) {
485  $dbw->update(
486  'recentchanges',
487  [ 'rc_bot' => 1 ],
488  [ 'rc_id' => $patrolled ],
489  __METHOD__
490  );
491  }
492  } elseif ( $useRCPatrol ) {
493  // Mark only unpatrolled reverted edits as patrolled
494  if ( $unpatrolled ) {
495  $dbw->update(
496  'recentchanges',
497  [ 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ],
498  [ 'rc_id' => $unpatrolled ],
499  __METHOD__
500  );
501  }
502  } else { // if ( $this->bot )
503  $dbw->update(
504  'recentchanges',
505  [ 'rc_bot' => 1 ],
506  [ 'rc_id' => $all ],
507  __METHOD__
508  );
509  }
510  }
511 
519  private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
520  $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
521  if ( $this->summary === '' ) {
522  if ( !$currentEditorForPublic ) { // no public user name
523  $summary = MessageValue::new( 'revertpage-nouser' );
524  } elseif ( $this->options->get( MainConfigNames::DisableAnonTalk ) &&
525  !$currentEditorForPublic->isRegistered() ) {
526  $summary = MessageValue::new( 'revertpage-anon' );
527  } else {
528  $summary = MessageValue::new( 'revertpage' );
529  }
530  } else {
531  $summary = $this->summary;
532  }
533 
534  $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
535  // Allow the custom summary to use the same args as the default message
536  $args = [
537  $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
538  $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
539  $target->getId(),
540  Message::dateTimeParam( $target->getTimestamp() ),
541  $current->getId(),
542  Message::dateTimeParam( $current->getTimestamp() ),
543  ];
544  if ( $summary instanceof MessageValue ) {
545  $summary = ( new Converter() )->convertMessageValue( $summary );
546  $summary = $summary->params( $args )->inContentLanguage()->text();
547  } else {
548  $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
549  }
550 
551  // Trim spaces on user supplied text
552  return trim( $summary );
553  }
554 }
getUser()
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'))
The persistent session ID (if any) loaded at startup.
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.
authorizeRollback()
Authorize the rollback.
setChangeTags(?array $tags)
Change tags to apply to the rollback.
__construct(ServiceOptions $options, ILoadBalancer $loadBalancer, UserFactory $userFactory, ReadOnlyMode $readOnlyMode, RevisionStore $revisionStore, TitleFormatter $titleFormatter, HookContainer $hookContainer, WikiPageFactory $wikiPageFactory, ActorMigration $actorMigration, ActorNormalization $actorNormalization, PageIdentity $page, Authority $performer, UserIdentity $byUser)
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
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:38
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:1179
A service class for fetching the wiki's current read-only mode.
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
Represents a page (or page fragment) title within MediaWiki.
Definition: TitleValue.php:40
Value object representing a message for i18n.
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
Service for dealing with the actor table.
Interface for objects representing user identity.
A title formatter service for MediaWiki.
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:36
update( $table, $set, $conds, $fname=__METHOD__, $options=[])
Update all rows in a table that match a given condition.
This class is a delegate to ILBFactory for a given database cluster.
select( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Execute a SELECT query constructed using the various parameters provided.
selectRow( $table, $vars, $conds, $fname=__METHOD__, $options=[], $join_conds=[])
Wrapper to IDatabase::select() that only fetches one row (via LIMIT)
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,...
const DB_PRIMARY
Definition: defines.php:28