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