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