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