MediaWiki  master
MergeHistory.php
Go to the documentation of this file.
1 <?php
2 
38 use Wikimedia\Timestamp\TimestampException;
39 
46 class MergeHistory {
47 
49  public const REVISION_LIMIT = 5000;
50 
52  protected $source;
53 
55  protected $dest;
56 
58  protected $dbw;
59 
61  private $timestamp;
62 
67  protected $maxTimestamp = false;
68 
73  protected $timeWhere = false;
74 
79  protected $timestampLimit = false;
80 
82  protected $revisionsMerged;
83 
86 
88  private $revisionStore;
89 
92 
94  private $spamChecker;
95 
97  private $hookRunner;
98 
101 
104 
106  private $titleFactory;
107 
122  public function __construct(
125  ?string $timestamp,
126  ILoadBalancer $loadBalancer,
131  HookContainer $hookContainer,
135  ) {
136  // Save the parameters
137  $this->source = $source;
138  $this->dest = $dest;
139  $this->timestamp = $timestamp;
140 
141  // Get the database
142  $this->dbw = $loadBalancer->getConnection( DB_PRIMARY );
143 
144  $this->contentHandlerFactory = $contentHandlerFactory;
145  $this->revisionStore = $revisionStore;
146  $this->watchedItemStore = $watchedItemStore;
147  $this->spamChecker = $spamChecker;
148  $this->hookRunner = new HookRunner( $hookContainer );
149  $this->wikiPageFactory = $wikiPageFactory;
150  $this->titleFormatter = $titleFormatter;
151  $this->titleFactory = $titleFactory;
152  }
153 
158  public function getRevisionCount() {
159  $count = $this->dbw->selectRowCount( 'revision', '1',
160  [ 'rev_page' => $this->source->getId(), $this->getTimeWhere() ],
161  __METHOD__,
162  [ 'LIMIT' => self::REVISION_LIMIT + 1 ]
163  );
164 
165  return $count;
166  }
167 
173  public function getMergedRevisionCount() {
174  return $this->revisionsMerged;
175  }
176 
183  private function authorizeInternal(
184  callable $authorizer,
185  Authority $performer,
186  string $reason
187  ) {
188  $status = PermissionStatus::newEmpty();
189 
190  $authorizer( 'edit', $this->source, $status );
191  $authorizer( 'edit', $this->dest, $status );
192 
193  // Anti-spam
194  if ( $this->spamChecker->checkSummary( $reason ) !== false ) {
195  // This is kind of lame, won't display nice
196  $status->fatal( 'spamprotectiontext' );
197  }
198 
199  // Check mergehistory permission
200  if ( !$performer->isAllowed( 'mergehistory' ) ) {
201  // User doesn't have the right to merge histories
202  $status->fatal( 'mergehistory-fail-permission' );
203  }
204  return $status;
205  }
206 
218  public function probablyCanMerge( Authority $performer, string $reason = null ): PermissionStatus {
219  return $this->authorizeInternal(
220  static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) {
221  return $performer->probablyCan( $action, $target, $status );
222  },
223  $performer,
224  $reason
225  );
226  }
227 
239  public function authorizeMerge( Authority $performer, string $reason = null ): PermissionStatus {
240  return $this->authorizeInternal(
241  static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) {
242  return $performer->authorizeWrite( $action, $target, $status );
243  },
244  $performer,
245  $reason
246  );
247  }
248 
256  public function isValidMerge() {
257  $status = new Status();
258 
259  // If either article ID is 0, then revisions cannot be reliably selected
260  if ( $this->source->getId() === 0 ) {
261  $status->fatal( 'mergehistory-fail-invalid-source' );
262  }
263  if ( $this->dest->getId() === 0 ) {
264  $status->fatal( 'mergehistory-fail-invalid-dest' );
265  }
266 
267  // Make sure page aren't the same
268  if ( $this->source->isSamePageAs( $this->dest ) ) {
269  $status->fatal( 'mergehistory-fail-self-merge' );
270  }
271 
272  // Make sure the timestamp is valid
273  if ( !$this->getTimestampLimit() ) {
274  $status->fatal( 'mergehistory-fail-bad-timestamp' );
275  }
276 
277  // $this->timestampLimit must be older than $this->maxTimestamp
278  if ( $this->getTimestampLimit() > $this->getMaxTimestamp() ) {
279  $status->fatal( 'mergehistory-fail-timestamps-overlap' );
280  }
281 
282  // Check that there are not too many revisions to move
283  if ( $this->getTimestampLimit() && $this->getRevisionCount() > self::REVISION_LIMIT ) {
284  $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
285  }
286 
287  return $status;
288  }
289 
304  public function merge( Authority $performer, $reason = '' ) {
306 
307  $status = new Status();
308 
309  // Check validity and permissions required for merge
310  $validCheck = $this->isValidMerge(); // Check this first to check for null pages
311  if ( !$validCheck->isOK() ) {
312  return $validCheck;
313  }
314  $permCheck = $this->authorizeMerge( $performer, $reason );
315  if ( !$permCheck->isOK() ) {
316  return Status::wrap( $permCheck );
317  }
318 
319  $this->dbw->startAtomic( __METHOD__ );
320 
321  $this->dbw->update(
322  'revision',
323  [ 'rev_page' => $this->dest->getId() ],
324  [ 'rev_page' => $this->source->getId(), $this->getTimeWhere() ],
325  __METHOD__
326  );
327 
328  // Check if this did anything
329  $this->revisionsMerged = $this->dbw->affectedRows();
330  if ( $this->revisionsMerged < 1 ) {
331  $this->dbw->endAtomic( __METHOD__ );
332  $status->fatal( 'mergehistory-fail-no-change' );
333 
334  return $status;
335  }
336 
337  // Update denormalized revactor_page too
339  $this->dbw->update(
340  'revision_actor_temp',
341  [ 'revactor_page' => $this->dest->getId() ],
342  [
343  'revactor_page' => $this->source->getId(),
344  // Slightly hacky, but should work given the values assigned in this class
345  str_replace( 'rev_timestamp', 'revactor_timestamp', $this->getTimeWhere() )
346  ],
347  __METHOD__
348  );
349  }
350 
351  $haveRevisions = $this->dbw->lockForUpdate(
352  'revision',
353  [ 'rev_page' => $this->source->getId() ],
354  __METHOD__
355  );
356 
357  $legacySource = $this->titleFactory->castFromPageIdentity( $this->source );
358  $legacyDest = $this->titleFactory->castFromPageIdentity( $this->dest );
359 
360  // Update source page, histories and invalidate caches
361  if ( !$haveRevisions ) {
362  if ( $reason ) {
363  $reason = wfMessage(
364  'mergehistory-comment',
365  $this->titleFormatter->getPrefixedText( $this->source ),
366  $this->titleFormatter->getPrefixedText( $this->dest ),
367  $reason
368  )->inContentLanguage()->text();
369  } else {
370  $reason = wfMessage(
371  'mergehistory-autocomment',
372  $this->titleFormatter->getPrefixedText( $this->source ),
373  $this->titleFormatter->getPrefixedText( $this->dest )
374  )->inContentLanguage()->text();
375  }
376 
377  $this->updateSourcePage( $status, $performer->getUser(), $reason );
378 
379  } else {
380  $legacySource->invalidateCache();
381  }
382  $legacyDest->invalidateCache();
383 
384  // Duplicate watchers of the old article to the new article
385  $this->watchedItemStore->duplicateAllAssociatedEntries( $this->source, $this->dest );
386 
387  // Update our logs
388  $logEntry = new ManualLogEntry( 'merge', 'merge' );
389  $logEntry->setPerformer( $performer->getUser() );
390  $logEntry->setComment( $reason );
391  $logEntry->setTarget( TitleValue::newFromPage( $this->source ) );
392  $logEntry->setParameters( [
393  '4::dest' => $this->titleFormatter->getPrefixedText( $this->dest ),
394  '5::mergepoint' => $this->getTimestampLimit()->getTimestamp( TS_MW )
395  ] );
396  $logId = $logEntry->insert();
397  $logEntry->publish( $logId );
398 
399  $this->hookRunner->onArticleMergeComplete( $legacySource, $legacyDest );
400 
401  $this->dbw->endAtomic( __METHOD__ );
402 
403  return $status;
404  }
405 
419  private function updateSourcePage( $status, $user, $reason ) {
420  $deleteSource = false;
421  $legacySourceTitle = $this->titleFactory->castFromPageIdentity( $this->source );
422  $legacyDestTitle = $this->titleFactory->castFromPageIdentity( $this->dest );
423  $sourceModel = $legacySourceTitle->getContentModel();
424  $contentHandler = $this->contentHandlerFactory->getContentHandler( $sourceModel );
425 
426  if ( !$contentHandler->supportsRedirects() ) {
427  $deleteSource = true;
428  $newContent = $contentHandler->makeEmptyContent();
429  } else {
430  $msg = wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain();
431  $newContent = $contentHandler->makeRedirectContent( $legacyDestTitle, $msg );
432  }
433 
434  if ( !$newContent instanceof Content ) {
435  // Handler supports redirect but cannot create new redirect content?
436  // Not possible to proceed without Content.
437 
438  // @todo. Remove this once there's no evidence it's happening or if it's
439  // determined all violating handlers have been fixed.
440  // This is mostly kept because previous code was also blindly checking
441  // existing of the Content for both content models that supports redirects
442  // and those that that don't, so it's hard to know what it was masking.
443  $logger = MediaWiki\Logger\LoggerFactory::getInstance( 'ContentHandler' );
444  $logger->warning(
445  'ContentHandler for {model} says it supports redirects but failed '
446  . 'to return Content object from ContentHandler::makeRedirectContent().'
447  . ' {value} returned instead.',
448  [
449  'value' => gettype( $newContent ),
450  'model' => $sourceModel
451  ]
452  );
453 
454  throw new InvalidArgumentException(
455  "ContentHandler for '$sourceModel' supports redirects" .
456  ' but cannot create redirect content during History merge.'
457  );
458  }
459 
460  // T263340/T93469: Create revision record to also serve as the page revision.
461  // This revision will be used to create page content. If the source page's
462  // content model supports redirects, then it will be the redirect content.
463  // If the content model does not supports redirect, this content will aid
464  // proper deletion of the page below.
465  $comment = CommentStoreComment::newUnsavedComment( $reason );
466  $revRecord = new MutableRevisionRecord( $this->source );
467  $revRecord->setContent( SlotRecord::MAIN, $newContent )
468  ->setPageId( $this->source->getId() )
469  ->setComment( $comment )
470  ->setUser( $user )
471  ->setTimestamp( wfTimestampNow() );
472 
473  $insertedRevRecord = $this->revisionStore->insertRevisionOn( $revRecord, $this->dbw );
474 
475  $newPage = $this->wikiPageFactory->newFromTitle( $this->source );
476  $newPage->updateRevisionOn( $this->dbw, $insertedRevRecord );
477 
478  if ( !$deleteSource ) {
479  // We have created a redirect page so let's
480  // record the link from the page to the new title.
481  // It should have no other outgoing links...
482  $this->dbw->delete(
483  'pagelinks',
484  [ 'pl_from' => $this->dest->getId() ],
485  __METHOD__
486  );
487  $this->dbw->insert( 'pagelinks',
488  [
489  'pl_from' => $this->dest->getId(),
490  'pl_from_namespace' => $this->dest->getNamespace(),
491  'pl_namespace' => $this->dest->getNamespace(),
492  'pl_title' => $this->dest->getDBkey() ],
493  __METHOD__
494  );
495 
496  } else {
497  // T263340/T93469: Delete the source page to prevent errors because its
498  // revisions are now tied to a different title and its content model
499  // does not support redirects, so we cannot leave a new revision on it.
500  // This deletion does not depend on userright but may still fails. If it
501  // fails, it will be communicated in the status reponse.
502  $reason = wfMessage( 'mergehistory-source-deleted-reason' )->inContentLanguage()->plain();
503  $deletionStatus = $newPage->doDeleteArticleReal( $reason, $user );
504  // Notify callers that the source page has been deleted.
505  $status->value = 'source-deleted';
506  $status->merge( $deletionStatus );
507  }
508 
509  return $status;
510  }
511 
517  private function getMaxTimestamp(): MWTimestamp {
518  if ( $this->maxTimestamp === false ) {
519  $this->initTimestampLimits();
520  }
521  return $this->maxTimestamp;
522  }
523 
530  private function getTimestampLimit(): ?MWTimestamp {
531  if ( $this->timestampLimit === false ) {
532  $this->initTimestampLimits();
533  }
534  return $this->timestampLimit;
535  }
536 
543  private function getTimeWhere(): ?string {
544  if ( $this->timeWhere === false ) {
545  $this->initTimestampLimits();
546  }
547  return $this->timeWhere;
548  }
549 
553  private function initTimestampLimits() {
554  // Max timestamp should be min of destination page
555  $firstDestTimestamp = $this->dbw->selectField(
556  'revision',
557  'MIN(rev_timestamp)',
558  [ 'rev_page' => $this->dest->getId() ],
559  __METHOD__
560  );
561  $this->maxTimestamp = new MWTimestamp( $firstDestTimestamp );
562 
563  // Get the timestamp pivot condition
564  try {
565  if ( $this->timestamp ) {
566  // If we have a requested timestamp, use the
567  // latest revision up to that point as the insertion point
568  $mwTimestamp = new MWTimestamp( $this->timestamp );
569  $lastWorkingTimestamp = $this->dbw->selectField(
570  'revision',
571  'MAX(rev_timestamp)',
572  [
573  'rev_timestamp <= ' .
574  $this->dbw->addQuotes( $this->dbw->timestamp( $mwTimestamp ) ),
575  'rev_page' => $this->source->getId()
576  ],
577  __METHOD__
578  );
579  $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
580 
581  $timeInsert = $mwLastWorkingTimestamp;
582  $this->timestampLimit = $mwLastWorkingTimestamp;
583  } else {
584  // If we don't, merge entire source page history into the
585  // beginning of destination page history
586 
587  // Get the latest timestamp of the source
588  $lastSourceTimestamp = $this->dbw->selectField(
589  [ 'page', 'revision' ],
590  'rev_timestamp',
591  [ 'page_id' => $this->source->getId(),
592  'page_latest = rev_id'
593  ],
594  __METHOD__
595  );
596  $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
597 
598  $timeInsert = $this->maxTimestamp;
599  $this->timestampLimit = $lasttimestamp;
600  }
601 
602  $this->timeWhere = "rev_timestamp <= " .
603  $this->dbw->addQuotes( $this->dbw->timestamp( $timeInsert ) );
604  } catch ( TimestampException $ex ) {
605  // The timestamp we got is screwed up and merge cannot continue
606  // This should be detected by $this->isValidMerge()
607  $this->timestampLimit = null;
608  $this->timeWhere = null;
609  }
610  }
611 }
Page\PageIdentity
Interface for objects (potentially) representing an editable wiki page.
Definition: PageIdentity.php:64
MWTimestamp
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:38
MergeHistory\getTimeWhere
getTimeWhere()
Get the SQL WHERE condition that selects source revisions to insert into destination,...
Definition: MergeHistory.php:543
Message\numParam
static numParam( $num)
Definition: Message.php:1103
CommentStoreComment\newUnsavedComment
static newUnsavedComment( $comment, array $data=null)
Create a new, unsaved CommentStoreComment.
Definition: CommentStoreComment.php:67
MergeHistory\$source
PageIdentity $source
Page from which history will be merged.
Definition: MergeHistory.php:52
MergeHistory\$watchedItemStore
WatchedItemStoreInterface $watchedItemStore
Definition: MergeHistory.php:91
MergeHistory\$maxTimestamp
MWTimestamp false $maxTimestamp
Maximum timestamp that we can use (oldest timestamp of dest).
Definition: MergeHistory.php:67
MergeHistory\$revisionsMerged
int $revisionsMerged
Number of revisions merged (for Special:MergeHistory success message)
Definition: MergeHistory.php:82
MediaWiki\Revision\RevisionStore
Service for looking up page revisions.
Definition: RevisionStore.php:88
MediaWiki\Logger\LoggerFactory\getInstance
static getInstance( $channel)
Get a named logger instance from the currently configured logger factory.
Definition: LoggerFactory.php:92
MergeHistory\$dbw
IDatabase $dbw
Database that we are using.
Definition: MergeHistory.php:58
Wikimedia\Rdbms\ILoadBalancer\getConnection
getConnection( $i, $groups=[], $domain=false, $flags=0)
Get a live handle for a specific or virtual (DB_PRIMARY/DB_REPLICA) server index.
MergeHistory\getMergedRevisionCount
getMergedRevisionCount()
Get the number of revisions that were moved Used in the SpecialMergeHistory success message.
Definition: MergeHistory.php:173
MergeHistory\$spamChecker
SpamChecker $spamChecker
Definition: MergeHistory.php:94
wfMessage
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Definition: GlobalFunctions.php:1182
MergeHistory\$contentHandlerFactory
IContentHandlerFactory $contentHandlerFactory
Definition: MergeHistory.php:85
MediaWiki\Permissions\Authority\probablyCan
probablyCan(string $action, PageIdentity $target, PermissionStatus $status=null)
Checks whether this authority can probably perform the given action on the given target page.
MediaWiki\Permissions\Authority\getUser
getUser()
Returns the performer of the actions associated with this authority.
MediaWiki\User\UserIdentity
Interface for objects representing user identity.
Definition: UserIdentity.php:39
MergeHistory\initTimestampLimits
initTimestampLimits()
Lazily initializes timestamp limits and conditions.
Definition: MergeHistory.php:553
Wikimedia\Rdbms\IDatabase
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:38
Status
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:44
MergeHistory\$revisionStore
RevisionStore $revisionStore
Definition: MergeHistory.php:88
TitleValue\newFromPage
static newFromPage(PageReference $page)
Constructs a TitleValue from a local PageReference.
Definition: TitleValue.php:119
MergeHistory\$titleFormatter
TitleFormatter $titleFormatter
Definition: MergeHistory.php:103
Status\wrap
static wrap( $sv)
Succinct helper method to wrap a StatusValue.
Definition: Status.php:62
MergeHistory\merge
merge(Authority $performer, $reason='')
Actually attempt the history move.
Definition: MergeHistory.php:304
Page\WikiPageFactory
Definition: WikiPageFactory.php:20
MergeHistory\$dest
PageIdentity $dest
Page to which history will be merged.
Definition: MergeHistory.php:55
MergeHistory\$titleFactory
TitleFactory $titleFactory
Definition: MergeHistory.php:106
wfTimestampNow
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
Definition: GlobalFunctions.php:1721
MediaWiki\Permissions\Authority\authorizeWrite
authorizeWrite(string $action, PageIdentity $target, PermissionStatus $status=null)
Authorize write access.
MergeHistory\__construct
__construct(PageIdentity $source, PageIdentity $dest, ?string $timestamp, ILoadBalancer $loadBalancer, IContentHandlerFactory $contentHandlerFactory, RevisionStore $revisionStore, WatchedItemStoreInterface $watchedItemStore, SpamChecker $spamChecker, HookContainer $hookContainer, WikiPageFactory $wikiPageFactory, TitleFormatter $titleFormatter, TitleFactory $titleFactory)
Definition: MergeHistory.php:122
MergeHistory\$hookRunner
HookRunner $hookRunner
Definition: MergeHistory.php:97
MediaWiki\Permissions\Authority
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37
MergeHistory\$timestampLimit
MWTimestamp false null $timestampLimit
Timestamp upto which history from the source will be merged.
Definition: MergeHistory.php:79
MergeHistory\probablyCanMerge
probablyCanMerge(Authority $performer, string $reason=null)
Check whether $performer can execute the merge.
Definition: MergeHistory.php:218
MediaWiki\Content\IContentHandlerFactory
Definition: IContentHandlerFactory.php:10
MediaWiki\Revision\MutableRevisionRecord
Definition: MutableRevisionRecord.php:44
DB_PRIMARY
const DB_PRIMARY
Definition: defines.php:27
MediaWiki\EditPage\SpamChecker
Service to check if text (either content or a summary) qualifies as spam.
Definition: SpamChecker.php:14
MergeHistory\getTimestampLimit
getTimestampLimit()
Get the timestamp upto which history from the source will be merged, or null if something went wrong.
Definition: MergeHistory.php:530
MergeHistory\REVISION_LIMIT
const REVISION_LIMIT
Maximum number of revisions that can be merged at once.
Definition: MergeHistory.php:49
SCHEMA_COMPAT_WRITE_TEMP
const SCHEMA_COMPAT_WRITE_TEMP
Definition: Defines.php:264
MergeHistory\authorizeInternal
authorizeInternal(callable $authorizer, Authority $performer, string $reason)
Definition: MergeHistory.php:183
MergeHistory
Handles the backend logic of merging the histories of two pages.
Definition: MergeHistory.php:46
MergeHistory\isValidMerge
isValidMerge()
Does various sanity checks that the merge is valid.
Definition: MergeHistory.php:256
MediaWiki\Permissions\PermissionStatus
A StatusValue for permission errors.
Definition: PermissionStatus.php:35
Content
Base interface for content objects.
Definition: Content.php:35
MergeHistory\getMaxTimestamp
getMaxTimestamp()
Get the maximum timestamp that we can use (oldest timestamp of dest)
Definition: MergeHistory.php:517
MediaWiki\Permissions\Authority\isAllowed
isAllowed(string $permission)
Checks whether this authority has the given permission in general.
TitleFormatter
A title formatter service for MediaWiki.
Definition: TitleFormatter.php:35
TitleFactory
Creates Title objects.
Definition: TitleFactory.php:35
MergeHistory\updateSourcePage
updateSourcePage( $status, $user, $reason)
Do various cleanup work and updates to the source page.
Definition: MergeHistory.php:419
MergeHistory\$timestamp
string $timestamp
Timestamp up to which history from the source will be merged.
Definition: MergeHistory.php:61
ManualLogEntry
Class for creating new log entries and inserting them into the database.
Definition: ManualLogEntry.php:44
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
MediaWiki\HookContainer\HookRunner
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:555
MergeHistory\$timeWhere
string false null $timeWhere
SQL WHERE condition that selects source revisions to insert into destination.
Definition: MergeHistory.php:73
MergeHistory\authorizeMerge
authorizeMerge(Authority $performer, string $reason=null)
Authorize the merge by $performer.
Definition: MergeHistory.php:239
WatchedItemStoreInterface
Definition: WatchedItemStoreInterface.php:31
MergeHistory\getRevisionCount
getRevisionCount()
Get the number of revisions that will be moved.
Definition: MergeHistory.php:158
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81
MergeHistory\$wikiPageFactory
WikiPageFactory $wikiPageFactory
Definition: MergeHistory.php:100
MediaWiki\Revision\SlotRecord
Value object representing a content slot associated with a page revision.
Definition: SlotRecord.php:40
$wgActorTableSchemaMigrationStage
int $wgActorTableSchemaMigrationStage
Actor table schema migration stage, for migration from the temporary table revision_actor_temp to the...
Definition: DefaultSettings.php:2413