MediaWiki  master
RollbackPage.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Page;
22 
23 use ActorMigration;
25 use ManualLogEntry;
40 use Message;
41 use RawMessage;
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 
90 
92  private $actorMigration;
93 
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,
136  RevisionStore $revisionStore,
138  HookContainer $hookContainer,
141  ActorNormalization $actorNormalization,
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  // T270033 Index renaming
285  $revIndex = $dbw->indexExists( 'revision', 'page_timestamp', __METHOD__ )
286  ? 'page_timestamp'
287  : 'rev_page_timestamp';
288 
289  // TODO: move this query to RevisionSelectQueryBuilder when it's available
290  // Get the last edit not by this person...
291  // Note: these may not be public values
292  $actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
293  $targetRevisionRow = $dbw->selectRow(
294  [ 'revision' ] + $actorWhere['tables'],
295  [ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
296  [
297  'rev_page' => $currentRevision->getPageId(),
298  'NOT(' . $actorWhere['conds'] . ')',
299  ],
300  __METHOD__,
301  [
302  'USE INDEX' => [ 'revision' => $revIndex ],
303  'ORDER BY' => [ 'rev_timestamp DESC', 'rev_id DESC' ]
304  ],
305  $actorWhere['joins']
306  );
307 
308  if ( $targetRevisionRow === false ) {
309  // No one else ever edited this page
310  return StatusValue::newFatal( 'cantrollback' );
311  } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
312  || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
313  ) {
314  // Only admins can see this text
315  return StatusValue::newFatal( 'notvisiblerev' );
316  }
317 
318  // Generate the edit summary if necessary
319  $targetRevision = $this->revisionStore
320  ->getRevisionById( $targetRevisionRow->rev_id, RevisionStore::READ_LATEST );
321 
322  // Save
323  $flags = EDIT_UPDATE | EDIT_INTERNAL;
324 
325  if ( $this->performer->isAllowed( 'minoredit' ) ) {
326  $flags |= EDIT_MINOR;
327  }
328 
329  if ( $this->bot ) {
330  $flags |= EDIT_FORCE_BOT;
331  }
332 
333  // TODO: MCR: also log model changes in other slots, in case that becomes possible!
334  $currentContent = $currentRevision->getContent( SlotRecord::MAIN );
335  $targetContent = $targetRevision->getContent( SlotRecord::MAIN );
336  $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
337 
338  // Build rollback revision:
339  // Restore old content
340  // TODO: MCR: test this once we can store multiple slots
341  foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
342  $updater->inheritSlot( $slot );
343  }
344 
345  // Remove extra slots
346  // TODO: MCR: test this once we can store multiple slots
347  foreach ( $currentRevision->getSlotRoles() as $role ) {
348  if ( !$targetRevision->hasSlot( $role ) ) {
349  $updater->removeSlot( $role );
350  }
351  }
352 
353  $updater->markAsRevert(
354  EditResult::REVERT_ROLLBACK,
355  $currentRevision->getId(),
356  $targetRevision->getId()
357  );
358 
359  // TODO: this logic should not be in the storage layer, it's here for compatibility
360  // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
361  // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
362  if ( $this->options->get( MainConfigNames::UseRCPatrol ) &&
363  $this->performer->authorizeWrite( 'autopatrol', $this->page )
364  ) {
365  $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
366  }
367 
368  $summary = $this->getSummary( $currentRevision, $targetRevision );
369 
370  // Actually store the rollback
371  $rev = $updater->saveRevision(
373  $flags
374  );
375 
376  // This is done even on edit failure to have patrolling in that case (T64157).
377  $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
378 
379  if ( !$updater->wasSuccessful() ) {
380  return $updater->getStatus();
381  }
382 
383  // Report if the edit was not created because it did not change the content.
384  if ( !$updater->wasRevisionCreated() ) {
385  $result = StatusValue::newGood( [
386  'current-revision-record' => $currentRevision
387  ] );
388  $result->fatal(
389  'alreadyrolled',
390  htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
391  htmlspecialchars( $this->byUser->getName() ),
392  htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
393  );
394  return $result;
395  }
396 
397  if ( $changingContentModel ) {
398  // If the content model changed during the rollback,
399  // make sure it gets logged to Special:Log/contentmodel
400  $log = new ManualLogEntry( 'contentmodel', 'change' );
401  $log->setPerformer( $this->performer->getUser() );
402  $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
403  $log->setComment( $summary );
404  $log->setParameters( [
405  '4::oldmodel' => $currentContent->getModel(),
406  '5::newmodel' => $targetContent->getModel(),
407  ] );
408 
409  $logId = $log->insert( $dbw );
410  $log->publish( $logId );
411  }
412 
413  $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
414 
415  $this->hookRunner->onRollbackComplete(
416  $wikiPage,
417  $this->performer->getUser(),
418  $targetRevision,
419  $currentRevision
420  );
421 
422  return StatusValue::newGood( [
423  'summary' => $summary,
424  'current-revision-record' => $currentRevision,
425  'target-revision-record' => $targetRevision,
426  'newid' => $rev->getId(),
427  'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
428  ] );
429  }
430 
438  private function updateRecentChange(
439  IDatabase $dbw,
440  RevisionRecord $current,
441  RevisionRecord $target
442  ) {
443  $set = [];
444  if ( $this->bot ) {
445  // Mark all reverted edits as bot
446  $set['rc_bot'] = 1;
447  }
448 
449  if ( $this->options->get( MainConfigNames::UseRCPatrol ) ) {
450  // Mark all reverted edits as patrolled
451  $set['rc_patrolled'] = RecentChange::PRC_AUTOPATROLLED;
452  }
453 
454  if ( $set ) {
455  $actorId = $this->actorNormalization
456  ->acquireActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
457  $dbw->update(
458  'recentchanges',
459  $set,
460  [ /* WHERE */
461  'rc_cur_id' => $current->getPageId(),
462  'rc_timestamp > ' . $dbw->addQuotes( $dbw->timestamp( $target->getTimestamp() ) ),
463  'rc_actor' => $actorId
464  ],
465  __METHOD__
466  );
467  }
468  }
469 
477  private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
478  $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
479  if ( $this->summary === '' ) {
480  if ( !$currentEditorForPublic ) { // no public user name
481  $summary = MessageValue::new( 'revertpage-nouser' );
482  } elseif ( $this->options->get( MainConfigNames::DisableAnonTalk ) &&
483  !$currentEditorForPublic->isRegistered() ) {
484  $summary = MessageValue::new( 'revertpage-anon' );
485  } else {
486  $summary = MessageValue::new( 'revertpage' );
487  }
488  } else {
489  $summary = $this->summary;
490  }
491 
492  $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
493  // Allow the custom summary to use the same args as the default message
494  $args = [
495  $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
496  $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
497  $target->getId(),
498  Message::dateTimeParam( $target->getTimestamp() ),
499  $current->getId(),
500  Message::dateTimeParam( $current->getTimestamp() ),
501  ];
502  if ( $summary instanceof MessageValue ) {
503  $summary = ( new Converter() )->convertMessageValue( $summary );
504  $summary = $summary->params( $args )->inContentLanguage()->text();
505  } else {
506  $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
507  }
508 
509  // Trim spaces on user supplied text
510  return trim( $summary );
511  }
512 }
getUser()
const EDIT_FORCE_BOT
Definition: Defines.php:129
const EDIT_INTERNAL
Definition: Defines.php:132
const EDIT_UPDATE
Definition: Defines.php:126
const EDIT_MINOR
Definition: Defines.php:127
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition: WebStart.php:82
This is not intended to be a long-term part of MediaWiki; it will be deprecated and removed once acto...
Value object for a comment stored by CommentStore.
static newUnsavedComment( $comment, array $data=null)
Create a new, unsaved CommentStoreComment.
Class for creating new log entries and inserting them into the database.
A class for passing options to services.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:562
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.
markAsBot(?bool $bot)
Mark all reverted edits as bot.
ILoadBalancer $loadBalancer
UserFactory $userFactory
rollback()
Backend implementation of rollbackIfAllowed().
rollbackIfAllowed()
Rollback the most recent consecutive set of edits to a page from the same user; fails if there are no...
HookRunner $hookRunner
getSummary(RevisionRecord $current, RevisionRecord $target)
Generate and format summary for the rollback.
ServiceOptions $options
ActorMigration $actorMigration
ActorNormalization $actorNormalization
updateRecentChange(IDatabase $dbw, RevisionRecord $current, RevisionRecord $target)
Set patrolling and bot flag on the edits, which gets rolled back.
PageIdentity $page
ReadOnlyMode $readOnlyMode
setChangeTags(?array $tags)
Change tags to apply to the rollback.
RevisionStore $revisionStore
Authority $performer
authorizeRollback()
Authorize 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)
TitleFormatter $titleFormatter
UserIdentity $byUser
who made the edits we are rolling back
setSummary(?string $summary)
Set custom edit summary.
WikiPageFactory $wikiPageFactory
Service for creating WikiPage objects.
A StatusValue for permission errors.
Page revision base class.
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:38
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:141
static dateTimeParam(string $dateTime)
Definition: Message.php:1199
Variant of the Message class.
Definition: RawMessage.php:35
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:43
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:70
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:82
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:40
Database cluster connection, tracking, load balancing, and transaction manager interface.
if( $line===false) $args
Definition: mcc.php:124
const DB_PRIMARY
Definition: defines.php:27