MediaWiki 1.43.1
RollbackPage.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Page;
22
44use RecentChange;
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
172 $permissionStatus = PermissionStatus::newEmpty();
173 $this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus );
174 $this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus );
175
176 if ( $this->readOnlyMode->isReadOnly() ) {
177 $permissionStatus->fatal( 'readonlytext' );
178 }
179
180 return $permissionStatus;
181 }
182
192 public function rollbackIfAllowed(): StatusValue {
193 $permissionStatus = $this->authorizeRollback();
194 if ( !$permissionStatus->isGood() ) {
195 return $permissionStatus;
196 }
197 return $this->rollback();
198 }
199
215 public function rollback() {
216 // Begin revision creation cycle by creating a PageUpdater.
217 // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
218 // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
219 $updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer );
220 $currentRevision = $updater->grabParentRevision();
221
222 if ( !$currentRevision ) {
223 // Something wrong... no page?
224 return StatusValue::newFatal( 'notanarticle' );
225 }
226
227 $currentEditor = $currentRevision->getUser( RevisionRecord::RAW );
228 $currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC );
229 // User name given should match up with the top revision.
230 if ( !$this->byUser->equals( $currentEditor ) ) {
231 $result = StatusValue::newGood( [
232 'current-revision-record' => $currentRevision
233 ] );
234 $result->fatal(
235 'alreadyrolled',
236 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
237 htmlspecialchars( $this->byUser->getName() ),
238 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
239 );
240 return $result;
241 }
242
243 $dbw = $this->dbProvider->getPrimaryDatabase();
244
245 // Get the last edit not by this person...
246 // Note: these may not be public values
247 $actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
248 $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $dbw )
249 ->where( [ 'rev_page' => $currentRevision->getPageId(), 'NOT(' . $actorWhere['conds'] . ')' ] )
250 ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
251 ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC );
252 $targetRevisionRow = $queryBuilder->caller( __METHOD__ )->fetchRow();
253
254 if ( $targetRevisionRow === false ) {
255 // No one else ever edited this page
256 return StatusValue::newFatal( 'cantrollback' );
257 } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
258 || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
259 ) {
260 // Only admins can see this text
261 return StatusValue::newFatal( 'notvisiblerev' );
262 }
263
264 // Generate the edit summary if necessary
265 $targetRevision = $this->revisionStore
266 ->getRevisionById( $targetRevisionRow->rev_id, IDBAccessObject::READ_LATEST );
267
268 // Save
269 $flags = EDIT_UPDATE | EDIT_INTERNAL;
270
271 if ( $this->performer->isAllowed( 'minoredit' ) ) {
272 $flags |= EDIT_MINOR;
273 }
274
275 if ( $this->bot ) {
276 $flags |= EDIT_FORCE_BOT;
277 }
278
279 // TODO: MCR: also log model changes in other slots, in case that becomes possible!
280 $currentContent = $currentRevision->getContent( SlotRecord::MAIN );
281 $targetContent = $targetRevision->getContent( SlotRecord::MAIN );
282 $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
283
284 // Build rollback revision:
285 // Restore old content
286 // TODO: MCR: test this once we can store multiple slots
287 foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
288 $updater->inheritSlot( $slot );
289 }
290
291 // Remove extra slots
292 // TODO: MCR: test this once we can store multiple slots
293 foreach ( $currentRevision->getSlotRoles() as $role ) {
294 if ( !$targetRevision->hasSlot( $role ) ) {
295 $updater->removeSlot( $role );
296 }
297 }
298
299 $updater->markAsRevert(
300 EditResult::REVERT_ROLLBACK,
301 $currentRevision->getId(),
302 $targetRevision->getId()
303 );
304
305 // TODO: this logic should not be in the storage layer, it's here for compatibility
306 // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
307 // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
308 if ( $this->options->get( MainConfigNames::UseRCPatrol ) &&
309 $this->performer->authorizeWrite( 'autopatrol', $this->page )
310 ) {
311 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
312 }
313
314 $summary = $this->getSummary( $currentRevision, $targetRevision );
315
316 // Actually store the rollback
317 $rev = $updater->addTags( $this->tags )->saveRevision(
318 CommentStoreComment::newUnsavedComment( $summary ),
319 $flags
320 );
321
322 // This is done even on edit failure to have patrolling in that case (T64157).
323 $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
324
325 if ( !$updater->wasSuccessful() ) {
326 return $updater->getStatus();
327 }
328
329 // Report if the edit was not created because it did not change the content.
330 if ( !$updater->wasRevisionCreated() ) {
331 $result = StatusValue::newGood( [
332 'current-revision-record' => $currentRevision
333 ] );
334 $result->fatal(
335 'alreadyrolled',
336 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
337 htmlspecialchars( $this->byUser->getName() ),
338 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
339 );
340 return $result;
341 }
342
343 if ( $changingContentModel ) {
344 // If the content model changed during the rollback,
345 // make sure it gets logged to Special:Log/contentmodel
346 $log = new ManualLogEntry( 'contentmodel', 'change' );
347 $log->setPerformer( $this->performer->getUser() );
348 $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
349 $log->setComment( $summary );
350 $log->setParameters( [
351 '4::oldmodel' => $currentContent->getModel(),
352 '5::newmodel' => $targetContent->getModel(),
353 ] );
354
355 $logId = $log->insert( $dbw );
356 $log->publish( $logId );
357 }
358
359 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
360
361 $this->hookRunner->onRollbackComplete(
362 $wikiPage,
363 $this->performer->getUser(),
364 $targetRevision,
365 $currentRevision
366 );
367
368 return StatusValue::newGood( [
369 'summary' => $summary,
370 'current-revision-record' => $currentRevision,
371 'target-revision-record' => $targetRevision,
372 'newid' => $rev->getId(),
373 'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
374 ] );
375 }
376
384 private function updateRecentChange(
385 IDatabase $dbw,
386 RevisionRecord $current,
387 RevisionRecord $target
388 ) {
389 $useRCPatrol = $this->options->get( MainConfigNames::UseRCPatrol );
390 if ( !$this->bot && !$useRCPatrol ) {
391 return;
392 }
393
394 $actorId = $this->actorNormalization->findActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
395 $timestamp = $dbw->timestamp( $target->getTimestamp() );
396 $rows = $dbw->newSelectQueryBuilder()
397 ->select( [ 'rc_id', 'rc_patrolled' ] )
398 ->from( 'recentchanges' )
399 ->where( [ 'rc_cur_id' => $current->getPageId(), 'rc_actor' => $actorId, ] )
400 ->andWhere( $dbw->buildComparison( '>', [
401 'rc_timestamp' => $timestamp,
402 'rc_this_oldid' => $target->getId(),
403 ] ) )
404 ->caller( __METHOD__ )->fetchResultSet();
405
406 $all = [];
407 $patrolled = [];
408 $unpatrolled = [];
409 foreach ( $rows as $row ) {
410 $all[] = (int)$row->rc_id;
411 if ( $row->rc_patrolled ) {
412 $patrolled[] = (int)$row->rc_id;
413 } else {
414 $unpatrolled[] = (int)$row->rc_id;
415 }
416 }
417
418 if ( $useRCPatrol && $this->bot ) {
419 // Mark all reverted edits as if they were made by a bot
420 // Also mark only unpatrolled reverted edits as patrolled
421 if ( $unpatrolled ) {
423 ->update( 'recentchanges' )
424 ->set( [ 'rc_bot' => 1, 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ] )
425 ->where( [ 'rc_id' => $unpatrolled ] )
426 ->caller( __METHOD__ )->execute();
427 }
428 if ( $patrolled ) {
430 ->update( 'recentchanges' )
431 ->set( [ 'rc_bot' => 1 ] )
432 ->where( [ 'rc_id' => $patrolled ] )
433 ->caller( __METHOD__ )->execute();
434 }
435 } elseif ( $useRCPatrol ) {
436 // Mark only unpatrolled reverted edits as patrolled
437 if ( $unpatrolled ) {
439 ->update( 'recentchanges' )
440 ->set( [ 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ] )
441 ->where( [ 'rc_id' => $unpatrolled ] )
442 ->caller( __METHOD__ )->execute();
443 }
444 } else {
445 // Edit is from a bot
446 if ( $all ) {
448 ->update( 'recentchanges' )
449 ->set( [ 'rc_bot' => 1 ] )
450 ->where( [ 'rc_id' => $all ] )
451 ->caller( __METHOD__ )->execute();
452 }
453 }
454 }
455
463 private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
464 $revisionsBetween = $this->revisionStore->countRevisionsBetween(
465 $current->getPageId(),
466 $target,
467 $current,
468 1000,
469 RevisionStore::INCLUDE_NEW
470 );
471 $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
472 if ( $this->summary === '' ) {
473 if ( !$currentEditorForPublic ) { // no public user name
474 $summary = MessageValue::new( 'revertpage-nouser' );
475 } elseif ( $this->options->get( MainConfigNames::DisableAnonTalk ) &&
476 !$currentEditorForPublic->isRegistered() ) {
477 $summary = MessageValue::new( 'revertpage-anon' );
478 } else {
479 $summary = MessageValue::new( 'revertpage' );
480 }
481 } else {
482 $summary = $this->summary;
483 }
484
485 $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
486 // Allow the custom summary to use the same args as the default message
487 $args = [
488 $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
489 $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
490 $target->getId(),
491 Message::dateTimeParam( $target->getTimestamp() ),
492 $current->getId(),
493 Message::dateTimeParam( $current->getTimestamp() ),
494 $revisionsBetween,
495 ];
496 if ( $summary instanceof MessageValue ) {
497 $summary = ( new Converter() )->convertMessageValue( $summary );
498 $summary = $summary->params( $args )->inContentLanguage()->text();
499 } else {
500 $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
501 }
502
503 // Trim spaces on user supplied text
504 return trim( $summary );
505 }
506}
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()
Converter between Message and MessageValue.
Definition Converter.php:13
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:156
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.
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...
Creates 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:48
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,...