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