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