summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'UserMerge/includes/MergeUser.php')
-rw-r--r--UserMerge/includes/MergeUser.php653
1 files changed, 653 insertions, 0 deletions
diff --git a/UserMerge/includes/MergeUser.php b/UserMerge/includes/MergeUser.php
new file mode 100644
index 00000000..6a959c2f
--- /dev/null
+++ b/UserMerge/includes/MergeUser.php
@@ -0,0 +1,653 @@
+<?php
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Contains the actual database backend logic for merging users
+ *
+ */
+class MergeUser {
+ /**
+ * @var User
+ */
+ private $oldUser, $newUser;
+
+ /**
+ * @var IUserMergeLogger
+ */
+ private $logger;
+
+ /** @var integer */
+ private $flags;
+
+ const USE_MULTI_COMMIT = 1; // allow begin/commit; useful for jobs or CLI mode
+
+ /**
+ * @param User $oldUser
+ * @param User $newUser
+ * @param IUserMergeLogger $logger
+ * @param int $flags Bitfield (Supports MergeUser::USE_*)
+ */
+ public function __construct(
+ User $oldUser,
+ User $newUser,
+ IUserMergeLogger $logger,
+ $flags = 0
+ ) {
+ $this->newUser = $newUser;
+ $this->oldUser = $oldUser;
+ $this->logger = $logger;
+ $this->flags = $flags;
+ }
+
+ /**
+ * @param User $performer
+ * @param string $fnameTrxOwner
+ */
+ public function merge( User $performer, $fnameTrxOwner = __METHOD__ ) {
+ $this->mergeEditcount();
+ $this->mergeDatabaseTables( $fnameTrxOwner );
+ $this->logger->addMergeEntry( $performer, $this->oldUser, $this->newUser );
+ }
+
+ /**
+ * @param User $performer
+ * @param callable $msg something that returns a Message object
+ *
+ * @return array Array of failed page moves, see MergeUser::movePages
+ */
+ public function delete( User $performer, /* callable */ $msg ) {
+ $failed = $this->movePages( $performer, $msg );
+ $this->deleteUser();
+ $this->logger->addDeleteEntry( $performer, $this->oldUser );
+
+ return $failed;
+ }
+
+ /**
+ * Adds edit count of both users
+ */
+ private function mergeEditcount() {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ $totalEdits = $dbw->selectField(
+ 'user',
+ 'SUM(user_editcount)',
+ [ 'user_id' => [ $this->newUser->getId(), $this->oldUser->getId() ] ],
+ __METHOD__
+ );
+
+ $totalEdits = (int)$totalEdits;
+
+ # don't run queries if neither user has any edits
+ if ( $totalEdits > 0 ) {
+ # update new user with total edits
+ $dbw->update( 'user',
+ [ 'user_editcount' => $totalEdits ],
+ [ 'user_id' => $this->newUser->getId() ],
+ __METHOD__
+ );
+
+ # clear old user's edits
+ $dbw->update( 'user',
+ [ 'user_editcount' => 0 ],
+ [ 'user_id' => $this->oldUser->getId() ],
+ __METHOD__
+ );
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+ }
+
+ /**
+ * @param IDatabase $dbw
+ * @return void
+ * @suppress PhanTypeMismatchArgument Phan thinks that $newBlock and $oldBlock are both null when
+ * Block::newFromRow is called, although the previous if/elseif returns if any of them is null.
+ */
+ private function mergeBlocks( IDatabase $dbw ) {
+ $dbw->startAtomic( __METHOD__ );
+
+ // Pull blocks directly from master
+ $rows = $dbw->select(
+ 'ipblocks',
+ '*',
+ [
+ 'ipb_user' => [ $this->oldUser->getId(), $this->newUser->getId() ],
+ ]
+ );
+
+ $newBlock = null;
+ $oldBlock = null;
+ foreach ( $rows as $row ) {
+ if ( (int)$row->ipb_user === $this->oldUser->getId() ) {
+ $oldBlock = $row;
+ } elseif ( (int)$row->ipb_user === $this->newUser->getId() ) {
+ $newBlock = $row;
+ }
+ }
+
+ if ( !$newBlock && !$oldBlock ) {
+ // No one is blocked, yaaay
+ $dbw->endAtomic( __METHOD__ );
+ return;
+ } elseif ( $newBlock && !$oldBlock ) {
+ // Only the new user is blocked, so nothing to do.
+ $dbw->endAtomic( __METHOD__ );
+ return;
+ } elseif ( $oldBlock && !$newBlock ) {
+ // Just move the old block to the new username
+ $dbw->update(
+ 'ipblocks',
+ [ 'ipb_user' => $this->newUser->getId() ],
+ [ 'ipb_id' => $oldBlock->ipb_id ],
+ __METHOD__
+ );
+ $dbw->endAtomic( __METHOD__ );
+ return;
+ }
+
+ // Okay, let's pick the "strongest" block, and re-apply it to
+ // the new user.
+ $oldBlockObj = Block::newFromRow( $oldBlock );
+ $newBlockObj = Block::newFromRow( $newBlock );
+
+ $winner = $this->chooseBlock( $oldBlockObj, $newBlockObj );
+ if ( $winner->getId() === $newBlockObj->getId() ) {
+ $oldBlockObj->delete();
+ } else { // Old user block won
+ $newBlockObj->delete(); // Delete current new block
+ $dbw->update(
+ 'ipblocks',
+ [ 'ipb_user' => $this->newUser->getId() ],
+ [ 'ipb_id' => $winner->getId() ],
+ __METHOD__
+ );
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+ }
+
+ /**
+ * @param Block $b1
+ * @param Block $b2
+ * @return Block
+ */
+ private function chooseBlock( Block $b1, Block $b2 ) {
+ // First, see if one is longer than the other.
+ if ( $b1->getExpiry() !== $b2->getExpiry() ) {
+ // This works for infinite blocks because:
+ // "infinity" > "20141024234513"
+ if ( $b1->getExpiry() > $b2->getExpiry() ) {
+ return $b1;
+ } else {
+ return $b2;
+ }
+ }
+
+ // Next check what they block, in order
+ foreach ( [ 'createaccount', 'sendemail', 'editownusertalk' ] as $action ) {
+ if ( $b1->prevents( $action ) xor $b2->prevents( $action ) ) {
+ if ( $b1->prevents( $action ) ) {
+ return $b1;
+ } else {
+ return $b2;
+ }
+ }
+ }
+
+ // Give up, return the second one.
+ return $b2;
+ }
+
+ private function stageNeedsUser( $stage ) {
+ if ( !defined( 'MIGRATION_NEW' ) ) {
+ return true;
+ }
+ if ( !class_exists( ActorMigration::class ) ) {
+ return false;
+ }
+
+ if ( defined( 'ActorMigration::MIGRATION_STAGE_SCHEMA_COMPAT' ) ) {
+ return (bool)( $stage & SCHEMA_COMPAT_WRITE_OLD );
+ } else {
+ return $stage < MIGRATION_NEW;
+ }
+ }
+
+ private function stageNeedsActor( $stage ) {
+ if ( !defined( 'MIGRATION_NEW' ) ) {
+ return false;
+ }
+ if ( !class_exists( ActorMigration::class ) ) {
+ return true;
+ }
+
+ if ( defined( 'ActorMigration::MIGRATION_STAGE_SCHEMA_COMPAT' ) ) {
+ return (bool)( $stage & SCHEMA_COMPAT_WRITE_NEW );
+ } else {
+ return $stage > MIGRATION_OLD;
+ }
+ }
+
+ /**
+ * Function to merge database references from one user to another user
+ *
+ * Merges database references from one user ID or username to another user ID or username
+ * to preserve referential integrity.
+ *
+ * @param string $fnameTrxOwner
+ */
+ private function mergeDatabaseTables( $fnameTrxOwner ) {
+ global $wgActorTableSchemaMigrationStage;
+
+ // Fields to update with the format:
+ // [
+ // tableName, idField, textField,
+ // 'batchKey' => unique field, 'options' => array(), 'db' => IDatabase
+ // 'actorId' => actor ID field,
+ // ];
+ // textField, batchKey, db, and options are optional
+ $updateFields = [
+ [ 'archive', 'ar_user', 'ar_user_text', 'batchKey' => 'ar_id', 'actorId' => 'ar_actor',
+ 'actorStage' => $wgActorTableSchemaMigrationStage ],
+ [ 'revision', 'rev_user', 'rev_user_text', 'batchKey' => 'rev_id', 'actorId' => '',
+ 'actorStage' => $wgActorTableSchemaMigrationStage ],
+ [ 'filearchive', 'fa_user', 'fa_user_text', 'batchKey' => 'fa_id', 'actorId' => 'fa_actor',
+ 'actorStage' => $wgActorTableSchemaMigrationStage ],
+ [ 'image', 'img_user', 'img_user_text', 'batchKey' => 'img_name', 'actorId' => 'img_actor',
+ 'actorStage' => $wgActorTableSchemaMigrationStage ],
+ [ 'oldimage', 'oi_user', 'oi_user_text', 'batchKey' => 'oi_archive_name',
+ 'actorId' => 'oi_actor', 'actorStage' => $wgActorTableSchemaMigrationStage ],
+ [ 'recentchanges', 'rc_user', 'rc_user_text', 'batchKey' => 'rc_id', 'actorId' => 'rc_actor',
+ 'actorStage' => $wgActorTableSchemaMigrationStage ],
+ [ 'logging', 'log_user', 'log_user_text', 'batchKey' => 'log_id', 'actorId' => 'log_actor',
+ 'actorStage' => $wgActorTableSchemaMigrationStage ],
+ [ 'ipblocks', 'ipb_by', 'ipb_by_text', 'batchKey' => 'ipb_id', 'actorId' => 'ipb_by_actor',
+ 'actorStage' => $wgActorTableSchemaMigrationStage ],
+ [ 'watchlist', 'wl_user', 'batchKey' => 'wl_title' ],
+ [ 'user_groups', 'ug_user', 'options' => [ 'IGNORE' ] ],
+ [ 'user_properties', 'up_user', 'options' => [ 'IGNORE' ] ],
+ [ 'user_former_groups', 'ufg_user', 'options' => [ 'IGNORE' ] ],
+ ];
+ if ( $this->stageNeedsActor( $wgActorTableSchemaMigrationStage ) ) {
+ $updateFields[] = [
+ 'revision_actor_temp', 'batchKey' => 'revactor_rev', 'actorId' => 'revactor_actor',
+ 'actorStage' => $wgActorTableSchemaMigrationStage
+ ];
+ }
+
+ Hooks::run( 'UserMergeAccountFields', [ &$updateFields ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+
+ $this->deduplicateWatchlistEntries( $dbw );
+ $this->mergeBlocks( $dbw );
+
+ if ( $this->flags & self::USE_MULTI_COMMIT ) {
+ // Flush prior writes; this actives the non-transaction path in the loop below.
+ $lbFactory->commitMasterChanges( $fnameTrxOwner );
+ }
+
+ foreach ( $updateFields as $fieldInfo ) {
+ if ( !isset( $fieldInfo[1] ) ) {
+ // Actors only
+ continue;
+ }
+
+ $options = isset( $fieldInfo['options'] ) ? $fieldInfo['options'] : [];
+ unset( $fieldInfo['options'] );
+ $db = isset( $fieldInfo['db'] ) ? $fieldInfo['db'] : $dbw;
+ unset( $fieldInfo['db'] );
+ $tableName = array_shift( $fieldInfo );
+ $idField = array_shift( $fieldInfo );
+ $keyField = isset( $fieldInfo['batchKey'] ) ? $fieldInfo['batchKey'] : null;
+ unset( $fieldInfo['batchKey'] );
+
+ if ( isset( $fieldInfo['actorId'] ) && !$this->stageNeedsUser( $fieldInfo['actorStage'] ) ) {
+ continue;
+ }
+ unset( $fieldInfo['actorId'], $fieldInfo['actorStage'] );
+
+ if ( $db->trxLevel() || $keyField === null ) {
+ // Can't batch/wait when in a transaction or when no batch key is given
+ $db->update(
+ $tableName,
+ [ $idField => $this->newUser->getId() ]
+ + array_fill_keys( $fieldInfo, $this->newUser->getName() ),
+ [ $idField => $this->oldUser->getId() ],
+ __METHOD__,
+ $options
+ );
+ } else {
+ $limit = 200;
+ do {
+ $checkSince = microtime( true );
+ // Note that UPDATE with ORDER BY + LIMIT is not well supported.
+ // Grab a batch of values on a mostly unique column for this user ID.
+ $res = $db->select(
+ $tableName,
+ [ $keyField ],
+ [ $idField => $this->oldUser->getId() ],
+ __METHOD__,
+ [ 'LIMIT' => $limit ]
+ );
+ $keyValues = [];
+ foreach ( $res as $row ) {
+ $keyValues[] = $row->$keyField;
+ }
+ // Update only those rows with the given column values
+ if ( count( $keyValues ) ) {
+ $db->update(
+ $tableName,
+ [ $idField => $this->newUser->getId() ]
+ + array_fill_keys( $fieldInfo, $this->newUser->getName() ),
+ [ $idField => $this->oldUser->getId(), $keyField => $keyValues ],
+ __METHOD__,
+ $options
+ );
+ }
+ // Wait for replication to catch up
+ $opts = [ 'ifWritesSince' => $checkSince ];
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket, $opts );
+ } while ( count( $keyValues ) >= $limit );
+ }
+ }
+
+ if ( $this->stageNeedsActor( $wgActorTableSchemaMigrationStage ) &&
+ $this->oldUser->getActorId()
+ ) {
+ $oldActorId = $this->oldUser->getActorId();
+ $newActorId = $this->newUser->getActorId( $db );
+
+ foreach ( $updateFields as $fieldInfo ) {
+ if ( empty( $fieldInfo['actorId'] ) || !$this->stageNeedsActor( $fieldInfo['actorStage'] ) ) {
+ continue;
+ }
+
+ $options = isset( $fieldInfo['options'] ) ? $fieldInfo['options'] : [];
+ $db = isset( $fieldInfo['db'] ) ? $fieldInfo['db'] : $dbw;
+ $tableName = array_shift( $fieldInfo );
+ $idField = $fieldInfo['actorId'];
+ $keyField = isset( $fieldInfo['batchKey'] ) ? $fieldInfo['batchKey'] : null;
+
+ if ( $db->trxLevel() || $keyField === null ) {
+ // Can't batch/wait when in a transaction or when no batch key is given
+ $db->update(
+ $tableName,
+ [ $idField => $newActorId ],
+ [ $idField => $oldActorId ],
+ __METHOD__,
+ $options
+ );
+ } else {
+ $limit = 200;
+ do {
+ $checkSince = microtime( true );
+ // Note that UPDATE with ORDER BY + LIMIT is not well supported.
+ // Grab a batch of values on a mostly unique column for this user ID.
+ $res = $db->select(
+ $tableName,
+ [ $keyField ],
+ [ $idField => $oldActorId ],
+ __METHOD__,
+ [ 'LIMIT' => $limit ]
+ );
+ $keyValues = [];
+ foreach ( $res as $row ) {
+ $keyValues[] = $row->$keyField;
+ }
+ // Update only those rows with the given column values
+ if ( count( $keyValues ) ) {
+ $db->update(
+ $tableName,
+ [ $idField => $newActorId ],
+ [ $idField => $oldActorId, $keyField => $keyValues ],
+ __METHOD__,
+ $options
+ );
+ }
+ // Wait for replication to catch up
+ $opts = [ 'ifWritesSince' => $checkSince ];
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket, $opts );
+ } while ( count( $keyValues ) >= $limit );
+ }
+ }
+ }
+
+ $dbw->delete( 'user_newtalk', [ 'user_id' => $this->oldUser->getId() ] );
+
+ Hooks::run( 'MergeAccountFromTo', [ &$this->oldUser, &$this->newUser ] );
+ }
+
+ /**
+ * Deduplicate watchlist entries
+ * which old (merge-from) and new (merge-to) users are watching
+ *
+ * @param IDatabase $dbw
+ */
+ private function deduplicateWatchlistEntries( $dbw ) {
+ $dbw->startAtomic( __METHOD__ );
+
+ // Get all titles both watched by the old and new user accounts.
+ // Avoid using self-joins as this fails on temporary tables (e.g. unit tests).
+ // See https://bugs.mysql.com/bug.php?id=10327.
+ $titlesToDelete = [];
+ $res = $dbw->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title' ],
+ [ 'wl_user' => $this->oldUser->getId() ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ foreach ( $res as $row ) {
+ $titlesToDelete[$row->wl_namespace . "|" . $row->wl_title] = false;
+ }
+ $res = $dbw->select(
+ 'watchlist',
+ [ 'wl_namespace', 'wl_title' ],
+ [ 'wl_user' => $this->newUser->getId() ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ foreach ( $res as $row ) {
+ $key = $row->wl_namespace . "|" . $row->wl_title;
+ if ( isset( $titlesToDelete[$key] ) ) {
+ $titlesToDelete[$key] = true;
+ }
+ }
+ $dbw->freeResult( $res );
+ $titlesToDelete = array_filter( $titlesToDelete );
+
+ $conds = [];
+ foreach ( array_keys( $titlesToDelete ) as $tuple ) {
+ list( $ns, $dbKey ) = explode( "|", $tuple, 2 );
+ $conds[] = $dbw->makeList(
+ [
+ 'wl_user' => $this->oldUser->getId(),
+ 'wl_namespace' => $ns,
+ 'wl_title' => $dbKey
+ ],
+ LIST_AND
+ );
+ }
+
+ if ( count( $conds ) ) {
+ # Perform a multi-row delete
+ $dbw->delete(
+ 'watchlist',
+ $dbw->makeList( $conds, LIST_OR ),
+ __METHOD__
+ );
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+ }
+
+ /**
+ * Function to merge user pages
+ *
+ * Deletes all pages when merging to Anon
+ * Moves user page when the target user page does not exist or is empty
+ * Deletes redirect if nothing links to old page
+ * Deletes the old user page when the target user page exists
+ *
+ * @todo This code is a duplicate of Renameuser and GlobalRename
+ * @suppress PhanParamTooMany Several calls to $message, which is a variadic closure
+ *
+ * @param User $performer
+ * @param callable $msg Function that returns a Message object
+ * @return array Array of old name (string) => new name (Title) where the move failed
+ */
+ private function movePages( User $performer, /* callable */ $msg ) {
+ global $wgContLang, $wgUser;
+
+ $oldusername = trim( str_replace( '_', ' ', $this->oldUser->getName() ) );
+ $oldusername = Title::makeTitle( NS_USER, $oldusername );
+ $newusername = Title::makeTitleSafe( NS_USER, $wgContLang->ucfirst( $this->newUser->getName() ) );
+
+ # select all user pages and sub-pages
+ $dbr = wfGetDB( DB_REPLICA );
+ $pages = $dbr->select(
+ 'page',
+ [ 'page_namespace', 'page_title' ],
+ [
+ 'page_namespace' => [ NS_USER, NS_USER_TALK ],
+ 'page_title' . $dbr->buildLike( $oldusername->getDBkey() . '/', $dbr->anyString() )
+ . ' OR page_title = ' . $dbr->addQuotes( $oldusername->getDBkey() ),
+ ]
+ );
+
+ $message = function ( /* ... */ ) use ( $msg ) {
+ return call_user_func_array( $msg, func_get_args() );
+ };
+
+ // Need to set $wgUser to attribute log properly.
+ $oldUser = $wgUser;
+ $wgUser = $performer;
+
+ $failedMoves = [];
+ foreach ( $pages as $row ) {
+ $oldPage = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
+ $newPage = Title::makeTitleSafe( $row->page_namespace,
+ preg_replace( '!^[^/]+!', $newusername->getDBkey(), $row->page_title ) );
+
+ if ( $this->newUser->getName() === 'Anonymous' ) { # delete ALL old pages
+ if ( $oldPage->exists() ) {
+ $error = '';
+ $oldPageArticle = new Article( $oldPage, 0 );
+ $oldPageArticle->doDeleteArticle(
+ $message( 'usermerge-autopagedelete' )->inContentLanguage()->text(),
+ false, null, null, $error, true
+ );
+ }
+ } elseif ( $newPage->exists()
+ && !$oldPage->isValidMoveTarget( $newPage )
+ && $newPage->getLength() > 0
+ ) {
+ # delete old pages that can't be moved
+ $error = '';
+ $oldPageArticle = new Article( $oldPage, 0 );
+ $oldPageArticle->doDeleteArticle(
+ $message( 'usermerge-autopagedelete' )->inContentLanguage()->text(),
+ false, null, null, $error, true
+ );
+
+ } else { # move content to new page
+ # delete target page if it exists and is blank
+ if ( $newPage->exists() ) {
+ $error = '';
+ $newPageArticle = new Article( $newPage, 0 );
+ $newPageArticle->doDeleteArticle(
+ $message( 'usermerge-autopagedelete' )->inContentLanguage()->text(),
+ false, null, null, $error, true
+ );
+ }
+
+ # move to target location
+ $errors = $oldPage->moveTo(
+ $newPage,
+ false,
+ $message(
+ 'usermerge-move-log',
+ $oldusername->getText(),
+ $newusername->getText() )->inContentLanguage()->text()
+ );
+ if ( $errors !== true ) {
+ $failedMoves[$oldPage->getPrefixedText()] = $newPage;
+ }
+
+ # check if any pages link here
+ $res = $dbr->selectField( 'pagelinks',
+ 'pl_title',
+ [ 'pl_title' => $this->oldUser->getName() ],
+ __METHOD__
+ );
+ if ( !$dbr->numRows( $res ) ) {
+ # nothing links here, so delete unmoved page/redirect
+ $error = '';
+ $oldPageArticle = new Article( $oldPage, 0 );
+ $oldPageArticle->doDeleteArticle(
+ $message( 'usermerge-autopagedelete' )->inContentLanguage()->text(),
+ false, null, null, $error, true
+ );
+ }
+ }
+ }
+
+ $wgUser = $oldUser;
+
+ return $failedMoves;
+ }
+
+ /**
+ * Function to delete users following a successful mergeUser call.
+ *
+ * Removes rows from the user, user_groups, user_properties
+ * and user_former_groups tables.
+ */
+ private function deleteUser() {
+ $dbw = wfGetDB( DB_MASTER );
+
+ /**
+ * Format is: table => user_id column
+ *
+ * If you want it to use a different db object:
+ * table => array( user_id colum, 'db' => IDatabase );
+ */
+ $tablesToDelete = [
+ 'user_groups' => 'ug_user',
+ 'user_properties' => 'up_user',
+ 'user_former_groups' => 'ufg_user',
+ ];
+
+ Hooks::run( 'UserMergeAccountDeleteTables', [ &$tablesToDelete ] );
+
+ // Make sure these are always set and last
+ if ( $dbw->tableExists( 'actor', __METHOD__ ) ) {
+ $tablesToDelete['actor'] = 'actor_user';
+ }
+ $tablesToDelete['user'] = 'user_id';
+
+ foreach ( $tablesToDelete as $table => $field ) {
+ // Check if a different database object was passed (Echo or Flow)
+ if ( is_array( $field ) ) {
+ $db = isset( $field['db'] ) ? $field['db'] : $dbw;
+ $field = $field[0];
+ } else {
+ $db = $dbw;
+ }
+ $db->delete(
+ $table,
+ [ $field => $this->oldUser->getId() ]
+ );
+ }
+
+ Hooks::run( 'DeleteAccount', [ &$this->oldUser ] );
+
+ DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'users' => -1 ] ) );
+ }
+}