MediaWiki 1.44.0
RollbackPage.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Page;
22
45use StatusValue;
52
59
63 public const CONSTRUCTOR_OPTIONS = [
66 ];
67
69 private $summary = '';
70
72 private $bot = false;
73
75 private $tags = [];
76
77 private ServiceOptions $options;
78 private IConnectionProvider $dbProvider;
79 private UserFactory $userFactory;
80 private ReadOnlyMode $readOnlyMode;
81 private RevisionStore $revisionStore;
82 private TitleFormatter $titleFormatter;
83 private HookRunner $hookRunner;
84 private WikiPageFactory $wikiPageFactory;
85 private ActorMigration $actorMigration;
86 private ActorNormalization $actorNormalization;
87 private PageIdentity $page;
88 private Authority $performer;
90 private UserIdentity $byUser;
91
95 public function __construct(
96 ServiceOptions $options,
97 IConnectionProvider $dbProvider,
98 UserFactory $userFactory,
99 ReadOnlyMode $readOnlyMode,
100 RevisionStore $revisionStore,
101 TitleFormatter $titleFormatter,
102 HookContainer $hookContainer,
103 WikiPageFactory $wikiPageFactory,
104 ActorMigration $actorMigration,
105 ActorNormalization $actorNormalization,
106 PageIdentity $page,
107 Authority $performer,
108 UserIdentity $byUser
109 ) {
110 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
111 $this->options = $options;
112 $this->dbProvider = $dbProvider;
113 $this->userFactory = $userFactory;
114 $this->readOnlyMode = $readOnlyMode;
115 $this->revisionStore = $revisionStore;
116 $this->titleFormatter = $titleFormatter;
117 $this->hookRunner = new HookRunner( $hookContainer );
118 $this->wikiPageFactory = $wikiPageFactory;
119 $this->actorMigration = $actorMigration;
120 $this->actorNormalization = $actorNormalization;
121
122 $this->page = $page;
123 $this->performer = $performer;
124 $this->byUser = $byUser;
125 }
126
133 public function setSummary( ?string $summary ): self {
134 $this->summary = $summary ?? '';
135 return $this;
136 }
137
144 public function markAsBot( ?bool $bot ): self {
145 if ( $bot && $this->performer->isAllowedAny( 'markbotedits', 'bot' ) ) {
146 $this->bot = true;
147 } elseif ( !$bot ) {
148 $this->bot = false;
149 }
150 return $this;
151 }
152
161 public function setChangeTags( ?array $tags ): self {
162 $this->tags = $tags ?: [];
163 return $this;
164 }
165
167 $permissionStatus = PermissionStatus::newEmpty();
168 $this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus );
169 $this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus );
170
171 if ( $this->readOnlyMode->isReadOnly() ) {
172 $permissionStatus->fatal( 'readonlytext' );
173 }
174
175 return $permissionStatus;
176 }
177
187 public function rollbackIfAllowed(): StatusValue {
188 $permissionStatus = $this->authorizeRollback();
189 if ( !$permissionStatus->isGood() ) {
190 return $permissionStatus;
191 }
192 return $this->rollback();
193 }
194
210 public function rollback() {
211 // Begin revision creation cycle by creating a PageUpdater.
212 // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
213 // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
214 $updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer );
215 $currentRevision = $updater->grabParentRevision();
216
217 if ( !$currentRevision ) {
218 // Something wrong... no page?
219 return StatusValue::newFatal( 'notanarticle' );
220 }
221
222 $currentEditor = $currentRevision->getUser( RevisionRecord::RAW );
223 $currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC );
224 // User name given should match up with the top revision.
225 if ( !$this->byUser->equals( $currentEditor ) ) {
226 $result = StatusValue::newGood( [
227 'current-revision-record' => $currentRevision
228 ] );
229 $result->fatal(
230 'alreadyrolled',
231 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
232 htmlspecialchars( $this->byUser->getName() ),
233 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
234 );
235 return $result;
236 }
237
238 $dbw = $this->dbProvider->getPrimaryDatabase();
239
240 // Get the last edit not by this person...
241 // Note: these may not be public values
242 $actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
243 $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $dbw )
244 ->where( [ 'rev_page' => $currentRevision->getPageId(), 'NOT(' . $actorWhere['conds'] . ')' ] )
245 ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
246 ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC );
247 $targetRevisionRow = $queryBuilder->caller( __METHOD__ )->fetchRow();
248
249 if ( $targetRevisionRow === false ) {
250 // No one else ever edited this page
251 return StatusValue::newFatal( 'cantrollback' );
252 } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
253 || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
254 ) {
255 // Only admins can see this text
256 return StatusValue::newFatal( 'notvisiblerev' );
257 }
258
259 // Generate the edit summary if necessary
260 $targetRevision = $this->revisionStore
261 ->getRevisionById( $targetRevisionRow->rev_id, IDBAccessObject::READ_LATEST );
262
263 // Save
264 $flags = EDIT_UPDATE | EDIT_INTERNAL;
265
266 if ( $this->performer->isAllowed( 'minoredit' ) ) {
267 $flags |= EDIT_MINOR;
268 }
269
270 if ( $this->bot ) {
271 $flags |= EDIT_FORCE_BOT;
272 }
273
274 // TODO: MCR: also log model changes in other slots, in case that becomes possible!
275 $currentContent = $currentRevision->getContent( SlotRecord::MAIN );
276 $targetContent = $targetRevision->getContent( SlotRecord::MAIN );
277 $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
278
279 // Build rollback revision:
280 // Restore old content
281 // TODO: MCR: test this once we can store multiple slots
282 foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
283 $updater->inheritSlot( $slot );
284 }
285
286 // Remove extra slots
287 // TODO: MCR: test this once we can store multiple slots
288 foreach ( $currentRevision->getSlotRoles() as $role ) {
289 if ( !$targetRevision->hasSlot( $role ) ) {
290 $updater->removeSlot( $role );
291 }
292 }
293
294 $updater->setCause( PageUpdateCauses::CAUSE_ROLLBACK );
295 $updater->markAsRevert(
296 EditResult::REVERT_ROLLBACK,
297 $currentRevision->getId(),
298 $targetRevision->getId()
299 );
300
301 // TODO: this logic should not be in the storage layer, it's here for compatibility
302 // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
303 // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
304 if ( $this->options->get( MainConfigNames::UseRCPatrol ) &&
305 $this->performer->authorizeWrite( 'autopatrol', $this->page )
306 ) {
307 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
308 }
309
310 $summary = $this->getSummary( $currentRevision, $targetRevision );
311
312 // Actually store the rollback
313 $rev = $updater->addTags( $this->tags )->saveRevision(
314 CommentStoreComment::newUnsavedComment( $summary ),
315 $flags
316 );
317
318 // This is done even on edit failure to have patrolling in that case (T64157).
319 $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
320
321 if ( !$updater->wasSuccessful() ) {
322 return $updater->getStatus();
323 }
324
325 // Report if the edit was not created because it did not change the content.
326 if ( !$updater->wasRevisionCreated() ) {
327 $result = StatusValue::newGood( [
328 'current-revision-record' => $currentRevision
329 ] );
330 $result->fatal(
331 'alreadyrolled',
332 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
333 htmlspecialchars( $this->byUser->getName() ),
334 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
335 );
336 return $result;
337 }
338
339 if ( $changingContentModel ) {
340 // If the content model changed during the rollback,
341 // make sure it gets logged to Special:Log/contentmodel
342 $log = new ManualLogEntry( 'contentmodel', 'change' );
343 $log->setPerformer( $this->performer->getUser() );
344 $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
345 $log->setComment( $summary );
346 $log->setParameters( [
347 '4::oldmodel' => $currentContent->getModel(),
348 '5::newmodel' => $targetContent->getModel(),
349 ] );
350
351 $logId = $log->insert( $dbw );
352 $log->publish( $logId );
353 }
354
355 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
356
357 $this->hookRunner->onRollbackComplete(
358 $wikiPage,
359 $this->performer->getUser(),
360 $targetRevision,
361 $currentRevision
362 );
363
364 return StatusValue::newGood( [
365 'summary' => $summary,
366 'current-revision-record' => $currentRevision,
367 'target-revision-record' => $targetRevision,
368 'newid' => $rev->getId(),
369 'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
370 ] );
371 }
372
380 private function updateRecentChange(
381 IDatabase $dbw,
382 RevisionRecord $current,
383 RevisionRecord $target
384 ) {
385 $useRCPatrol = $this->options->get( MainConfigNames::UseRCPatrol );
386 if ( !$this->bot && !$useRCPatrol ) {
387 return;
388 }
389
390 $actorId = $this->actorNormalization->findActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
391 $timestamp = $dbw->timestamp( $target->getTimestamp() );
392 $rows = $dbw->newSelectQueryBuilder()
393 ->select( [ 'rc_id', 'rc_patrolled' ] )
394 ->from( 'recentchanges' )
395 ->where( [ 'rc_cur_id' => $current->getPageId(), 'rc_actor' => $actorId, ] )
396 ->andWhere( $dbw->buildComparison( '>', [
397 'rc_timestamp' => $timestamp,
398 'rc_this_oldid' => $target->getId(),
399 ] ) )
400 ->caller( __METHOD__ )->fetchResultSet();
401
402 $all = [];
403 $patrolled = [];
404 $unpatrolled = [];
405 foreach ( $rows as $row ) {
406 $all[] = (int)$row->rc_id;
407 if ( $row->rc_patrolled ) {
408 $patrolled[] = (int)$row->rc_id;
409 } else {
410 $unpatrolled[] = (int)$row->rc_id;
411 }
412 }
413
414 if ( $useRCPatrol && $this->bot ) {
415 // Mark all reverted edits as if they were made by a bot
416 // Also mark only unpatrolled reverted edits as patrolled
417 if ( $unpatrolled ) {
419 ->update( 'recentchanges' )
420 ->set( [ 'rc_bot' => 1, 'rc_patrolled' => RecentChange::PRC_PATROLLED ] )
421 ->where( [ 'rc_id' => $unpatrolled ] )
422 ->caller( __METHOD__ )->execute();
423 }
424 if ( $patrolled ) {
426 ->update( 'recentchanges' )
427 ->set( [ 'rc_bot' => 1 ] )
428 ->where( [ 'rc_id' => $patrolled ] )
429 ->caller( __METHOD__ )->execute();
430 }
431 } elseif ( $useRCPatrol ) {
432 // Mark only unpatrolled reverted edits as patrolled
433 if ( $unpatrolled ) {
435 ->update( 'recentchanges' )
436 ->set( [ 'rc_patrolled' => RecentChange::PRC_PATROLLED ] )
437 ->where( [ 'rc_id' => $unpatrolled ] )
438 ->caller( __METHOD__ )->execute();
439 }
440 } else {
441 // Edit is from a bot
442 if ( $all ) {
444 ->update( 'recentchanges' )
445 ->set( [ 'rc_bot' => 1 ] )
446 ->where( [ 'rc_id' => $all ] )
447 ->caller( __METHOD__ )->execute();
448 }
449 }
450 }
451
459 private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
460 $revisionsBetween = $this->revisionStore->countRevisionsBetween(
461 $current->getPageId(),
462 $target,
463 $current,
464 1000,
465 RevisionStore::INCLUDE_NEW
466 );
467 $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
468 if ( $this->summary === '' ) {
469 if ( !$currentEditorForPublic ) { // no public user name
470 $summary = MessageValue::new( 'revertpage-nouser' );
471 } elseif ( $this->options->get( MainConfigNames::DisableAnonTalk ) &&
472 !$currentEditorForPublic->isRegistered() ) {
473 $summary = MessageValue::new( 'revertpage-anon' );
474 } else {
475 $summary = MessageValue::new( 'revertpage' );
476 }
477 } else {
478 $summary = $this->summary;
479 }
480
481 $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
482 // Allow the custom summary to use the same args as the default message
483 $args = [
484 $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
485 $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
486 $target->getId(),
487 Message::dateTimeParam( $target->getTimestamp() ),
488 $current->getId(),
489 Message::dateTimeParam( $current->getTimestamp() ),
490 $revisionsBetween,
491 ];
492 if ( $summary instanceof MessageValue ) {
493 $summary = Message::newFromSpecifier( $summary )->params( $args )->inContentLanguage()->text();
494 } else {
495 $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
496 }
497
498 // Trim spaces on user supplied text
499 return trim( $summary );
500 }
501}
const EDIT_FORCE_BOT
Mark the edit a "bot" edit regardless of user rights.
Definition Defines.php:143
const EDIT_INTERNAL
Signal that the page retrieve/save cycle happened entirely in this request.
Definition Defines.php:152
const EDIT_UPDATE
Article is assumed to be pre-existing, fail if it doesn't exist.
Definition Defines.php:131
const EDIT_MINOR
Mark this edit minor, if the user is allowed to do so.
Definition Defines.php:134
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:82
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.
Class for creating new log entries and inserting them into the database.
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()
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:157
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)
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.
Utility class for creating and reading rows in the recentchanges table.
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.
A title formatter service for MediaWiki.
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...
Create User objects.
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 objects (potentially) representing an editable wiki page.
This interface represents the authority associated with the current execution context,...
Definition Authority.php:37
Constants for representing well known causes for page updates.
Service for dealing with the actor table.
Interface for objects representing user identity.
Provide primary and replica IDatabase connections.
Interface for database access objects.
Interface to a relational database.
Definition IDatabase.php:45
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,...