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