Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.97% covered (warning)
68.97%
140 / 203
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RenameuserSQL
68.97% covered (warning)
68.97%
140 / 203
50.00% covered (danger)
50.00%
4 / 8
74.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
5.01
 debug
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 rename
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 renameUser
62.34% covered (warning)
62.34%
96 / 154
0.00% covered (danger)
0.00%
0 / 1
47.86
 lockUserAndGetId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 shouldUpdate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isTableShared
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isPreviouslyRenamedAccount
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\RenameUser;
4
5use MediaWiki\HookContainer\HookRunner;
6use MediaWiki\JobQueue\JobQueueGroup;
7use MediaWiki\JobQueue\JobSpecification;
8use MediaWiki\Logger\LoggerFactory;
9use MediaWiki\Logging\ManualLogEntry;
10use MediaWiki\MainConfigNames;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\RecentChanges\RecentChange;
13use MediaWiki\Specials\SpecialLog;
14use MediaWiki\Status\Status;
15use MediaWiki\Title\TitleFactory;
16use MediaWiki\User\User;
17use MediaWiki\User\UserFactory;
18use Psr\Log\LoggerInterface;
19use Wikimedia\Rdbms\IConnectionProvider;
20use Wikimedia\Rdbms\IDBAccessObject;
21use Wikimedia\Rdbms\IReadableDatabase;
22use Wikimedia\Rdbms\SelectQueryBuilder;
23
24/**
25 * Class which performs the actual renaming of users
26 */
27class RenameuserSQL {
28    /**
29     * The old username of the user being renamed
30     *
31     * @var string
32     */
33    public $old;
34
35    /**
36     * The new username of the user being renamed
37     *
38     * @var string
39     */
40    public $new;
41
42    /**
43     * The user ID of the user being renamed
44     *
45     * @var int
46     */
47    public $uid;
48
49    /**
50     * The [ tables => fields ] to be updated
51     *
52     * @var array
53     */
54    public $tables;
55
56    /**
57     * [ tables => fields ] to be updated in a deferred job
58     *
59     * @var array[]
60     */
61    public $tablesJob;
62
63    /**
64     * Flag that can be set to false, in case another process has already started
65     * the updates and the old username may have already been renamed in the user table.
66     *
67     * @var bool
68     */
69    public $checkIfUserExists;
70
71    /**
72     * User object of the user performing the rename, for logging purposes
73     *
74     * @var User
75     */
76    private $renamer;
77
78    /**
79     * Reason for the rename to be used in the log entry
80     *
81     * @var string
82     */
83    private $reason = '';
84
85    /**
86     * A prefix to use in all debug log messages
87     *
88     * @var string
89     */
90    private $debugPrefix = '';
91
92    /**
93     * Whether shared tables and virtual domains should be updated
94     *
95     * When this is set to true, it is assumed that the shared tables are already updated.
96     *
97     * @var bool
98     */
99    private $derived = false;
100
101    // B/C constants for tablesJob field
102    public const NAME_COL = 0;
103    public const UID_COL  = 1;
104    public const TIME_COL = 2;
105
106    /** @var HookRunner */
107    private $hookRunner;
108
109    /** @var IConnectionProvider */
110    private $dbProvider;
111
112    /** @var UserFactory */
113    private $userFactory;
114
115    /** @var JobQueueGroup */
116    private $jobQueueGroup;
117
118    /** @var TitleFactory */
119    private $titleFactory;
120
121    /** @var LoggerInterface */
122    private $logger;
123
124    /** @var int */
125    private $updateRowsPerJob;
126
127    /**
128     * Constructor
129     *
130     * @param string $old The old username
131     * @param string $new The new username
132     * @param int $uid
133     * @param User $renamer
134     * @param array $options Optional extra options.
135     *    'reason' - string, reason for the rename
136     *    'debugPrefix' - string, prefixed to debug messages
137     *    'checkIfUserExists' - bool, whether to update the user table
138     *    'derived' - bool, whether to skip updates to shared tables
139     */
140    public function __construct( string $old, string $new, int $uid, User $renamer, $options = [] ) {
141        $services = MediaWikiServices::getInstance();
142        $this->hookRunner = new HookRunner( $services->getHookContainer() );
143        $this->dbProvider = $services->getConnectionProvider();
144        $this->userFactory = $services->getUserFactory();
145        $this->jobQueueGroup = $services->getJobQueueGroup();
146        $this->titleFactory = $services->getTitleFactory();
147        $this->logger = LoggerFactory::getInstance( 'Renameuser' );
148
149        $config = $services->getMainConfig();
150        $this->updateRowsPerJob = $config->get( MainConfigNames::UpdateRowsPerJob );
151
152        $this->old = $old;
153        $this->new = $new;
154        $this->uid = $uid;
155        $this->renamer = $renamer;
156        $this->checkIfUserExists = true;
157
158        if ( isset( $options['checkIfUserExists'] ) ) {
159            $this->checkIfUserExists = $options['checkIfUserExists'];
160        }
161
162        if ( isset( $options['debugPrefix'] ) ) {
163            $this->debugPrefix = $options['debugPrefix'];
164        }
165
166        if ( isset( $options['reason'] ) ) {
167            $this->reason = $options['reason'];
168        }
169
170        if ( isset( $options['derived'] ) ) {
171            $this->derived = $options['derived'];
172        }
173
174        $this->tables = []; // Immediate updates
175        $this->tablesJob = []; // Slow updates
176
177        $this->hookRunner->onRenameUserSQL( $this );
178    }
179
180    protected function debug( string $msg ) {
181        if ( $this->debugPrefix ) {
182            $msg = "{$this->debugPrefix}: $msg";
183        }
184        $this->logger->debug( $msg );
185    }
186
187    /**
188     * Do the rename operation
189     * @deprecated since 1.44 use renameUser
190     * @return bool
191     */
192    public function rename() {
193        wfDeprecated( __METHOD__, '1.44' );
194        return $this->renameUser()->isOK();
195    }
196
197    /**
198     * Do the rename operation
199     * @return Status
200     */
201    public function renameUser(): Status {
202        $dbw = $this->dbProvider->getPrimaryDatabase();
203        $atomicId = $dbw->startAtomic( __METHOD__, $dbw::ATOMIC_CANCELABLE );
204
205        $this->hookRunner->onRenameUserPreRename( $this->uid, $this->old, $this->new );
206
207        // Make sure the user exists if needed
208        if ( $this->checkIfUserExists ) {
209            // if derived is true and 'user' table is shared,
210            // then 'user.user_name' should already be updated
211            $userRenamed = $this->derived && self::isTableShared( 'user' );
212            $currentName = $userRenamed ? $this->new : $this->old;
213            if ( !$this->lockUserAndGetId( $currentName ) ) {
214                $this->debug( "User $currentName does not exist, bailing out" );
215                $dbw->cancelAtomic( __METHOD__, $atomicId );
216
217                return Status::newFatal( 'renameusererrordoesnotexist', $this->new );
218            }
219        }
220
221        // Grab the user's edit count before any updates are made; used later in a log entry
222        $contribs = $this->userFactory->newFromId( $this->uid )->getEditCount();
223
224        // Rename and touch the user before re-attributing edits to avoid users still being
225        // logged in and making new edits (under the old name) while being renamed.
226        $this->debug( "Starting rename of {$this->old} to {$this->new}" );
227        if ( !$this->derived ) {
228            $this->debug( "Rename of {$this->old} to {$this->new} will update shared tables" );
229        }
230        if ( $this->shouldUpdate( 'user' ) ) {
231            $this->debug( "Updating user table for {$this->old} to {$this->new}" );
232            $dbw->newUpdateQueryBuilder()
233                ->update( 'user' )
234                ->set( [ 'user_name' => $this->new, 'user_touched' => $dbw->timestamp() ] )
235                ->where( [ 'user_name' => $this->old, 'user_id' => $this->uid ] )
236                ->caller( __METHOD__ )->execute();
237        } else {
238            $this->debug( "Skipping user table for rename from {$this->old} to {$this->new}" );
239        }
240        if ( $this->shouldUpdate( 'actor' ) ) {
241            $this->debug( "Updating actor table for {$this->old} to {$this->new}" );
242            $dbw->newUpdateQueryBuilder()
243                ->update( 'actor' )
244                ->set( [ 'actor_name' => $this->new ] )
245                ->where( [ 'actor_name' => $this->old, 'actor_user' => $this->uid ] )
246                ->caller( __METHOD__ )->execute();
247        } else {
248            $this->debug( "Skipping actor table for rename from {$this->old} to {$this->new}" );
249        }
250
251        // If this user is renaming themself, make sure that code below uses a proper name
252        if ( $this->renamer->getId() === $this->uid ) {
253            $this->renamer->setName( $this->new );
254        }
255
256        // Reset token to break login with central auth systems.
257        // Again, avoids user being logged in with old name.
258        $user = $this->userFactory->newFromId( $this->uid );
259
260        $user->load( IDBAccessObject::READ_LATEST );
261        MediaWikiServices::getInstance()->getSessionManager()->invalidateSessionsForUser( $user );
262
263        // Purge user cache
264        $user->invalidateCache();
265
266        // Update the block_target table rows if this user has a block in there.
267        if ( $this->shouldUpdate( 'block_target' ) ) {
268            $this->debug( "Updating block_target table for {$this->old} to {$this->new}" );
269            $dbw->newUpdateQueryBuilder()
270                ->update( 'block_target' )
271                ->set( [ 'bt_user_text' => $this->new ] )
272                ->where( [ 'bt_user' => $this->uid, 'bt_user_text' => $this->old ] )
273                ->caller( __METHOD__ )->execute();
274        } else {
275            $this->debug( "Skipping block_target table for rename from {$this->old} to {$this->new}" );
276        }
277
278        // Update this users block/rights log. Ideally, the logs would be historical,
279        // but it is really annoying when users have "clean" block logs by virtue of
280        // being renamed, which makes admin tasks more of a pain...
281        $oldTitle = $this->titleFactory->makeTitle( NS_USER, $this->old );
282        $newTitle = $this->titleFactory->makeTitle( NS_USER, $this->new );
283
284        // Exclude user renames per T200731
285        $logTypesOnUser = array_diff( SpecialLog::getLogTypesOnUser(), [ 'renameuser' ] );
286
287        if ( $this->shouldUpdate( 'logging' ) ) {
288            $this->debug( "Updating logging table for {$this->old} to {$this->new}" );
289            $dbw->newUpdateQueryBuilder()
290                ->update( 'logging' )
291                ->set( [ 'log_title' => $newTitle->getDBkey() ] )
292                ->where( [
293                    'log_type' => $logTypesOnUser,
294                    'log_namespace' => NS_USER,
295                    'log_title' => $oldTitle->getDBkey()
296                ] )
297                ->caller( __METHOD__ )->execute();
298        } else {
299            $this->debug( "Skipping logging table for rename from {$this->old} to {$this->new}" );
300        }
301
302        if ( $this->shouldUpdate( 'recentchanges' ) ) {
303            $this->debug( "Updating recentchanges table for rename from {$this->old} to {$this->new}" );
304            $dbw->newUpdateQueryBuilder()
305                ->update( 'recentchanges' )
306                ->set( [ 'rc_title' => $newTitle->getDBkey() ] )
307                ->where( [
308                    'rc_source' => RecentChange::SRC_LOG,
309                    'rc_log_type' => $logTypesOnUser,
310                    'rc_namespace' => NS_USER,
311                    'rc_title' => $oldTitle->getDBkey()
312                ] )
313                ->caller( __METHOD__ )->execute();
314        } else {
315            $this->debug( "Skipping recentchanges table for rename from {$this->old} to {$this->new}" );
316        }
317
318        // Do immediate re-attribution table updates...
319        foreach ( $this->tables as $table => $fieldSet ) {
320            [ $nameCol, $userCol ] = $fieldSet;
321            if ( $this->shouldUpdate( $table ) ) {
322                $this->debug( "Updating {$table} table for rename from {$this->old} to {$this->new}" );
323                $dbw->newUpdateQueryBuilder()
324                    ->update( $table )
325                    ->set( [ $nameCol => $this->new ] )
326                    ->where( [ $nameCol => $this->old, $userCol => $this->uid ] )
327                    ->caller( __METHOD__ )->execute();
328            } else {
329                $this->debug( "Skipping {$table} table for rename from {$this->old} to {$this->new}" );
330            }
331        }
332
333        /** @var \MediaWiki\RenameUser\Job\RenameUserTableJob[] $jobs */
334        $jobs = []; // jobs for all tables
335        // Construct jobqueue updates...
336        // FIXME: if a bureaucrat renames a user in error, they
337        // must be careful to wait until the rename finishes before
338        // renaming back. This is due to the fact the job "queue"
339        // is not really FIFO, so we might end up with a bunch of edits
340        // randomly mixed between the two new names. Some sort of rename
341        // lock might be in order...
342        foreach ( $this->tablesJob as $table => $params ) {
343            if ( !$this->shouldUpdate( $table ) ) {
344                $this->debug( "Skipping {$table} table for rename from {$this->old} to {$this->new}" );
345                continue;
346            }
347            $this->debug( "Updating {$table} table for rename from {$this->old} to {$this->new}" );
348
349            $userTextC = $params[self::NAME_COL]; // some *_user_text column
350            $userIDC = $params[self::UID_COL]; // some *_user column
351            $timestampC = $params[self::TIME_COL]; // some *_timestamp column
352
353            $res = $dbw->newSelectQueryBuilder()
354                ->select( [ $timestampC ] )
355                ->from( $table )
356                ->where( [ $userTextC => $this->old, $userIDC => $this->uid ] )
357                ->orderBy( $timestampC, SelectQueryBuilder::SORT_ASC )
358                ->caller( __METHOD__ )->fetchResultSet();
359
360            $jobParams = [
361                'table' => $table,
362                'column' => $userTextC,
363                'uidColumn' => $userIDC,
364                'timestampColumn' => $timestampC,
365                'oldname' => $this->old,
366                'newname' => $this->new,
367                'userID' => $this->uid,
368                // Timestamp column data for index optimizations
369                'minTimestamp' => '0',
370                'maxTimestamp' => '0',
371                'count' => 0,
372            ];
373            // Unique column for replica lag avoidance
374            if ( isset( $params['uniqueKey'] ) ) {
375                $jobParams['uniqueKey'] = $params['uniqueKey'];
376            }
377
378            // Insert jobs into queue!
379            foreach ( $res as $row ) {
380                // Since the ORDER BY is ASC, set the min timestamp with first row
381                if ( $jobParams['count'] === 0 ) {
382                    $jobParams['minTimestamp'] = $row->$timestampC;
383                }
384                // Keep updating the last timestamp, so it should be correct
385                // when the last item is added.
386                $jobParams['maxTimestamp'] = $row->$timestampC;
387                // Update row counter
388                $jobParams['count']++;
389                // Once a job has $wgUpdateRowsPerJob rows, add it to the queue
390                if ( $jobParams['count'] >= $this->updateRowsPerJob ) {
391                    $jobs[] = new JobSpecification( 'renameUserTable', $jobParams, [], $oldTitle );
392                    $jobParams['minTimestamp'] = '0';
393                    $jobParams['maxTimestamp'] = '0';
394                    $jobParams['count'] = 0;
395                }
396            }
397            // If there are any job rows left, add it to the queue as one job
398            if ( $jobParams['count'] > 0 ) {
399                $jobs[] = new JobSpecification( 'renameUserTable', $jobParams, [], $oldTitle );
400            }
401        }
402
403        // Log it!
404        $logEntry = new ManualLogEntry( 'renameuser', 'renameuser' );
405        $logEntry->setPerformer( $this->renamer );
406        $logEntry->setTarget( $oldTitle );
407        $logEntry->setComment( $this->reason );
408        $logEntry->setParameters( [
409            '4::olduser' => $this->old,
410            '5::newuser' => $this->new,
411            '6::edits' => $contribs,
412            'derived' => $this->derived
413        ] );
414        $logid = $logEntry->insert();
415
416        // Insert any jobs as needed. If this fails, then an exception will be thrown and the
417        // DB transaction will be rolled back. If it succeeds but the DB commit fails, then the
418        // jobs will see that the transaction was not committed and will cancel themselves.
419        $count = count( $jobs );
420        if ( $count > 0 ) {
421            $this->jobQueueGroup->push( $jobs );
422            $this->debug( "Queued $count jobs for rename from {$this->old} to {$this->new}" );
423        }
424
425        // Commit the transaction
426        $dbw->endAtomic( __METHOD__ );
427
428        $fname = __METHOD__;
429        $dbw->onTransactionCommitOrIdle(
430            function () use ( $dbw, $logEntry, $logid, $fname ) {
431                $dbw->startAtomic( $fname );
432                // Clear caches and inform authentication plugins
433                $user = $this->userFactory->newFromId( $this->uid );
434                $user->load( IDBAccessObject::READ_LATEST );
435                // Trigger the UserSaveSettings hook
436                $user->saveSettings();
437                $this->hookRunner->onRenameUserComplete( $this->uid, $this->old, $this->new );
438                // Publish to RC
439                $logEntry->publish( $logid );
440                $dbw->endAtomic( $fname );
441            },
442            $fname
443        );
444
445        $this->debug( "Finished rename from {$this->old} to {$this->new}" );
446
447        return Status::newGood();
448    }
449
450    /**
451     * @param string $name Current wiki local username
452     * @return int Returns 0 if no row was found
453     */
454    private function lockUserAndGetId( $name ) {
455        return (int)$this->dbProvider->getPrimaryDatabase()->newSelectQueryBuilder()
456            ->select( 'user_id' )
457            ->forUpdate()
458            ->from( 'user' )
459            ->where( [ 'user_name' => $name ] )
460            ->caller( __METHOD__ )->fetchField();
461    }
462
463    /**
464     * Checks if a table should be updated in this rename.
465     * @param string $table
466     * @return bool
467     */
468    private function shouldUpdate( string $table ) {
469        return !$this->derived || !self::isTableShared( $table );
470    }
471
472    /**
473     * Check if a table is shared.
474     *
475     * @param string $table The table name
476     * @return bool Returns true if the table is shared
477     */
478    private static function isTableShared( string $table ) {
479        global $wgSharedTables, $wgSharedDB;
480        return $wgSharedDB && in_array( $table, $wgSharedTables, true );
481    }
482
483    /**
484     * This method does not check log_deleted, and thus will reveal the existence of hidden rename log
485     * entries. This seems unavoidable: we have to reveal that the username can't be registered when
486     * someone tries to register it, and there is no other plausible error message we could give.
487     * Please review whether that's appropriate for your use case when calling it.
488     *
489     * @since 1.47
490     */
491    public static function isPreviouslyRenamedAccount( string $username, IReadableDatabase $db ): bool {
492        return (bool)$db->newSelectQueryBuilder()
493            ->from( 'logging' )
494            ->where( [
495                'log_type' => 'renameuser',
496                'log_action' => 'renameuser',
497                'log_namespace' => NS_USER,
498                'log_title' => strtr( $username, ' ', '_' ),
499            ] )
500            ->field( '1' )
501            ->caller( __METHOD__ )
502            ->fetchField();
503    }
504}