Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
23.03% |
82 / 356 |
|
23.08% |
3 / 13 |
CRAP | |
0.00% |
0 / 1 |
MergeUser | |
23.03% |
82 / 356 |
|
23.08% |
3 / 13 |
1872.60 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
merge | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
delete | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
mergeEditcount | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
2 | |||
mergeBlocks | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
30 | |||
chooseBlock | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
56 | |||
stageNeedsUser | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
stageNeedsActor | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
mergeDatabaseTables | |
0.00% |
0 / 135 |
|
0.00% |
0 / 1 |
420 | |||
deduplicateWatchlistEntries | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
42 | |||
movePages | |
90.91% |
50 / 55 |
|
0.00% |
0 / 1 |
10.08 | |||
deletePage | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
deleteUser | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | use MediaWiki\Block\DatabaseBlock; |
4 | use MediaWiki\Block\DatabaseBlockStore; |
5 | use MediaWiki\Deferred\DeferredUpdates; |
6 | use MediaWiki\Deferred\SiteStatsUpdate; |
7 | use MediaWiki\Extension\UserMerge\Hooks\HookRunner; |
8 | use MediaWiki\MediaWikiServices; |
9 | use MediaWiki\Title\Title; |
10 | use MediaWiki\User\User; |
11 | use Wikimedia\Rdbms\IDatabase; |
12 | use Wikimedia\Rdbms\IExpression; |
13 | use Wikimedia\Rdbms\LikeValue; |
14 | |
15 | /** |
16 | * Contains the actual database backend logic for merging users |
17 | */ |
18 | class MergeUser { |
19 | /** |
20 | * @var User |
21 | */ |
22 | private $oldUser; |
23 | /** |
24 | * @var User |
25 | */ |
26 | private $newUser; |
27 | |
28 | /** |
29 | * @var IUserMergeLogger |
30 | */ |
31 | private $logger; |
32 | |
33 | /** |
34 | * @var DatabaseBlockStore |
35 | */ |
36 | private $blockStore; |
37 | |
38 | /** @var int */ |
39 | private $flags; |
40 | |
41 | // allow begin/commit; useful for jobs or CLI mode |
42 | public const USE_MULTI_COMMIT = 1; |
43 | |
44 | /** |
45 | * @param User $oldUser |
46 | * @param User $newUser |
47 | * @param IUserMergeLogger $logger |
48 | * @param DatabaseBlockStore $blockStore |
49 | * @param int $flags Bitfield (Supports MergeUser::USE_*) |
50 | */ |
51 | public function __construct( |
52 | User $oldUser, |
53 | User $newUser, |
54 | IUserMergeLogger $logger, |
55 | DatabaseBlockStore $blockStore, |
56 | $flags = 0 |
57 | ) { |
58 | $this->newUser = $newUser; |
59 | $this->oldUser = $oldUser; |
60 | $this->logger = $logger; |
61 | $this->blockStore = $blockStore; |
62 | $this->flags = $flags; |
63 | } |
64 | |
65 | /** |
66 | * @param User $performer |
67 | * @param string $fnameTrxOwner |
68 | */ |
69 | public function merge( User $performer, $fnameTrxOwner = __METHOD__ ) { |
70 | $this->mergeEditcount(); |
71 | $this->mergeDatabaseTables( $fnameTrxOwner ); |
72 | $this->logger->addMergeEntry( $performer, $this->oldUser, $this->newUser ); |
73 | } |
74 | |
75 | /** |
76 | * @param User $performer |
77 | * @param callable $msg something that returns a Message object |
78 | * |
79 | * @return array Array of failed page moves, see MergeUser::movePages |
80 | */ |
81 | public function delete( User $performer, $msg ) { |
82 | $failed = $this->movePages( $performer, $msg ); |
83 | $this->deleteUser(); |
84 | $this->logger->addDeleteEntry( $performer, $this->oldUser ); |
85 | |
86 | return $failed; |
87 | } |
88 | |
89 | /** |
90 | * Adds edit count of both users |
91 | */ |
92 | private function mergeEditcount() { |
93 | $dbw = MediaWikiServices::getInstance() |
94 | ->getConnectionProvider() |
95 | ->getPrimaryDatabase(); |
96 | $dbw->startAtomic( __METHOD__ ); |
97 | |
98 | $totalEdits = $dbw->newSelectQueryBuilder() |
99 | ->select( 'SUM(user_editcount)' ) |
100 | ->from( 'user' ) |
101 | ->where( [ 'user_id' => [ $this->newUser->getId(), $this->oldUser->getId() ] ] ) |
102 | ->caller( __METHOD__ ) |
103 | ->fetchField(); |
104 | |
105 | $totalEdits = (int)$totalEdits; |
106 | |
107 | # don't run queries if neither user has any edits |
108 | if ( $totalEdits > 0 ) { |
109 | # update new user with total edits |
110 | $dbw->newUpdateQueryBuilder() |
111 | ->update( 'user' ) |
112 | ->set( [ 'user_editcount' => $totalEdits ] ) |
113 | ->where( [ 'user_id' => $this->newUser->getId() ] ) |
114 | ->caller( __METHOD__ ) |
115 | ->execute(); |
116 | |
117 | # clear old user's edits |
118 | $dbw->newUpdateQueryBuilder() |
119 | ->update( 'user' ) |
120 | ->set( [ 'user_editcount' => 0 ] ) |
121 | ->where( [ 'user_id' => $this->oldUser->getId() ] ) |
122 | ->caller( __METHOD__ ) |
123 | ->execute(); |
124 | } |
125 | |
126 | $dbw->endAtomic( __METHOD__ ); |
127 | } |
128 | |
129 | /** |
130 | * @param IDatabase $dbw |
131 | * @return void |
132 | */ |
133 | private function mergeBlocks( IDatabase $dbw ) { |
134 | $dbw->startAtomic( __METHOD__ ); |
135 | |
136 | // Pull blocks directly from primary |
137 | $oldBlocks = $this->blockStore->newListFromConds( |
138 | [ 'bt_user' => $this->oldUser->getId() ], |
139 | true, true |
140 | ); |
141 | $newBlocks = $this->blockStore->newListFromConds( |
142 | [ 'bt_user' => $this->newUser->getId() ], |
143 | true, true |
144 | ); |
145 | |
146 | if ( !$oldBlocks ) { |
147 | // No one is blocked or |
148 | // Only the new user is blocked, so nothing to do. |
149 | $dbw->endAtomic( __METHOD__ ); |
150 | return; |
151 | } |
152 | if ( !$newBlocks ) { |
153 | // Just move the old blocks to the new username |
154 | foreach ( $oldBlocks as $block ) { |
155 | $this->blockStore->updateTarget( $block, $this->newUser ); |
156 | } |
157 | $dbw->endAtomic( __METHOD__ ); |
158 | return; |
159 | } |
160 | |
161 | // Okay, let's pick the "strongest" block, and re-apply it to |
162 | // the new user. |
163 | $oldBlockObj = reset( $oldBlocks ); |
164 | $newBlockObj = reset( $newBlocks ); |
165 | $winner = $this->chooseBlock( $oldBlockObj, $newBlockObj ); |
166 | if ( $winner->getId() === $newBlockObj->getId() ) { |
167 | $oldBlockObj->delete(); |
168 | } else { |
169 | // Old user block won |
170 | // Delete current new block |
171 | $newBlockObj->delete(); |
172 | $this->blockStore->updateTarget( $oldBlockObj, $this->newUser ); |
173 | } |
174 | |
175 | $dbw->endAtomic( __METHOD__ ); |
176 | } |
177 | |
178 | /** |
179 | * @param DatabaseBlock $b1 |
180 | * @param DatabaseBlock $b2 |
181 | * @return DatabaseBlock |
182 | */ |
183 | private function chooseBlock( DatabaseBlock $b1, DatabaseBlock $b2 ) { |
184 | // First, see if one is longer than the other. |
185 | if ( $b1->getExpiry() !== $b2->getExpiry() ) { |
186 | // This works for infinite blocks because: |
187 | // "infinity" > "20141024234513" |
188 | if ( $b1->getExpiry() > $b2->getExpiry() ) { |
189 | return $b1; |
190 | } else { |
191 | return $b2; |
192 | } |
193 | } |
194 | |
195 | // Next check what they block, in order |
196 | $blockProps = []; |
197 | foreach ( [ $b1, $b2 ] as $block ) { |
198 | $blockProps[] = [ |
199 | 'block' => $block, |
200 | 'createaccount' => $block->isCreateAccountBlocked(), |
201 | 'sendemail' => $block->isEmailBlocked(), |
202 | 'editownusertalk' => !$block->isUsertalkEditAllowed(), |
203 | ]; |
204 | } |
205 | foreach ( [ 'createaccount', 'sendemail', 'editownusertalk' ] as $action ) { |
206 | if ( $blockProps[0][$action] xor $blockProps[1][$action] ) { |
207 | if ( $blockProps[0][$action] ) { |
208 | return $blockProps[0]['block']; |
209 | } else { |
210 | return $blockProps[1]['block']; |
211 | } |
212 | } |
213 | } |
214 | |
215 | // Give up, return the second one. |
216 | return $b2; |
217 | } |
218 | |
219 | /** |
220 | * @param int $stage |
221 | * |
222 | * @return bool |
223 | */ |
224 | private function stageNeedsUser( $stage ) { |
225 | if ( !defined( 'MIGRATION_NEW' ) ) { |
226 | return true; |
227 | } |
228 | |
229 | if ( defined( 'ActorMigration::MIGRATION_STAGE_SCHEMA_COMPAT' ) ) { |
230 | return (bool)( (int)$stage & SCHEMA_COMPAT_WRITE_OLD ); |
231 | } else { |
232 | return $stage < MIGRATION_NEW; |
233 | } |
234 | } |
235 | |
236 | /** |
237 | * @param int $stage |
238 | * |
239 | * @return bool |
240 | */ |
241 | private function stageNeedsActor( $stage ) { |
242 | if ( !defined( 'MIGRATION_NEW' ) ) { |
243 | return false; |
244 | } |
245 | |
246 | if ( defined( 'ActorMigration::MIGRATION_STAGE_SCHEMA_COMPAT' ) ) { |
247 | return (bool)( $stage & SCHEMA_COMPAT_WRITE_NEW ); |
248 | } else { |
249 | return $stage > MIGRATION_OLD; |
250 | } |
251 | } |
252 | |
253 | /** |
254 | * Function to merge database references from one user to another user |
255 | * |
256 | * Merges database references from one user ID or username to another user ID or username |
257 | * to preserve referential integrity. |
258 | * |
259 | * @param string $fnameTrxOwner |
260 | */ |
261 | private function mergeDatabaseTables( $fnameTrxOwner ) { |
262 | // Fields to update with the format: |
263 | // [ |
264 | // tableName, idField, textField, |
265 | // 'batchKey' => unique field, 'options' => array(), 'db' => IDatabase |
266 | // 'actorId' => actor ID field, |
267 | // 'actorStage' => actor schema migration stage |
268 | // ]; |
269 | // textField, batchKey, db, and options are optional |
270 | $updateFields = [ |
271 | [ 'archive', 'batchKey' => 'ar_id', 'actorId' => 'ar_actor', |
272 | 'actorStage' => SCHEMA_COMPAT_NEW ], |
273 | [ 'revision', 'batchKey' => 'rev_id', 'actorId' => 'rev_actor', |
274 | 'actorStage' => SCHEMA_COMPAT_NEW ], |
275 | [ 'filearchive', 'batchKey' => 'fa_id', 'actorId' => 'fa_actor', |
276 | 'actorStage' => SCHEMA_COMPAT_NEW ], |
277 | [ 'image', 'batchKey' => 'img_name', 'actorId' => 'img_actor', |
278 | 'actorStage' => SCHEMA_COMPAT_NEW ], |
279 | [ 'oldimage', 'batchKey' => 'oi_archive_name', 'actorId' => 'oi_actor', |
280 | 'actorStage' => SCHEMA_COMPAT_NEW ], |
281 | [ 'recentchanges', 'batchKey' => 'rc_id', 'actorId' => 'rc_actor', |
282 | 'actorStage' => SCHEMA_COMPAT_NEW ], |
283 | [ 'logging', 'batchKey' => 'log_id', 'actorId' => 'log_actor', |
284 | 'actorStage' => SCHEMA_COMPAT_NEW ], |
285 | [ 'block', 'batchKey' => 'bl_id', 'actorId' => 'bl_by_actor', |
286 | 'actorStage' => SCHEMA_COMPAT_NEW ], |
287 | [ 'watchlist', 'wl_user', 'batchKey' => 'wl_title' ], |
288 | [ 'user_groups', 'ug_user', 'options' => [ 'IGNORE' ] ], |
289 | [ 'user_properties', 'up_user', 'options' => [ 'IGNORE' ] ], |
290 | [ 'user_former_groups', 'ufg_user', 'options' => [ 'IGNORE' ] ], |
291 | [ 'revision_actor_temp', 'batchKey' => 'revactor_rev', 'actorId' => 'revactor_actor', |
292 | 'actorStage' => SCHEMA_COMPAT_TEMP ], |
293 | ]; |
294 | |
295 | $services = MediaWikiServices::getInstance(); |
296 | $hookRunner = new HookRunner( $services->getHookContainer() ); |
297 | $hookRunner->onUserMergeAccountFields( $updateFields ); |
298 | |
299 | $lbFactory = $services->getDBLoadBalancerFactory(); |
300 | $dbw = $lbFactory->getPrimaryDatabase(); |
301 | $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ ); |
302 | |
303 | $this->deduplicateWatchlistEntries( $dbw ); |
304 | $this->mergeBlocks( $dbw ); |
305 | |
306 | if ( $this->flags & self::USE_MULTI_COMMIT ) { |
307 | // Flush prior writes; this actives the non-transaction path in the loop below. |
308 | $lbFactory->commitPrimaryChanges( $fnameTrxOwner ); |
309 | } |
310 | |
311 | foreach ( $updateFields as $fieldInfo ) { |
312 | if ( !isset( $fieldInfo[1] ) ) { |
313 | // Actors only |
314 | continue; |
315 | } |
316 | |
317 | $options = $fieldInfo['options'] ?? []; |
318 | unset( $fieldInfo['options'] ); |
319 | $db = $fieldInfo['db'] ?? $dbw; |
320 | unset( $fieldInfo['db'] ); |
321 | $tableName = array_shift( $fieldInfo ); |
322 | $idField = array_shift( $fieldInfo ); |
323 | $keyField = $fieldInfo['batchKey'] ?? null; |
324 | unset( $fieldInfo['batchKey'] ); |
325 | |
326 | if ( isset( $fieldInfo['actorId'] ) && isset( $fieldInfo['actorStage'] ) && |
327 | !$this->stageNeedsUser( $fieldInfo['actorStage'] ) |
328 | ) { |
329 | continue; |
330 | } |
331 | unset( $fieldInfo['actorId'], $fieldInfo['actorStage'] ); |
332 | |
333 | if ( $db->trxLevel() || $keyField === null ) { |
334 | // Can't batch/wait when in a transaction or when no batch key is given |
335 | $db->newUpdateQueryBuilder() |
336 | ->update( $tableName ) |
337 | ->set( [ $idField => $this->newUser->getId() ] |
338 | + array_fill_keys( $fieldInfo, $this->newUser->getName() ) ) |
339 | ->where( [ $idField => $this->oldUser->getId() ] ) |
340 | ->options( $options ) |
341 | ->caller( __METHOD__ ) |
342 | ->execute(); |
343 | } else { |
344 | $limit = 200; |
345 | do { |
346 | $checkSince = microtime( true ); |
347 | // Note that UPDATE with ORDER BY + LIMIT is not well supported. |
348 | // Grab a batch of values on a mostly unique column for this user ID. |
349 | $res = $db->newSelectQueryBuilder() |
350 | ->select( $keyField ) |
351 | ->from( $tableName ) |
352 | ->where( [ $idField => $this->oldUser->getId() ] ) |
353 | ->limit( $limit ) |
354 | ->caller( __METHOD__ ) |
355 | ->fetchResultSet(); |
356 | $keyValues = []; |
357 | foreach ( $res as $row ) { |
358 | $keyValues[] = $row->$keyField; |
359 | } |
360 | // Update only those rows with the given column values |
361 | if ( count( $keyValues ) ) { |
362 | $db->newUpdateQueryBuilder() |
363 | ->update( $tableName ) |
364 | ->set( [ $idField => $this->newUser->getId() ] |
365 | + array_fill_keys( $fieldInfo, $this->newUser->getName() ) ) |
366 | ->where( [ $idField => $this->oldUser->getId(), $keyField => $keyValues ] ) |
367 | ->options( $options ) |
368 | ->caller( __METHOD__ ) |
369 | ->execute(); |
370 | } |
371 | // Wait for replication to catch up |
372 | $opts = [ 'ifWritesSince' => $checkSince ]; |
373 | $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket, $opts ); |
374 | } while ( count( $keyValues ) >= $limit ); |
375 | } |
376 | } |
377 | |
378 | if ( $this->oldUser->getActorId() ) { |
379 | $oldActorId = $this->oldUser->getActorId(); |
380 | $newActorId = MediaWikiServices::getInstance() |
381 | ->getActorNormalization() |
382 | ->acquireActorId( $this->newUser, $dbw ); |
383 | |
384 | foreach ( $updateFields as $fieldInfo ) { |
385 | if ( empty( $fieldInfo['actorId'] ) || empty( $fieldInfo['actorStage'] ) || |
386 | !$this->stageNeedsActor( $fieldInfo['actorStage'] ) |
387 | ) { |
388 | continue; |
389 | } |
390 | |
391 | $options = $fieldInfo['options'] ?? []; |
392 | $db = $fieldInfo['db'] ?? $dbw; |
393 | $tableName = array_shift( $fieldInfo ); |
394 | $idField = $fieldInfo['actorId']; |
395 | $keyField = $fieldInfo['batchKey'] ?? null; |
396 | |
397 | if ( $db->trxLevel() || $keyField === null ) { |
398 | // Can't batch/wait when in a transaction or when no batch key is given |
399 | $db->newUpdateQueryBuilder() |
400 | ->update( $tableName ) |
401 | ->set( [ $idField => $newActorId ] ) |
402 | ->where( [ $idField => $oldActorId ] ) |
403 | ->options( $options ) |
404 | ->caller( __METHOD__ ) |
405 | ->execute(); |
406 | } else { |
407 | $limit = 200; |
408 | do { |
409 | $checkSince = microtime( true ); |
410 | // Note that UPDATE with ORDER BY + LIMIT is not well supported. |
411 | // Grab a batch of values on a mostly unique column for this user ID. |
412 | $res = $db->newSelectQueryBuilder() |
413 | ->select( $keyField ) |
414 | ->from( $tableName ) |
415 | ->where( [ $idField => $oldActorId ] ) |
416 | ->limit( $limit ) |
417 | ->caller( __METHOD__ ) |
418 | ->fetchResultSet(); |
419 | $keyValues = []; |
420 | foreach ( $res as $row ) { |
421 | $keyValues[] = $row->$keyField; |
422 | } |
423 | // Update only those rows with the given column values |
424 | if ( count( $keyValues ) ) { |
425 | $db->newUpdateQueryBuilder() |
426 | ->update( $tableName ) |
427 | ->set( [ $idField => $newActorId ] ) |
428 | ->where( [ $idField => $oldActorId, $keyField => $keyValues ] ) |
429 | ->options( $options ) |
430 | ->caller( __METHOD__ ) |
431 | ->execute(); |
432 | } |
433 | // Wait for replication to catch up |
434 | $opts = [ 'ifWritesSince' => $checkSince ]; |
435 | $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket, $opts ); |
436 | } while ( count( $keyValues ) >= $limit ); |
437 | } |
438 | } |
439 | } |
440 | |
441 | $dbw->newDeleteQueryBuilder() |
442 | ->deleteFrom( 'user_newtalk' ) |
443 | ->where( [ 'user_id' => $this->oldUser->getId() ] ) |
444 | ->caller( __METHOD__ ) |
445 | ->execute(); |
446 | $this->oldUser->clearInstanceCache(); |
447 | $this->newUser->clearInstanceCache(); |
448 | |
449 | $hookRunner->onMergeAccountFromTo( $this->oldUser, $this->newUser ); |
450 | } |
451 | |
452 | /** |
453 | * Deduplicate watchlist entries |
454 | * which old (merge-from) and new (merge-to) users are watching |
455 | * |
456 | * @param IDatabase $dbw |
457 | */ |
458 | private function deduplicateWatchlistEntries( $dbw ) { |
459 | $dbw->startAtomic( __METHOD__ ); |
460 | |
461 | // Get all titles both watched by the old and new user accounts. |
462 | // Avoid using self-joins as this fails on temporary tables (e.g. unit tests). |
463 | // See https://bugs.mysql.com/bug.php?id=10327. |
464 | $titlesToDelete = []; |
465 | $res = $dbw->newSelectQueryBuilder() |
466 | ->select( [ 'wl_namespace', 'wl_title' ] ) |
467 | ->from( 'watchlist' ) |
468 | ->where( [ 'wl_user' => $this->oldUser->getId() ] ) |
469 | ->forUpdate() |
470 | ->caller( __METHOD__ ) |
471 | ->fetchResultSet(); |
472 | foreach ( $res as $row ) { |
473 | $titlesToDelete[$row->wl_namespace . "|" . $row->wl_title] = false; |
474 | } |
475 | $res = $dbw->newSelectQueryBuilder() |
476 | ->select( [ 'wl_namespace', 'wl_title' ] ) |
477 | ->from( 'watchlist' ) |
478 | ->where( [ 'wl_user' => $this->newUser->getId() ] ) |
479 | ->forUpdate() |
480 | ->caller( __METHOD__ ) |
481 | ->fetchResultSet(); |
482 | foreach ( $res as $row ) { |
483 | $key = $row->wl_namespace . "|" . $row->wl_title; |
484 | if ( isset( $titlesToDelete[$key] ) ) { |
485 | $titlesToDelete[$key] = true; |
486 | } |
487 | } |
488 | $titlesToDelete = array_filter( $titlesToDelete ); |
489 | |
490 | $conds = []; |
491 | foreach ( array_keys( $titlesToDelete ) as $tuple ) { |
492 | [ $ns, $dbKey ] = explode( "|", $tuple, 2 ); |
493 | $conds[] = $dbw->andExpr( [ |
494 | 'wl_user' => $this->oldUser->getId(), |
495 | 'wl_namespace' => $ns, |
496 | 'wl_title' => $dbKey |
497 | ] ); |
498 | } |
499 | |
500 | if ( count( $conds ) ) { |
501 | # Perform a multi-row delete |
502 | $dbw->newDeleteQueryBuilder() |
503 | ->deleteFrom( 'watchlist' ) |
504 | ->where( $dbw->orExpr( $conds ) ) |
505 | ->caller( __METHOD__ ) |
506 | ->execute(); |
507 | } |
508 | |
509 | $dbw->endAtomic( __METHOD__ ); |
510 | } |
511 | |
512 | /** |
513 | * Function to merge user pages |
514 | * |
515 | * Deletes all pages when merging to Anon |
516 | * Moves user page when the target user page does not exist or is empty |
517 | * Deletes redirect if nothing links to old page |
518 | * Deletes the old user page when the target user page exists |
519 | * |
520 | * @todo This code is a duplicate of Renameuser and GlobalRename |
521 | * |
522 | * @param User $performer |
523 | * @param callable $msg Function that returns a Message object |
524 | * @return array Array of old name (string) => new name (Title) where the move failed |
525 | */ |
526 | private function movePages( User $performer, $msg ) { |
527 | $contLang = MediaWikiServices::getInstance()->getContentLanguage(); |
528 | |
529 | $oldusername = trim( str_replace( '_', ' ', $this->oldUser->getName() ) ); |
530 | $oldusername = Title::makeTitle( NS_USER, $oldusername ); |
531 | $newusername = Title::makeTitleSafe( NS_USER, $contLang->ucfirst( $this->newUser->getName() ) ); |
532 | |
533 | # select all user pages and sub-pages |
534 | $dbr = MediaWikiServices::getInstance() |
535 | ->getConnectionProvider() |
536 | ->getReplicaDatabase(); |
537 | $pages = $dbr->newSelectQueryBuilder() |
538 | ->select( [ 'page_namespace', 'page_title' ] ) |
539 | ->from( 'page' ) |
540 | ->where( [ |
541 | 'page_namespace' => [ NS_USER, NS_USER_TALK ], |
542 | $dbr->expr( 'page_title', IExpression::LIKE, |
543 | new LikeValue( $oldusername->getDBkey() . '/', $dbr->anyString() ) |
544 | )->or( 'page_title', '=', $oldusername->getDBkey() ), |
545 | ] ) |
546 | ->caller( __METHOD__ ) |
547 | ->fetchResultSet(); |
548 | |
549 | $message = static function () use ( $msg ) { |
550 | return call_user_func_array( $msg, func_get_args() ); |
551 | }; |
552 | |
553 | $failedMoves = []; |
554 | foreach ( $pages as $row ) { |
555 | $oldPage = Title::makeTitleSafe( $row->page_namespace, $row->page_title ); |
556 | $newPage = Title::makeTitleSafe( $row->page_namespace, |
557 | preg_replace( '!^[^/]+!', $newusername->getDBkey(), $row->page_title ) ); |
558 | |
559 | if ( $this->newUser->getName() === 'Anonymous' ) { |
560 | # delete ALL old pages |
561 | if ( $oldPage->exists() ) { |
562 | $this->deletePage( $message, $performer, $oldPage ); |
563 | } |
564 | } elseif ( $newPage->exists() |
565 | && !MediaWikiServices::getInstance() |
566 | ->getMovePageFactory() |
567 | ->newMovePage( $oldPage, $newPage ) |
568 | ->isValidMove() |
569 | ->isOk() |
570 | && $newPage->getLength() > 0 |
571 | ) { |
572 | # delete old pages that can't be moved |
573 | $this->deletePage( $message, $performer, $oldPage ); |
574 | } else { |
575 | # move content to new page |
576 | # delete target page if it exists and is blank |
577 | if ( $newPage->exists() ) { |
578 | $this->deletePage( $message, $performer, $newPage ); |
579 | } |
580 | |
581 | # move to target location |
582 | $status = MediaWikiServices::getInstance() |
583 | ->getMovePageFactory() |
584 | ->newMovePage( $oldPage, $newPage ) |
585 | ->move( |
586 | $performer, |
587 | $message( |
588 | 'usermerge-move-log', |
589 | $oldusername->getText(), |
590 | $newusername->getText() )->inContentLanguage()->text() |
591 | ); |
592 | if ( !$status->isOk() ) { |
593 | $failedMoves[$oldPage->getPrefixedText()] = $newPage; |
594 | } |
595 | |
596 | # check if any pages link here |
597 | $res = $oldPage->getLinksTo( [ 'limit' => 1 ] ); |
598 | if ( !$res ) { |
599 | # nothing links here, so delete unmoved page/redirect |
600 | $this->deletePage( $message, $performer, $oldPage ); |
601 | } |
602 | } |
603 | } |
604 | |
605 | return $failedMoves; |
606 | } |
607 | |
608 | /** |
609 | * Helper to delete pages |
610 | * |
611 | * @param callable $msg |
612 | * @param User $user |
613 | * @param Title $title |
614 | */ |
615 | private function deletePage( $msg, User $user, Title $title ) { |
616 | $wikipage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title ); |
617 | $reason = $msg( 'usermerge-autopagedelete' )->inContentLanguage()->text(); |
618 | $error = ''; |
619 | $wikipage->doDeleteArticleReal( |
620 | $reason, |
621 | $user, |
622 | false, |
623 | // Unused |
624 | null, |
625 | $error, |
626 | // Unused |
627 | null, |
628 | [], |
629 | 'delete', |
630 | true |
631 | ); |
632 | } |
633 | |
634 | /** |
635 | * Function to delete users following a successful mergeUser call. |
636 | * |
637 | * Removes rows from the user, user_groups, user_properties |
638 | * and user_former_groups tables. |
639 | */ |
640 | private function deleteUser() { |
641 | $dbw = MediaWikiServices::getInstance() |
642 | ->getConnectionProvider() |
643 | ->getPrimaryDatabase(); |
644 | |
645 | /** |
646 | * Format is: table => user_id column |
647 | * |
648 | * If you want it to use a different db object: |
649 | * table => array( user_id colum, 'db' => IDatabase ); |
650 | */ |
651 | $tablesToDelete = [ |
652 | 'user_groups' => 'ug_user', |
653 | 'user_properties' => 'up_user', |
654 | 'user_former_groups' => 'ufg_user', |
655 | ]; |
656 | |
657 | $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ); |
658 | $hookRunner->onUserMergeAccountDeleteTables( $tablesToDelete ); |
659 | |
660 | // Make sure these are always set, and set last |
661 | $tablesToDelete['actor'] = 'actor_user'; |
662 | $tablesToDelete['user'] = 'user_id'; |
663 | |
664 | foreach ( $tablesToDelete as $table => $field ) { |
665 | // Check if a different database object was passed (Echo or Flow) |
666 | if ( is_array( $field ) ) { |
667 | $db = $field['db'] ?? $dbw; |
668 | $field = $field[0]; |
669 | } else { |
670 | $db = $dbw; |
671 | } |
672 | $db->newDeleteQueryBuilder() |
673 | ->deleteFrom( $table ) |
674 | ->where( [ $field => $this->oldUser->getId() ] ) |
675 | ->caller( __METHOD__ ) |
676 | ->execute(); |
677 | } |
678 | |
679 | $hookRunner->onDeleteAccount( $this->oldUser ); |
680 | |
681 | DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'users' => -1 ] ) ); |
682 | } |
683 | } |