MediaWiki  master
MergeHistory.php
Go to the documentation of this file.
1 <?php
2 
39 use Wikimedia\Timestamp\TimestampException;
40 
47 class MergeHistory {
48 
50  public const REVISION_LIMIT = 5000;
51 
53  protected $source;
54 
56  protected $dest;
57 
59  protected $dbw;
60 
62  private $timestamp;
63 
68  protected $maxTimestamp = false;
69 
74  protected $timeWhere = false;
75 
80  protected $timestampLimit = false;
81 
83  protected $revisionsMerged;
84 
87 
89  private $revisionStore;
90 
93 
95  private $spamChecker;
96 
98  private $hookRunner;
99 
102 
105 
107  private $titleFactory;
108 
123  public function __construct(
126  ?string $timestamp,
127  ILoadBalancer $loadBalancer,
132  HookContainer $hookContainer,
136  ) {
137  // Save the parameters
138  $this->source = $source;
139  $this->dest = $dest;
140  $this->timestamp = $timestamp;
141 
142  // Get the database
143  $this->dbw = $loadBalancer->getConnection( DB_PRIMARY );
144 
145  $this->contentHandlerFactory = $contentHandlerFactory;
146  $this->revisionStore = $revisionStore;
147  $this->watchedItemStore = $watchedItemStore;
148  $this->spamChecker = $spamChecker;
149  $this->hookRunner = new HookRunner( $hookContainer );
150  $this->wikiPageFactory = $wikiPageFactory;
151  $this->titleFormatter = $titleFormatter;
152  $this->titleFactory = $titleFactory;
153  }
154 
159  public function getRevisionCount() {
160  $count = $this->dbw->selectRowCount( 'revision', '1',
161  [ 'rev_page' => $this->source->getId(), $this->getTimeWhere() ],
162  __METHOD__,
163  [ 'LIMIT' => self::REVISION_LIMIT + 1 ]
164  );
165 
166  return $count;
167  }
168 
174  public function getMergedRevisionCount() {
175  return $this->revisionsMerged;
176  }
177 
184  private function authorizeInternal(
185  callable $authorizer,
186  Authority $performer,
187  string $reason
188  ) {
189  $status = PermissionStatus::newEmpty();
190 
191  $authorizer( 'edit', $this->source, $status );
192  $authorizer( 'edit', $this->dest, $status );
193 
194  // Anti-spam
195  if ( $this->spamChecker->checkSummary( $reason ) !== false ) {
196  // This is kind of lame, won't display nice
197  $status->fatal( 'spamprotectiontext' );
198  }
199 
200  // Check mergehistory permission
201  if ( !$performer->isAllowed( 'mergehistory' ) ) {
202  // User doesn't have the right to merge histories
203  $status->fatal( 'mergehistory-fail-permission' );
204  }
205  return $status;
206  }
207 
219  public function probablyCanMerge( Authority $performer, string $reason = null ): PermissionStatus {
220  return $this->authorizeInternal(
221  static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) {
222  return $performer->probablyCan( $action, $target, $status );
223  },
224  $performer,
225  $reason
226  );
227  }
228 
240  public function authorizeMerge( Authority $performer, string $reason = null ): PermissionStatus {
241  return $this->authorizeInternal(
242  static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) {
243  return $performer->authorizeWrite( $action, $target, $status );
244  },
245  $performer,
246  $reason
247  );
248  }
249 
257  public function isValidMerge() {
258  $status = new Status();
259 
260  // If either article ID is 0, then revisions cannot be reliably selected
261  if ( $this->source->getId() === 0 ) {
262  $status->fatal( 'mergehistory-fail-invalid-source' );
263  }
264  if ( $this->dest->getId() === 0 ) {
265  $status->fatal( 'mergehistory-fail-invalid-dest' );
266  }
267 
268  // Make sure page aren't the same
269  if ( $this->source->isSamePageAs( $this->dest ) ) {
270  $status->fatal( 'mergehistory-fail-self-merge' );
271  }
272 
273  // Make sure the timestamp is valid
274  if ( !$this->getTimestampLimit() ) {
275  $status->fatal( 'mergehistory-fail-bad-timestamp' );
276  }
277 
278  // $this->timestampLimit must be older than $this->maxTimestamp
279  if ( $this->getTimestampLimit() > $this->getMaxTimestamp() ) {
280  $status->fatal( 'mergehistory-fail-timestamps-overlap' );
281  }
282 
283  // Check that there are not too many revisions to move
284  if ( $this->getTimestampLimit() && $this->getRevisionCount() > self::REVISION_LIMIT ) {
285  $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
286  }
287 
288  return $status;
289  }
290 
305  public function merge( Authority $performer, $reason = '' ) {
306  $actorTableSchemaMigrationStage = MediaWikiServices::getInstance()
307  ->getMainConfig()->get( 'ActorTableSchemaMigrationStage' );
308 
309  $status = new Status();
310 
311  // Check validity and permissions required for merge
312  $validCheck = $this->isValidMerge(); // Check this first to check for null pages
313  if ( !$validCheck->isOK() ) {
314  return $validCheck;
315  }
316  $permCheck = $this->authorizeMerge( $performer, $reason );
317  if ( !$permCheck->isOK() ) {
318  return Status::wrap( $permCheck );
319  }
320 
321  $this->dbw->startAtomic( __METHOD__ );
322 
323  $this->dbw->update(
324  'revision',
325  [ 'rev_page' => $this->dest->getId() ],
326  [ 'rev_page' => $this->source->getId(), $this->getTimeWhere() ],
327  __METHOD__
328  );
329 
330  // Check if this did anything
331  $this->revisionsMerged = $this->dbw->affectedRows();
332  if ( $this->revisionsMerged < 1 ) {
333  $this->dbw->endAtomic( __METHOD__ );
334  return $status->fatal( 'mergehistory-fail-no-change' );
335  }
336 
337  // Update denormalized revactor_page too
338  if ( $actorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_TEMP ) {
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 response.
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:1127
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:53
MergeHistory\$watchedItemStore
WatchedItemStoreInterface $watchedItemStore
Definition: MergeHistory.php:92
MergeHistory\$maxTimestamp
MWTimestamp false $maxTimestamp
Maximum timestamp that we can use (oldest timestamp of dest).
Definition: MergeHistory.php:68
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:203
MergeHistory\$revisionsMerged
int $revisionsMerged
Number of revisions merged (for Special:MergeHistory success message)
Definition: MergeHistory.php:83
MediaWiki\Revision\RevisionStore
Service for looking up page revisions.
Definition: RevisionStore.php:89
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:59
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:174
MergeHistory\$spamChecker
SpamChecker $spamChecker
Definition: MergeHistory.php:95
wfMessage
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Definition: GlobalFunctions.php:1167
MergeHistory\$contentHandlerFactory
IContentHandlerFactory $contentHandlerFactory
Definition: MergeHistory.php:86
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:89
TitleValue\newFromPage
static newFromPage(PageReference $page)
Create a TitleValue from a local PageReference.
Definition: TitleValue.php:103
MergeHistory\$titleFormatter
TitleFormatter $titleFormatter
Definition: MergeHistory.php:104
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:305
Page\WikiPageFactory
Definition: WikiPageFactory.php:19
MergeHistory\$dest
PageIdentity $dest
Page to which history will be merged.
Definition: MergeHistory.php:56
MergeHistory\$titleFactory
TitleFactory $titleFactory
Definition: MergeHistory.php:107
wfTimestampNow
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
Definition: GlobalFunctions.php:1678
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:123
MergeHistory\$hookRunner
HookRunner $hookRunner
Definition: MergeHistory.php:98
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:80
MergeHistory\probablyCanMerge
probablyCanMerge(Authority $performer, string $reason=null)
Check whether $performer can execute the merge.
Definition: MergeHistory.php:219
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:50
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:184
MergeHistory
Handles the backend logic of merging the histories of two pages.
Definition: MergeHistory.php:47
MergeHistory\isValidMerge
isValidMerge()
Does various checks that the merge is valid.
Definition: MergeHistory.php:257
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:62
ManualLogEntry
Class for creating new log entries and inserting them into the database.
Definition: ManualLogEntry.php:45
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:557
MergeHistory\$timeWhere
string false null $timeWhere
SQL WHERE condition that selects source revisions to insert into destination.
Definition: MergeHistory.php:74
MergeHistory\authorizeMerge
authorizeMerge(Authority $performer, string $reason=null)
Authorize the merge by $performer.
Definition: MergeHistory.php:240
WatchedItemStoreInterface
Definition: WatchedItemStoreInterface.php:31
MergeHistory\getRevisionCount
getRevisionCount()
Get the number of revisions that will be moved.
Definition: MergeHistory.php:159
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81
MergeHistory\$wikiPageFactory
WikiPageFactory $wikiPageFactory
Definition: MergeHistory.php:101
MediaWiki\Revision\SlotRecord
Value object representing a content slot associated with a page revision.
Definition: SlotRecord.php:40