Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
68.07% |
113 / 166 |
|
25.00% |
1 / 4 |
CRAP | |
0.00% |
0 / 1 |
RenameuserSQL | |
68.07% |
113 / 166 |
|
25.00% |
1 / 4 |
33.02 | |
0.00% |
0 / 1 |
__construct | |
92.00% |
23 / 25 |
|
0.00% |
0 / 1 |
4.01 | |||
debug | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
rename | |
62.12% |
82 / 132 |
|
0.00% |
0 / 1 |
22.18 | |||
lockUserAndGetId | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\RenameUser; |
4 | |
5 | use IDBAccessObject; |
6 | use JobQueueGroup; |
7 | use JobSpecification; |
8 | use ManualLogEntry; |
9 | use MediaWiki\HookContainer\HookRunner; |
10 | use MediaWiki\Logger\LoggerFactory; |
11 | use MediaWiki\MainConfigNames; |
12 | use MediaWiki\MediaWikiServices; |
13 | use MediaWiki\Session\SessionManager; |
14 | use MediaWiki\Specials\SpecialLog; |
15 | use MediaWiki\Title\TitleFactory; |
16 | use MediaWiki\User\User; |
17 | use MediaWiki\User\UserFactory; |
18 | use Psr\Log\LoggerInterface; |
19 | use RenameUserJob; |
20 | use Wikimedia\Rdbms\IConnectionProvider; |
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 | // 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 | } |