Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
67.54% |
129 / 191 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
RenameuserSQL | |
67.54% |
129 / 191 |
|
42.86% |
3 / 7 |
76.90 | |
0.00% |
0 / 1 |
__construct | |
92.00% |
23 / 25 |
|
0.00% |
0 / 1 |
5.01 | |||
debug | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
rename | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
renameUser | |
62.75% |
96 / 153 |
|
0.00% |
0 / 1 |
47.03 | |||
lockUserAndGetId | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
shouldUpdate | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
isTableShared | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\RenameUser; |
4 | |
5 | use MediaWiki\HookContainer\HookRunner; |
6 | use MediaWiki\JobQueue\JobQueueGroup; |
7 | use MediaWiki\JobQueue\JobSpecification; |
8 | use MediaWiki\Logger\LoggerFactory; |
9 | use MediaWiki\Logging\ManualLogEntry; |
10 | use MediaWiki\MainConfigNames; |
11 | use MediaWiki\MediaWikiServices; |
12 | use MediaWiki\Session\SessionManager; |
13 | use MediaWiki\Specials\SpecialLog; |
14 | use MediaWiki\Status\Status; |
15 | use MediaWiki\Title\TitleFactory; |
16 | use MediaWiki\User\User; |
17 | use MediaWiki\User\UserFactory; |
18 | use Psr\Log\LoggerInterface; |
19 | use Wikimedia\Rdbms\IConnectionProvider; |
20 | use Wikimedia\Rdbms\IDBAccessObject; |
21 | use Wikimedia\Rdbms\SelectQueryBuilder; |
22 | |
23 | /** |
24 | * Class which performs the actual renaming of users |
25 | */ |
26 | class 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 | } |