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