MediaWiki REL1_37
RollbackPage.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Page;
22
39use Message;
40use RawMessage;
41use ReadOnlyMode;
42use RecentChange;
43use StatusValue;
45use TitleValue;
49
56
61 public const CONSTRUCTOR_OPTIONS = [
62 'UseRCPatrol',
63 'DisableAnonTalk',
64 ];
65
67 private $options;
68
71
73 private $userFactory;
74
77
80
83
85 private $hookRunner;
86
89
92
95
97 private $page;
98
100 private $performer;
101
103 private $byUser;
104
106 private $summary = '';
107
109 private $bot = false;
110
112 private $tags = [];
113
129 public function __construct(
136 HookContainer $hookContainer,
143 ) {
144 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
145 $this->options = $options;
146 $this->loadBalancer = $loadBalancer;
147 $this->userFactory = $userFactory;
148 $this->readOnlyMode = $readOnlyMode;
149 $this->revisionStore = $revisionStore;
150 $this->titleFormatter = $titleFormatter;
151 $this->hookRunner = new HookRunner( $hookContainer );
152 $this->wikiPageFactory = $wikiPageFactory;
153 $this->actorMigration = $actorMigration;
154 $this->actorNormalization = $actorNormalization;
155
156 $this->page = $page;
157 $this->performer = $performer;
158 $this->byUser = $byUser;
159 }
160
167 public function setSummary( ?string $summary ): self {
168 $this->summary = $summary ?: '';
169 return $this;
170 }
171
178 public function markAsBot( ?bool $bot ): self {
179 if ( $bot && $this->performer->isAllowedAny( 'markbotedits', 'bot' ) ) {
180 $this->bot = true;
181 } elseif ( !$bot ) {
182 $this->bot = false;
183 }
184 return $this;
185 }
186
195 public function setChangeTags( ?array $tags ): self {
196 $this->tags = $tags ?: [];
197 return $this;
198 }
199
206 $permissionStatus = PermissionStatus::newEmpty();
207 $this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus );
208 $this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus );
209
210 if ( $this->readOnlyMode->isReadOnly() ) {
211 $permissionStatus->fatal( 'readonlytext' );
212 }
213
214 $user = $this->userFactory->newFromAuthority( $this->performer );
215 if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
216 $permissionStatus->fatal( 'actionthrottledtext' );
217 }
218 return $permissionStatus;
219 }
220
230 public function rollbackIfAllowed(): StatusValue {
231 $permissionStatus = $this->authorizeRollback();
232 if ( !$permissionStatus->isGood() ) {
233 return $permissionStatus;
234 }
235 return $this->rollback();
236 }
237
253 public function rollback() {
254 // Begin revision creation cycle by creating a PageUpdater.
255 // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
256 // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
257 $updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer );
258 $currentRevision = $updater->grabParentRevision();
259
260 if ( !$currentRevision ) {
261 // Something wrong... no page?
262 return StatusValue::newFatal( 'notanarticle' );
263 }
264
265 $currentEditor = $currentRevision->getUser( RevisionRecord::RAW );
266 $currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC );
267 // User name given should match up with the top revision.
268 if ( !$this->byUser->equals( $currentEditor ) ) {
269 $result = StatusValue::newGood( [
270 'current-revision-record' => $currentRevision
271 ] );
272 $result->fatal(
273 'alreadyrolled',
274 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
275 htmlspecialchars( $this->byUser->getName() ),
276 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
277 );
278 return $result;
279 }
280
281 $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
282 // T270033 Index renaming
283 $revIndex = $dbw->indexExists( 'revision', 'page_timestamp', __METHOD__ )
284 ? 'page_timestamp'
285 : 'rev_page_timestamp';
286
287 // TODO: move this query to RevisionSelectQueryBuilder when it's available
288 // Get the last edit not by this person...
289 // Note: these may not be public values
290 $actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
291 $targetRevisionRow = $dbw->selectRow(
292 [ 'revision' ] + $actorWhere['tables'],
293 [ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
294 [
295 'rev_page' => $currentRevision->getPageId(),
296 'NOT(' . $actorWhere['conds'] . ')',
297 ],
298 __METHOD__,
299 [
300 'USE INDEX' => [ 'revision' => $revIndex ],
301 'ORDER BY' => [ 'rev_timestamp DESC', 'rev_id DESC' ]
302 ],
303 $actorWhere['joins']
304 );
305
306 if ( $targetRevisionRow === false ) {
307 // No one else ever edited this page
308 return StatusValue::newFatal( 'cantrollback' );
309 } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
310 || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
311 ) {
312 // Only admins can see this text
313 return StatusValue::newFatal( 'notvisiblerev' );
314 }
315
316 // Generate the edit summary if necessary
317 $targetRevision = $this->revisionStore
318 ->getRevisionById( $targetRevisionRow->rev_id, RevisionStore::READ_LATEST );
319
320 // Save
321 $flags = EDIT_UPDATE | EDIT_INTERNAL;
322
323 if ( $this->performer->isAllowed( 'minoredit' ) ) {
324 $flags |= EDIT_MINOR;
325 }
326
327 if ( $this->bot ) {
328 $flags |= EDIT_FORCE_BOT;
329 }
330
331 // TODO: MCR: also log model changes in other slots, in case that becomes possible!
332 $currentContent = $currentRevision->getContent( SlotRecord::MAIN );
333 $targetContent = $targetRevision->getContent( SlotRecord::MAIN );
334 $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
335
336 // Build rollback revision:
337 // Restore old content
338 // TODO: MCR: test this once we can store multiple slots
339 foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
340 $updater->inheritSlot( $slot );
341 }
342
343 // Remove extra slots
344 // TODO: MCR: test this once we can store multiple slots
345 foreach ( $currentRevision->getSlotRoles() as $role ) {
346 if ( !$targetRevision->hasSlot( $role ) ) {
347 $updater->removeSlot( $role );
348 }
349 }
350
351 $updater->setOriginalRevisionId( $targetRevision->getId() );
352 $oldestRevertedRevision = $this->revisionStore->getNextRevision(
353 $targetRevision,
354 RevisionStore::READ_LATEST
355 );
356 if ( $oldestRevertedRevision !== null ) {
357 $updater->markAsRevert(
358 EditResult::REVERT_ROLLBACK,
359 $oldestRevertedRevision->getId(),
360 $currentRevision->getId()
361 );
362 }
363
364 // TODO: this logic should not be in the storage layer, it's here for compatibility
365 // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
366 // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
367 if ( $this->options->get( 'UseRCPatrol' ) &&
368 $this->performer->authorizeWrite( 'autopatrol', $this->page )
369 ) {
370 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
371 }
372
373 $summary = $this->getSummary( $currentRevision, $targetRevision );
374
375 // Actually store the rollback
376 $rev = $updater->saveRevision(
377 CommentStoreComment::newUnsavedComment( $summary ),
378 $flags
379 );
380
381 // This is done even on edit failure to have patrolling in that case (T64157).
382 $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
383
384 if ( !$updater->wasSuccessful() ) {
385 return $updater->getStatus();
386 }
387
388 // Report if the edit was not created because it did not change the content.
389 if ( $updater->isUnchanged() ) {
390 $result = StatusValue::newGood( [
391 'current-revision-record' => $currentRevision
392 ] );
393 $result->fatal(
394 'alreadyrolled',
395 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
396 htmlspecialchars( $this->byUser->getName() ),
397 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
398 );
399 return $result;
400 }
401
402 if ( $changingContentModel ) {
403 // If the content model changed during the rollback,
404 // make sure it gets logged to Special:Log/contentmodel
405 $log = new ManualLogEntry( 'contentmodel', 'change' );
406 $log->setPerformer( $this->performer->getUser() );
407 $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
408 $log->setComment( $summary );
409 $log->setParameters( [
410 '4::oldmodel' => $currentContent->getModel(),
411 '5::newmodel' => $targetContent->getModel(),
412 ] );
413
414 $logId = $log->insert( $dbw );
415 $log->publish( $logId );
416 }
417
418 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
419
420 $this->hookRunner->onRollbackComplete(
421 $wikiPage,
422 $this->performer->getUser(),
423 $targetRevision,
424 $currentRevision
425 );
426
427 return StatusValue::newGood( [
428 'summary' => $summary,
429 'current-revision-record' => $currentRevision,
430 'target-revision-record' => $targetRevision,
431 'newid' => $rev->getId(),
432 'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
433 ] );
434 }
435
443 private function updateRecentChange(
444 IDatabase $dbw,
445 RevisionRecord $current,
446 RevisionRecord $target
447 ) {
448 $set = [];
449 if ( $this->bot ) {
450 // Mark all reverted edits as bot
451 $set['rc_bot'] = 1;
452 }
453
454 if ( $this->options->get( 'UseRCPatrol' ) ) {
455 // Mark all reverted edits as patrolled
456 $set['rc_patrolled'] = RecentChange::PRC_AUTOPATROLLED;
457 }
458
459 if ( $set ) {
460 $actorId = $this->actorNormalization
461 ->acquireActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
462 $dbw->update(
463 'recentchanges',
464 $set,
465 [ /* WHERE */
466 'rc_cur_id' => $current->getPageId(),
467 'rc_timestamp > ' . $dbw->addQuotes( $dbw->timestamp( $target->getTimestamp() ) ),
468 'rc_actor' => $actorId
469 ],
470 __METHOD__
471 );
472 }
473 }
474
482 private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
483 $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
484 if ( !$this->summary ) {
485 if ( !$currentEditorForPublic ) { // no public user name
486 $summary = MessageValue::new( 'revertpage-nouser' );
487 } elseif ( $this->options->get( 'DisableAnonTalk' ) && !$currentEditorForPublic->isRegistered() ) {
488 $summary = MessageValue::new( 'revertpage-anon' );
489 } else {
490 $summary = MessageValue::new( 'revertpage' );
491 }
492 } else {
493 $summary = $this->summary;
494 }
495
496 $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
497 // Allow the custom summary to use the same args as the default message
498 $args = [
499 $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
500 $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
501 $target->getId(),
503 $current->getId(),
505 ];
506 if ( $summary instanceof MessageValue ) {
507 $summary = ( new Converter() )->convertMessageValue( $summary );
508 $summary = $summary->params( $args )->inContentLanguage()->text();
509 } else {
510 $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
511 }
512
513 // Trim spaces on user supplied text
514 return trim( $summary );
515 }
516}
const EDIT_FORCE_BOT
Definition Defines.php:129
const EDIT_INTERNAL
Definition Defines.php:132
const EDIT_UPDATE
Definition Defines.php:126
const EDIT_MINOR
Definition Defines.php:127
if(ini_get('mbstring.func_overload')) if(!defined('MW_ENTRY_POINT'))
Pre-config setup: Before loading LocalSettings.php.
Definition Setup.php:88
This is not intended to be a long-term part of MediaWiki; it will be deprecated and removed once acto...
Value object for a comment stored by CommentStore.
Class for creating new log entries and inserting them into the database.
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...
Converter between Message and MessageValue.
Definition Converter.php:18
getSummary(RevisionRecord $current, RevisionRecord $target)
Generate and format summary for the rollback.
ActorNormalization $actorNormalization
rollback()
Backend implementation of rollbackIfAllowed().
WikiPageFactory $wikiPageFactory
setSummary(?string $summary)
Set custom edit summary.
updateRecentChange(IDatabase $dbw, RevisionRecord $current, RevisionRecord $target)
Set patrolling and bot flag on the edits, which gets rolled back.
authorizeRollback()
Authorize the rollback.
setChangeTags(?array $tags)
Change tags to apply to the rollback.
__construct(ServiceOptions $options, ILoadBalancer $loadBalancer, UserFactory $userFactory, ReadOnlyMode $readOnlyMode, RevisionStore $revisionStore, TitleFormatter $titleFormatter, HookContainer $hookContainer, WikiPageFactory $wikiPageFactory, ActorMigration $actorMigration, ActorNormalization $actorNormalization, PageIdentity $page, Authority $performer, UserIdentity $byUser)
UserIdentity $byUser
who made the edits we are rolling back
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.
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.
Creates User objects.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:138
static dateTimeParam(string $dateTime)
Definition Message.php:1134
Variant of the Message class.
A service class for fetching the wiki's current read-only mode.
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.
Represents a page (or page fragment) title within MediaWiki.
Value object representing a message for i18n.
Interface for objects (potentially) representing an editable wiki page.
This interface represents the authority associated the current execution context, such as a web reque...
Definition Authority.php:37
Service for dealing with the actor table.
Interface for objects representing user identity.
A title formatter service for MediaWiki.
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
update( $table, $set, $conds, $fname=__METHOD__, $options=[])
Update all rows in a table that match a given condition.
addQuotes( $s)
Escape and quote a raw value string for use in a SQL query.
timestamp( $ts=0)
Convert a timestamp in one of the formats accepted by ConvertibleTimestamp to the format used for ins...
Database cluster connection, tracking, load balancing, and transaction manager interface.
if( $line===false) $args
Definition mcc.php:124
const DB_PRIMARY
Definition defines.php:27