Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.84% covered (warning)
60.84%
87 / 143
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
RenameUser
60.84% covered (warning)
60.84%
87 / 143
0.00% covered (danger)
0.00%
0 / 9
214.39
0.00% covered (danger)
0.00%
0 / 1
 __construct
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 check
66.67% covered (warning)
66.67%
18 / 27
0.00% covered (danger)
0.00%
0 / 1
27.70
 renameLocal
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
4.06
 moveUserPages
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
8.06
 movePagesAndSubPages
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
4.15
 renameGlobal
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 enqueueRemoteRename
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 renameUnsafe
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 rename
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace MediaWiki\RenameUser;
4
5use LogicException;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\Context\RequestContext;
8use MediaWiki\JobQueue\JobQueueGroupFactory;
9use MediaWiki\JobQueue\JobSpecification;
10use MediaWiki\Logger\LoggerFactory;
11use MediaWiki\MainConfigNames;
12use MediaWiki\Page\MovePageFactory;
13use MediaWiki\Permissions\PermissionManager;
14use MediaWiki\Status\Status;
15use MediaWiki\Title\Title;
16use MediaWiki\Title\TitleFactory;
17use MediaWiki\User\CentralId\CentralIdLookupFactory;
18use MediaWiki\User\User;
19use MediaWiki\User\UserFactory;
20use MediaWiki\User\UserNameUtils;
21use MediaWiki\WikiMap\WikiMap;
22use Psr\Log\LoggerInterface;
23use Wikimedia\Rdbms\IConnectionProvider;
24
25/**
26 * Handles the backend logic of renaming users.
27 *
28 * @since 1.44
29 */
30class RenameUser {
31
32    /** @var User */
33    private $performer;
34    /** @var User */
35    private $target;
36    /** @var string */
37    private $oldName;
38    /** @var string */
39    private $newName;
40    /** @var string */
41    private $reason;
42
43    /** @var bool */
44    private $forceGlobalDetach = false;
45    /** @var bool */
46    private $movePages = true;
47    /** @var bool */
48    private $suppressRedirect = false;
49    /** @var bool */
50    private $derived = false;
51
52    private readonly LoggerInterface $logger;
53
54    /**
55     * @internal For use by RenameUserFactory
56     */
57    public const CONSTRUCTOR_OPTIONS = [
58        MainConfigNames::LocalDatabases,
59    ];
60
61    /**
62     * Valid options for $renameOptions:
63     *   - forceGlobalDetach      : Force to detach from CentralAuth
64     *   - movePages              : Whether user pages should be moved
65     *   - suppressRedirect       : Whether to suppress redirects for user pages
66     *   - derived                : Whether shared tables should be updated
67     *       If derived is true, it is assumed that all shared tables have been updated.
68     */
69    public function __construct(
70        private readonly ServiceOptions $options,
71        private readonly CentralIdLookupFactory $centralIdLookupFactory,
72        private readonly IConnectionProvider $dbProvider,
73        private readonly JobQueueGroupFactory $jobQueueGroupFactory,
74        private readonly MovePageFactory $movePageFactory,
75        private readonly UserFactory $userFactory,
76        private readonly UserNameUtils $userNameUtils,
77        private readonly PermissionManager $permissionManager,
78        private readonly TitleFactory $titleFactory,
79        User $performer,
80        User $target,
81        string $oldName,
82        string $newName,
83        string $reason,
84        array $renameOptions
85    ) {
86        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
87        $this->logger = LoggerFactory::getInstance( 'RenameUser' );
88
89        foreach ( [
90            'forceGlobalDetach',
91            'movePages',
92            'suppressRedirect',
93            'derived',
94        ] as $possibleOption ) {
95            if ( isset( $renameOptions[ $possibleOption ] ) ) {
96                $this->$possibleOption = $renameOptions[ $possibleOption ];
97            }
98        }
99
100        $this->performer = $performer;
101        $this->target = $target;
102        $this->oldName = $oldName;
103        $this->newName = $newName;
104        $this->reason = $reason;
105    }
106
107    /**
108     * Checks if the rename operation is valid.
109     * @note This method doesn't check user permissions. Use 'rename' for that.
110     * @return Status Validation result.
111     *   If status is Ok with no errors, the rename can be performed.
112     *   If status is Ok with some errors,
113     */
114    private function check(): Status {
115        // Check if the user has a proper name
116        // The wiki triggering a global rename across a wiki family using virtual domains
117        // may not have the same user database as this wiki
118        $expectedName = $this->oldName;
119        if ( $this->derived && $this->userFactory->isUserTableShared() ) {
120            $expectedName = $this->newName;
121        }
122        if ( $this->target->getName() !== $expectedName ) {
123            return Status::newFatal( 'renameuser-error-unexpected-name' );
124        }
125
126        // Check user names valid
127        $newRigor = $this->derived ? UserFactory::RIGOR_NONE : UserFactory::RIGOR_CREATABLE;
128        $oldUser = $this->userFactory->newFromName( $this->oldName, UserFactory::RIGOR_NONE );
129        $newUser = $this->userFactory->newFromName( $this->newName, $newRigor );
130        if ( !$oldUser ) {
131            return Status::newFatal( 'renameusererrorinvalid', $this->oldName );
132        }
133        if ( !$newUser ) {
134            return Status::newFatal( 'renameusererrorinvalid', $this->newName );
135        }
136        $currentUser = $this->derived ? $newUser : $oldUser;
137        if ( !$currentUser->isRegistered() ) {
138            return Status::newFatal( 'renameusererrordoesnotexist', $currentUser->getName() );
139        }
140        if ( !$this->derived && $newUser->isRegistered() ) {
141            return Status::newFatal( 'renameusererrorexists', $this->newName );
142        }
143
144        // Do not act on temp users
145        if ( $this->userNameUtils->isTemp( $this->oldName ) ) {
146            return Status::newFatal( 'renameuser-error-temp-user', $this->oldName );
147        }
148        if (
149            $this->userNameUtils->isTemp( $this->newName ) ||
150            $this->userNameUtils->isTempReserved( $this->newName )
151        ) {
152            return Status::newFatal( 'renameuser-error-temp-user-reserved', $this->newName );
153        }
154
155        // Check global detaching
156        $centralIdLookup = $this->centralIdLookupFactory->getNonLocalLookup();
157        $userCentralAttached = $centralIdLookup && $centralIdLookup->isAttached( $this->target );
158        if ( !$this->forceGlobalDetach && $userCentralAttached ) {
159            return Status::newFatal( 'renameuser-error-global-detaching' );
160        }
161
162        return Status::newGood();
163    }
164
165    /**
166     * Performs the rename in local domain.
167     * @return Status
168     */
169    public function renameLocal(): Status {
170        $status = $this->check();
171        if ( !$status->isOK() ) {
172            return $status;
173        }
174
175        $user = $this->target;
176        $performer = $this->performer;
177        $oldName = $this->oldName;
178        $newName = $this->newName;
179
180        $options = [
181            'reason' => $this->reason,
182            'derived' => $this->derived,
183        ];
184
185        // Do the heavy lifting ...
186        $rename = new RenameuserSQL(
187            $oldName,
188            $newName,
189            $user->getId(),
190            $performer,
191            $options
192        );
193        $status->merge( $rename->renameUser() );
194        if ( !$status->isOK() ) {
195            return $status;
196        }
197
198        // If the user is renaming themself, make sure that code below uses a proper name
199        if ( $performer->getId() === $user->getId() ) {
200            $performer->setName( $newName );
201            $this->performer->setName( $newName );
202        }
203
204        // Move any user pages
205        $status->merge( $this->moveUserPages() );
206
207        return $status;
208    }
209
210    /**
211     * Attempts to move local user pages.
212     * @return Status
213     */
214    public function moveUserPages(): Status {
215        if ( $this->movePages && $this->permissionManager->userHasRight( $this->performer, 'move' ) ) {
216            $suppressRedirect = $this->suppressRedirect
217                && $this->permissionManager->userHasRight( $this->performer, 'suppressredirect' );
218            $oldTitle = $this->titleFactory->makeTitle( NS_USER, $this->oldName );
219            $newTitle = $this->titleFactory->makeTitle( NS_USER, $this->newName );
220
221            $status = $this->movePagesAndSubPages( $this->performer, $oldTitle, $newTitle, $suppressRedirect );
222            if ( !$status->isOK() ) {
223                return $status;
224            }
225
226            $oldTalkTitle = $oldTitle->getTalkPageIfDefined();
227            $newTalkTitle = $newTitle->getTalkPageIfDefined();
228            if ( $oldTalkTitle && $newTalkTitle ) {
229                $status = $this->movePagesAndSubPages(
230                    $this->performer,
231                    $oldTalkTitle,
232                    $newTalkTitle,
233                    $suppressRedirect
234                );
235                if ( !$status->isOK() ) {
236                    return $status;
237                }
238            }
239        }
240        return Status::newGood();
241    }
242
243    private function movePagesAndSubPages(
244        User $performer, Title $oldTitle, Title $newTitle, bool $suppressRedirect
245    ): Status {
246        $status = Status::newGood();
247
248        $movePage = $this->movePageFactory->newMovePage(
249            $oldTitle,
250            $newTitle,
251        );
252        $movePage->setMaximumMovedPages( -1 );
253        $logMessage = RequestContext::getMain()->msg(
254            'renameuser-move-log',
255            $oldTitle->getText(),
256            $newTitle->getText()
257        )->inContentLanguage()->text();
258
259        if ( $oldTitle->exists() ) {
260            $status->merge( $movePage->moveIfAllowed( $performer, $logMessage, !$suppressRedirect ) );
261            if ( !$status->isGood() ) {
262                return $status;
263            }
264        }
265
266        $batchStatus = $movePage->moveSubpagesIfAllowed( $performer, $logMessage, !$suppressRedirect );
267        foreach ( $batchStatus->getValue() as $titleText => $moveStatus ) {
268            $status->merge( $moveStatus );
269        }
270        return $status;
271    }
272
273    /**
274     * Attempts to perform the rename globally.
275     * @note This method doesn't check user permissions. Use 'rename' for that.
276     *
277     * This will first call renameLocal to complete local renaming,
278     * then enqueue RenameUserDerivedJob for all other wikis in the same
279     * wiki family.
280     *
281     * @return Status
282     */
283    public function renameGlobal(): Status {
284        if ( $this->derived ) {
285            throw new LogicException( "Can't rename globally with a command created with newDerivedRenameUser()" );
286        }
287        $status = $this->renameLocal();
288        if ( !$status->isGood() ) {
289            return $status;
290        }
291
292        // Create jobs for other wikis if needed
293        if ( $this->userFactory->isUserTableShared() ) {
294            foreach ( $this->options->get( MainConfigNames::LocalDatabases ) as $database ) {
295                if ( $database == WikiMap::getCurrentWikiDbDomain()->getId() ) {
296                    continue;
297                }
298                $status->merge( $this->enqueueRemoteRename( $database ) );
299            }
300        }
301
302        return $status;
303    }
304
305    /**
306     * Enqueues a job to perform local rename on another wiki.
307     *
308     * Checks will not be performed during enqueuing operation.
309     *
310     * @param string $database
311     * @return Status
312     */
313    private function enqueueRemoteRename( string $database ): Status {
314        $jobParams = [
315            'oldname' => $this->oldName,
316            'newname' => $this->newName,
317            'uid' => $this->target->getId(),
318            'performer' => $this->performer->getId(),
319            'reason' => $this->reason,
320            'movePages' => $this->movePages,
321            'suppressRedirect' => $this->suppressRedirect,
322        ];
323        $oldTitle = $this->titleFactory->makeTitle( NS_USER, $this->oldName );
324        $this->logger->info( "Enqueuing a rename job for domain {$database}" );
325        $job = new JobSpecification( 'renameUserDerived', $jobParams, [], $oldTitle );
326        $this->jobQueueGroupFactory->makeJobQueueGroup( $database )->push( $job );
327        return Status::newGood();
328    }
329
330    /**
331     * Attempts to perform the rename smartly.
332     * @note This method doesn't check user permissions. Use 'rename' for that.
333     *
334     * This decides whether renameGlobal or renameLocal should be used and call the proper
335     * function.
336     *
337     * @return Status
338     */
339    public function renameUnsafe(): Status {
340        if ( !$this->derived && $this->userFactory->isUserTableShared() ) {
341            return $this->renameGlobal();
342        } else {
343            return $this->renameLocal();
344        }
345    }
346
347    /**
348     * Attempts to perform the rename smartly after checking the performer's rights.
349     *
350     * This decides whether renameGlobal or renameLocal should be used and call the proper
351     * function.
352     *
353     * @return Status
354     */
355    public function rename(): Status {
356        // renameuser is always required
357        if ( !$this->permissionManager->userHasRight( $this->performer, 'renameuser' ) ) {
358            return Status::newFatal( 'renameuser-error-local-rights' );
359        }
360
361        // for global renames, renameuser-global is also required
362        $centralIdLookup = $this->centralIdLookupFactory->getNonLocalLookup();
363        $userCentralAttached = $centralIdLookup && $centralIdLookup->isAttached( $this->target );
364        if ( ( $this->userFactory->isUserTableShared() || $userCentralAttached )
365            && !$this->permissionManager->userHasRight( $this->performer, 'renameuser-global' ) ) {
366            return Status::newFatal( 'renameuser-error-global-rights' );
367        }
368
369        return $this->renameUnsafe();
370    }
371}