MediaWiki REL1_39
RollbackPage.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Page;
22
40use Message;
41use RawMessage;
42use ReadOnlyMode;
43use RecentChange;
44use StatusValue;
46use TitleValue;
50
57
62 public const CONSTRUCTOR_OPTIONS = [
65 ];
66
68 private $options;
69
71 private $loadBalancer;
72
74 private $userFactory;
75
77 private $readOnlyMode;
78
80 private $titleFormatter;
81
83 private $revisionStore;
84
86 private $hookRunner;
87
89 private $wikiPageFactory;
90
92 private $actorMigration;
93
95 private $actorNormalization;
96
98 private $page;
99
101 private $performer;
102
104 private $byUser;
105
107 private $summary = '';
108
110 private $bot = false;
111
113 private $tags = [];
114
131 public function __construct(
132 ServiceOptions $options,
133 ILoadBalancer $loadBalancer,
134 UserFactory $userFactory,
135 ReadOnlyMode $readOnlyMode,
136 RevisionStore $revisionStore,
137 TitleFormatter $titleFormatter,
138 HookContainer $hookContainer,
139 WikiPageFactory $wikiPageFactory,
140 ActorMigration $actorMigration,
141 ActorNormalization $actorNormalization,
142 PageIdentity $page,
143 Authority $performer,
144 UserIdentity $byUser
145 ) {
146 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
147 $this->options = $options;
148 $this->loadBalancer = $loadBalancer;
149 $this->userFactory = $userFactory;
150 $this->readOnlyMode = $readOnlyMode;
151 $this->revisionStore = $revisionStore;
152 $this->titleFormatter = $titleFormatter;
153 $this->hookRunner = new HookRunner( $hookContainer );
154 $this->wikiPageFactory = $wikiPageFactory;
155 $this->actorMigration = $actorMigration;
156 $this->actorNormalization = $actorNormalization;
157
158 $this->page = $page;
159 $this->performer = $performer;
160 $this->byUser = $byUser;
161 }
162
169 public function setSummary( ?string $summary ): self {
170 $this->summary = $summary ?? '';
171 return $this;
172 }
173
180 public function markAsBot( ?bool $bot ): self {
181 if ( $bot && $this->performer->isAllowedAny( 'markbotedits', 'bot' ) ) {
182 $this->bot = true;
183 } elseif ( !$bot ) {
184 $this->bot = false;
185 }
186 return $this;
187 }
188
197 public function setChangeTags( ?array $tags ): self {
198 $this->tags = $tags ?: [];
199 return $this;
200 }
201
208 $permissionStatus = PermissionStatus::newEmpty();
209 $this->performer->authorizeWrite( 'edit', $this->page, $permissionStatus );
210 $this->performer->authorizeWrite( 'rollback', $this->page, $permissionStatus );
211
212 if ( $this->readOnlyMode->isReadOnly() ) {
213 $permissionStatus->fatal( 'readonlytext' );
214 }
215
216 $user = $this->userFactory->newFromAuthority( $this->performer );
217 if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
218 $permissionStatus->fatal( 'actionthrottledtext' );
219 }
220 return $permissionStatus;
221 }
222
232 public function rollbackIfAllowed(): StatusValue {
233 $permissionStatus = $this->authorizeRollback();
234 if ( !$permissionStatus->isGood() ) {
235 return $permissionStatus;
236 }
237 return $this->rollback();
238 }
239
255 public function rollback() {
256 // Begin revision creation cycle by creating a PageUpdater.
257 // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
258 // TODO: move PageUpdater to PageStore or PageUpdaterFactory or something?
259 $updater = $this->wikiPageFactory->newFromTitle( $this->page )->newPageUpdater( $this->performer );
260 $currentRevision = $updater->grabParentRevision();
261
262 if ( !$currentRevision ) {
263 // Something wrong... no page?
264 return StatusValue::newFatal( 'notanarticle' );
265 }
266
267 $currentEditor = $currentRevision->getUser( RevisionRecord::RAW );
268 $currentEditorForPublic = $currentRevision->getUser( RevisionRecord::FOR_PUBLIC );
269 // User name given should match up with the top revision.
270 if ( !$this->byUser->equals( $currentEditor ) ) {
271 $result = StatusValue::newGood( [
272 'current-revision-record' => $currentRevision
273 ] );
274 $result->fatal(
275 'alreadyrolled',
276 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
277 htmlspecialchars( $this->byUser->getName() ),
278 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
279 );
280 return $result;
281 }
282
283 $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
284
285 // TODO: move this query to RevisionSelectQueryBuilder when it's available
286 // Get the last edit not by this person...
287 // Note: these may not be public values
288 $actorWhere = $this->actorMigration->getWhere( $dbw, 'rev_user', $currentEditor );
289 $targetRevisionRow = $dbw->selectRow(
290 [ 'revision' ] + $actorWhere['tables'],
291 [ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
292 [
293 'rev_page' => $currentRevision->getPageId(),
294 'NOT(' . $actorWhere['conds'] . ')',
295 ],
296 __METHOD__,
297 [
298 'USE INDEX' => [ 'revision' => 'rev_page_timestamp' ],
299 'ORDER BY' => [ 'rev_timestamp DESC', 'rev_id DESC' ]
300 ],
301 $actorWhere['joins']
302 );
303
304 if ( $targetRevisionRow === false ) {
305 // No one else ever edited this page
306 return StatusValue::newFatal( 'cantrollback' );
307 } elseif ( $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_TEXT
308 || $targetRevisionRow->rev_deleted & RevisionRecord::DELETED_USER
309 ) {
310 // Only admins can see this text
311 return StatusValue::newFatal( 'notvisiblerev' );
312 }
313
314 // Generate the edit summary if necessary
315 $targetRevision = $this->revisionStore
316 ->getRevisionById( $targetRevisionRow->rev_id, RevisionStore::READ_LATEST );
317
318 // Save
319 $flags = EDIT_UPDATE | EDIT_INTERNAL;
320
321 if ( $this->performer->isAllowed( 'minoredit' ) ) {
322 $flags |= EDIT_MINOR;
323 }
324
325 if ( $this->bot ) {
326 $flags |= EDIT_FORCE_BOT;
327 }
328
329 // TODO: MCR: also log model changes in other slots, in case that becomes possible!
330 $currentContent = $currentRevision->getContent( SlotRecord::MAIN );
331 $targetContent = $targetRevision->getContent( SlotRecord::MAIN );
332 $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
333
334 // Build rollback revision:
335 // Restore old content
336 // TODO: MCR: test this once we can store multiple slots
337 foreach ( $targetRevision->getSlots()->getSlots() as $slot ) {
338 $updater->inheritSlot( $slot );
339 }
340
341 // Remove extra slots
342 // TODO: MCR: test this once we can store multiple slots
343 foreach ( $currentRevision->getSlotRoles() as $role ) {
344 if ( !$targetRevision->hasSlot( $role ) ) {
345 $updater->removeSlot( $role );
346 }
347 }
348
349 $updater->markAsRevert(
350 EditResult::REVERT_ROLLBACK,
351 $currentRevision->getId(),
352 $targetRevision->getId()
353 );
354
355 // TODO: this logic should not be in the storage layer, it's here for compatibility
356 // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
357 // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
358 if ( $this->options->get( MainConfigNames::UseRCPatrol ) &&
359 $this->performer->authorizeWrite( 'autopatrol', $this->page )
360 ) {
361 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
362 }
363
364 $summary = $this->getSummary( $currentRevision, $targetRevision );
365
366 // Actually store the rollback
367 $rev = $updater->saveRevision(
368 CommentStoreComment::newUnsavedComment( $summary ),
369 $flags
370 );
371
372 // This is done even on edit failure to have patrolling in that case (T64157).
373 $this->updateRecentChange( $dbw, $currentRevision, $targetRevision );
374
375 if ( !$updater->wasSuccessful() ) {
376 return $updater->getStatus();
377 }
378
379 // Report if the edit was not created because it did not change the content.
380 if ( !$updater->wasRevisionCreated() ) {
381 $result = StatusValue::newGood( [
382 'current-revision-record' => $currentRevision
383 ] );
384 $result->fatal(
385 'alreadyrolled',
386 htmlspecialchars( $this->titleFormatter->getPrefixedText( $this->page ) ),
387 htmlspecialchars( $this->byUser->getName() ),
388 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
389 );
390 return $result;
391 }
392
393 if ( $changingContentModel ) {
394 // If the content model changed during the rollback,
395 // make sure it gets logged to Special:Log/contentmodel
396 $log = new ManualLogEntry( 'contentmodel', 'change' );
397 $log->setPerformer( $this->performer->getUser() );
398 $log->setTarget( new TitleValue( $this->page->getNamespace(), $this->page->getDBkey() ) );
399 $log->setComment( $summary );
400 $log->setParameters( [
401 '4::oldmodel' => $currentContent->getModel(),
402 '5::newmodel' => $targetContent->getModel(),
403 ] );
404
405 $logId = $log->insert( $dbw );
406 $log->publish( $logId );
407 }
408
409 $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
410
411 $this->hookRunner->onRollbackComplete(
412 $wikiPage,
413 $this->performer->getUser(),
414 $targetRevision,
415 $currentRevision
416 );
417
418 return StatusValue::newGood( [
419 'summary' => $summary,
420 'current-revision-record' => $currentRevision,
421 'target-revision-record' => $targetRevision,
422 'newid' => $rev->getId(),
423 'tags' => array_merge( $this->tags, $updater->getEditResult()->getRevertTags() )
424 ] );
425 }
426
434 private function updateRecentChange(
435 IDatabase $dbw,
436 RevisionRecord $current,
437 RevisionRecord $target
438 ) {
439 $useRCPatrol = $this->options->get( MainConfigNames::UseRCPatrol );
440 if ( !$this->bot && !$useRCPatrol ) {
441 return;
442 }
443
444 $actorId = $this->actorNormalization
445 ->acquireActorId( $current->getUser( RevisionRecord::RAW ), $dbw );
446 $timestamp = $dbw->timestamp( $target->getTimestamp() );
447 $rows = $dbw->select(
448 'recentchanges',
449 [ 'rc_id', 'rc_patrolled' ],
450 [
451 'rc_cur_id' => $current->getPageId(),
452 $dbw->makeList( [
453 'rc_timestamp > ' . $dbw->addQuotes( $timestamp ),
454 $dbw->makeList( [
455 'rc_timestamp' => $timestamp,
456 'rc_this_oldid > ' . $dbw->addQuotes( $target->getId() ),
457 ], IDatabase::LIST_AND ),
458 ], IDatabase::LIST_OR ),
459 'rc_actor' => $actorId,
460 ],
461 __METHOD__
462 );
463
464 $all = [];
465 $patrolled = [];
466 $unpatrolled = [];
467 foreach ( $rows as $row ) {
468 $all[] = (int)$row->rc_id;
469 if ( $row->rc_patrolled ) {
470 $patrolled[] = (int)$row->rc_id;
471 } else {
472 $unpatrolled[] = (int)$row->rc_id;
473 }
474 }
475
476 if ( $useRCPatrol && $this->bot ) {
477 // Mark all reverted edits as if they were made by a bot
478 // Also mark only unpatrolled reverted edits as patrolled
479 if ( $unpatrolled ) {
480 $dbw->update(
481 'recentchanges',
482 [ 'rc_bot' => 1, 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ],
483 [ 'rc_id' => $unpatrolled ],
484 __METHOD__
485 );
486 }
487 if ( $patrolled ) {
488 $dbw->update(
489 'recentchanges',
490 [ 'rc_bot' => 1 ],
491 [ 'rc_id' => $patrolled ],
492 __METHOD__
493 );
494 }
495 } elseif ( $useRCPatrol ) {
496 // Mark only unpatrolled reverted edits as patrolled
497 if ( $unpatrolled ) {
498 $dbw->update(
499 'recentchanges',
500 [ 'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED ],
501 [ 'rc_id' => $unpatrolled ],
502 __METHOD__
503 );
504 }
505 } else {
506 // Edit is from a bot
507 if ( $all ) {
508 $dbw->update(
509 'recentchanges',
510 [ 'rc_bot' => 1 ],
511 [ 'rc_id' => $all ],
512 __METHOD__
513 );
514 }
515 }
516 }
517
525 private function getSummary( RevisionRecord $current, RevisionRecord $target ): string {
526 $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
527 if ( $this->summary === '' ) {
528 if ( !$currentEditorForPublic ) { // no public user name
529 $summary = MessageValue::new( 'revertpage-nouser' );
530 } elseif ( $this->options->get( MainConfigNames::DisableAnonTalk ) &&
531 !$currentEditorForPublic->isRegistered() ) {
532 $summary = MessageValue::new( 'revertpage-anon' );
533 } else {
534 $summary = MessageValue::new( 'revertpage' );
535 }
536 } else {
537 $summary = $this->summary;
538 }
539
540 $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
541 // Allow the custom summary to use the same args as the default message
542 $args = [
543 $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
544 $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
545 $target->getId(),
546 Message::dateTimeParam( $target->getTimestamp() ),
547 $current->getId(),
548 Message::dateTimeParam( $current->getTimestamp() ),
549 ];
550 if ( $summary instanceof MessageValue ) {
551 $summary = ( new Converter() )->convertMessageValue( $summary );
552 $summary = $summary->params( $args )->inContentLanguage()->text();
553 } else {
554 $summary = ( new RawMessage( $summary, $args ) )->inContentLanguage()->plain();
555 }
556
557 // Trim spaces on user supplied text
558 return trim( $summary );
559 }
560}
getUser()
const EDIT_FORCE_BOT
Definition Defines.php:130
const EDIT_INTERNAL
Definition Defines.php:133
const EDIT_UPDATE
Definition Defines.php:127
const EDIT_MINOR
Definition Defines.php:128
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition WebStart.php:82
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...
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
Backend logic for performing a page rollback action.
rollback()
Backend implementation of rollbackIfAllowed().
setSummary(?string $summary)
Set custom edit summary.
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)
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.
Creates User objects.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:140
static dateTimeParam(string $dateTime)
Definition Message.php:1178
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.
addQuotes( $s)
Escape and quote a raw value string for use in a SQL query.
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:39
selectRow( $table, $vars, $conds, $fname=__METHOD__, $options=[], $join_conds=[])
Wrapper to IDatabase::select() that only fetches one row (via LIMIT)
select( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Execute a SELECT query constructed using the various parameters provided.
update( $table, $set, $conds, $fname=__METHOD__, $options=[])
Update all rows in a table that match a given condition.
Create and track the database connections and transactions for a given database cluster.
makeList(array $a, $mode=self::LIST_COMMA)
Makes an encoded list of strings from an array.
timestamp( $ts=0)
Convert a timestamp in one of the formats accepted by ConvertibleTimestamp to the format used for ins...
if( $line===false) $args
Definition mcc.php:124
const DB_PRIMARY
Definition defines.php:28