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