Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.28% covered (warning)
67.28%
146 / 217
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialRenameUser
67.59% covered (warning)
67.59%
146 / 216
25.00% covered (danger)
25.00%
2 / 8
122.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRestriction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
51.75% covered (warning)
51.75%
59 / 114
0.00% covered (danger)
0.00%
0 / 1
123.44
 getWarnings
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
 showForm
93.75% covered (success)
93.75%
75 / 80
0.00% covered (danger)
0.00%
0 / 1
6.01
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Specials;
4
5use MediaWiki\CommentStore\CommentStore;
6use MediaWiki\Exception\UserBlockedError;
7use MediaWiki\Html\Html;
8use MediaWiki\HTMLForm\HTMLForm;
9use MediaWiki\Language\FormatterFactory;
10use MediaWiki\MainConfigNames;
11use MediaWiki\Message\Message;
12use MediaWiki\Permissions\PermissionManager;
13use MediaWiki\RenameUser\RenameUserFactory;
14use MediaWiki\RenameUser\RenameuserSQL;
15use MediaWiki\SpecialPage\SpecialPage;
16use MediaWiki\Status\Status;
17use MediaWiki\Status\StatusFormatter;
18use MediaWiki\Title\TitleFactory;
19use MediaWiki\User\UserFactory;
20use MediaWiki\User\UserNamePrefixSearch;
21use OOUI\FieldLayout;
22use OOUI\HtmlSnippet;
23use OOUI\MessageWidget;
24use Wikimedia\Rdbms\IConnectionProvider;
25
26/**
27 * Rename a user account.
28 *
29 * @ingroup SpecialPage
30 */
31class SpecialRenameUser extends SpecialPage {
32    private readonly StatusFormatter $statusFormatter;
33
34    public function __construct(
35        private readonly IConnectionProvider $dbConns,
36        private readonly PermissionManager $permissionManager,
37        private readonly TitleFactory $titleFactory,
38        private readonly UserFactory $userFactory,
39        private readonly UserNamePrefixSearch $userNamePrefixSearch,
40        private readonly RenameUserFactory $renameUserFactory,
41        FormatterFactory $formatterFactory,
42    ) {
43        parent::__construct( 'Renameuser' );
44
45        $this->statusFormatter = $formatterFactory->getStatusFormatter( $this );
46    }
47
48    /** @inheritDoc */
49    public function getRestriction(): string {
50        return $this->userFactory->isUserTableShared() ? 'renameuser-global' : 'renameuser';
51    }
52
53    /** @inheritDoc */
54    public function doesWrites() {
55        return true;
56    }
57
58    /**
59     * Show the special page
60     *
61     * @param null|string $par Parameter passed to the page
62     */
63    public function execute( $par ) {
64        $this->setHeaders();
65        $this->addHelpLink( 'Help:Renameuser' );
66
67        $this->checkPermissions();
68        $this->checkReadOnly();
69
70        $performer = $this->getUser();
71
72        $block = $performer->getBlock();
73        if ( $block ) {
74            throw new UserBlockedError( $block );
75        }
76
77        $out = $this->getOutput();
78        $out->addWikiMsg( 'renameuser-summary' );
79
80        $this->useTransactionalTimeLimit();
81
82        $request = $this->getRequest();
83
84        // This works as "/" is not valid in usernames
85        $userNames = $par !== null ? explode( '/', $par, 2 ) : [];
86
87        // Get the old name, applying minimal validation or canonicalization
88        $oldName = $request->getText( 'oldusername', $userNames[0] ?? '' );
89        $oldName = trim( str_replace( '_', ' ', $oldName ) );
90        $oldTitle = $this->titleFactory->makeTitle( NS_USER, $oldName );
91
92        // Get the new name and canonicalize it
93        $origNewName = $request->getText( 'newusername', $userNames[1] ?? '' );
94        $origNewName = trim( str_replace( '_', ' ', $origNewName ) );
95        // Force uppercase of new username, otherwise wikis
96        // with wgCapitalLinks=false can create lc usernames
97        $newTitle = $this->titleFactory->makeTitleSafe( NS_USER, $this->getContentLanguage()->ucfirst( $origNewName ) );
98        $newName = $newTitle ? $newTitle->getText() : '';
99
100        $reason = $request->getText( 'reason' );
101        $moveChecked = $request->getBool( 'movepages', !$request->wasPosted() );
102        $suppressChecked = $request->getCheck( 'suppressredirect' );
103        $confirmAction = $request->getCheck( 'confirmaction' );
104
105        if ( $oldName !== '' && $newName !== '' && !$confirmAction ) {
106            $warnings = $this->getWarnings( $oldName, $newName );
107        } else {
108            $warnings = [];
109        }
110
111        $this->showForm( $oldName, $newName, $warnings, $reason, $moveChecked, $suppressChecked );
112
113        if ( $request->getText( 'wpEditToken' ) === '' ) {
114            # They probably haven't even submitted the form, so don't go further.
115            return;
116        }
117        if ( $warnings ) {
118            # Let user read warnings
119            return;
120        }
121        if (
122            !$request->wasPosted() ||
123            !$performer->matchEditToken( $request->getVal( 'wpEditToken' ) )
124        ) {
125            $out->addHTML( Html::errorBox( $out->msg( 'renameuser-error-request' )->parse() ) );
126
127            return;
128        }
129        if ( !$newTitle ) {
130            $out->addHTML( Html::errorBox(
131                $out->msg( 'renameusererrorinvalid', $request->getText( 'newusername' ) )->parse()
132            ) );
133
134            return;
135        }
136        if ( $oldName === $newName ) {
137            $out->addHTML( Html::errorBox( $out->msg( 'renameuser-error-same-user' )->parse() ) );
138
139            return;
140        }
141
142        // Suppress username validation of old username
143        $oldUser = $this->userFactory->newFromName( $oldName, $this->userFactory::RIGOR_NONE );
144        $newUser = $this->userFactory->newFromName( $newName, $this->userFactory::RIGOR_CREATABLE );
145
146        // It won't be an object if for instance "|" is supplied as a value
147        if ( !$oldUser ) {
148            $out->addHTML( Html::errorBox(
149                $out->msg( 'renameusererrorinvalid', $oldTitle->getText() )->parse()
150            ) );
151
152            return;
153        }
154        if ( !$newUser ) {
155            $out->addHTML( Html::errorBox(
156                $out->msg( 'renameusererrorinvalid', $newTitle->getText() )->parse()
157            ) );
158
159            return;
160        }
161
162        // Check for the existence of lowercase old username in database.
163        // Until r19631 it was possible to rename a user to a name with first character as lowercase
164        if ( $oldName !== $this->getContentLanguage()->ucfirst( $oldName ) ) {
165            // old username was entered as lowercase -> check for existence in table 'user'
166            $dbr = $this->dbConns->getReplicaDatabase();
167            $uid = $dbr->newSelectQueryBuilder()
168                ->select( 'user_id' )
169                ->from( 'user' )
170                ->where( [ 'user_name' => $oldName ] )
171                ->caller( __METHOD__ )
172                ->fetchField();
173            if ( $uid === false ) {
174                if ( !$this->getConfig()->get( MainConfigNames::CapitalLinks ) ) {
175                    $uid = 0; // We are on a lowercase wiki but lowercase username does not exist
176                } else {
177                    // We are on a standard uppercase wiki, use normal
178                    $uid = $oldUser->idForName();
179                    $oldTitle = $this->titleFactory->makeTitleSafe( NS_USER, $oldUser->getName() );
180                    if ( !$oldTitle ) {
181                        $out->addHTML( Html::errorBox(
182                            $out->msg( 'renameusererrorinvalid', $oldName )->parse()
183                        ) );
184                        return;
185                    }
186                    $oldName = $oldTitle->getText();
187                }
188            }
189        } else {
190            // old username was entered as uppercase -> standard procedure
191            $uid = $oldUser->idForName();
192        }
193
194        if ( $uid === 0 ) {
195            $out->addHTML( Html::errorBox(
196                $out->msg( 'renameusererrordoesnotexist', $oldName )->parse()
197            ) );
198
199            return;
200        }
201
202        if ( $newUser->idForName() !== 0 ) {
203            $out->addHTML( Html::errorBox(
204                $out->msg( 'renameusererrorexists', $newName )->parse()
205            ) );
206
207            return;
208        }
209
210        // Check user rights again
211        // This is needed because SpecialPage::__construct only supports
212        // checking for one right, but both renameuser and -global is required
213        // to rename a global user.
214        if ( !$this->permissionManager->userHasRight( $performer, 'renameuser' ) ) {
215            $this->displayRestrictionError();
216        }
217        if ( $this->userFactory->isUserTableShared()
218            && !$this->permissionManager->userHasRight( $performer, 'renameuser-global' ) ) {
219            $out->addHTML( Html::errorBox( $out->msg( 'renameuser-error-global-rights' )->parse() ) );
220            return;
221        }
222
223        // Give other affected extensions a chance to validate or abort
224        if ( !$this->getHookRunner()->onRenameUserAbort( $uid, $oldName, $newName ) ) {
225            return;
226        }
227
228        $forceGlobalDetach = $confirmAction && $request->getBool( 'forceglobaldetach' );
229
230        $rename = $this->renameUserFactory->newRenameUser( $performer, $oldUser, $newName, $reason, [
231            'movePages' => $moveChecked,
232            'suppressRedirect' => $suppressChecked,
233            'forceGlobalDetach' => $forceGlobalDetach,
234        ] );
235        $status = $rename->rename();
236
237        if ( $status->isGood() ) {
238            // Output success message stuff :)
239            $out->addHTML(
240                Html::successBox(
241                    $out->msg( 'renameusersuccess', $oldTitle->getText(), $newTitle->getText() )
242                        ->parse()
243                )
244            );
245        } else {
246            // Output errors stuff
247            $outHtml = '';
248            foreach ( $status->getMessages() as $msg ) {
249                $outHtml = $outHtml . $out->msg( $msg )->parse() . '<br/>';
250            }
251            if ( $status->isOK() ) {
252                $out->addHTML( Html::warningBox( $outHtml ) );
253            } else {
254                $out->addHTML( Html::errorBox( $outHtml ) );
255            }
256        }
257    }
258
259    private function getWarnings( string $oldName, string $newName ): array {
260        $warnings = [];
261        $oldUser = $this->userFactory->newFromName( $oldName, $this->userFactory::RIGOR_NONE );
262        if ( $oldUser && !$oldUser->isTemp() && $oldUser->getBlock() ) {
263            $warnings[] = [
264                'renameuser-warning-currentblock',
265                SpecialPage::getTitleFor( 'Log', 'block' )->getFullURL( [ 'page' => $oldName ] )
266            ];
267        }
268        $dbr = $this->dbConns->getReplicaDatabase();
269        $newUser = $this->userFactory->newFromName( $newName, $this->userFactory::RIGOR_CREATABLE );
270        if ( RenameuserSQL::isPreviouslyRenamedAccount( $newUser->getName(), $dbr ) ) {
271            $warnings[] = 'renameuser-warning-previously-renamed-account';
272        }
273        $this->getHookRunner()->onRenameUserWarning( $oldName, $newName, $warnings );
274        return $warnings;
275    }
276
277    private function showForm(
278        ?string $oldName, ?string $newName, array $warnings, string $reason, bool $moveChecked, bool $suppressChecked
279    ) {
280        $performer = $this->getUser();
281
282        $formDescriptor = [
283            'oldusername' => [
284                'type' => 'user',
285                'name' => 'oldusername',
286                'label-message' => 'renameuserold',
287                'default' => $oldName,
288                'required' => true,
289                'excludetemp' => true,
290            ],
291            'newusername' => [
292                'type' => 'text',
293                'name' => 'newusername',
294                'label-message' => 'renameusernew',
295                'default' => $newName,
296                'required' => true,
297            ],
298            'reason' => [
299                'type' => 'text',
300                'name' => 'reason',
301                'label-message' => 'renameuserreason',
302                'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
303                'maxlength-unit' => 'codepoints',
304                'infusable' => true,
305                'default' => $reason,
306                'required' => true,
307            ],
308        ];
309
310        if ( $this->permissionManager->userHasRight( $performer, 'move' ) ) {
311            $formDescriptor['confirm'] = [
312                'type' => 'check',
313                'id' => 'movepages',
314                'name' => 'movepages',
315                'label-message' => 'renameusermove',
316                'default' => $moveChecked,
317            ];
318        }
319        if ( $this->permissionManager->userHasRight( $performer, 'suppressredirect' ) ) {
320            $formDescriptor['suppressredirect'] = [
321                'type' => 'check',
322                'id' => 'suppressredirect',
323                'name' => 'suppressredirect',
324                'label-message' => 'renameusersuppress',
325                'default' => $suppressChecked,
326            ];
327        }
328
329        if ( $warnings ) {
330            $status = Status::newGood();
331
332            foreach ( $warnings as $warning ) {
333                $status->warning( Message::newFromSpecifier( $warning ) );
334            }
335
336            $formDescriptor['renameuserwarnings'] = [
337                'type' => 'info',
338                'label-message' => 'renameuserwarnings',
339                'raw' => true,
340                'rawrow' => true,
341                'default' => new FieldLayout(
342                    new MessageWidget( [
343                        'label' => new HtmlSnippet(
344                            $this->statusFormatter->getMessage( $status )->parse()
345                        ),
346                        'type' => 'warning',
347                    ] )
348                ),
349            ];
350
351            // Confirmation checkbox to ignore warnings.
352            // If this is checked, rename will proceed despite warnings,
353            // and the hook that allows adding warnings will not be run.
354            $formDescriptor['confirmaction'] = [
355                'type' => 'check',
356                'name' => 'confirmaction',
357                'id' => 'confirmaction',
358                'label-message' => 'renameuserconfirm',
359            ];
360
361            // T407242: Hidden field for forcing global account detach.
362            // If the confirmation field above is checked, warnings will
363            // be suppressed, however the conditions that produce
364            // 'merged user warning' are checked again in RenameUser.
365            // This field provides the value needed to both override the
366            // check in RenameUser and complement the confirmation check
367            // field to truly allow suppressing the specific warning.
368            if ( $status->hasMessage( 'centralauth-renameuser-merged' ) ) {
369                $formDescriptor['forceglobaldetach'] = [
370                    'type' => 'hidden',
371                    'name' => 'forceglobaldetach',
372                    'default' => '1',
373                ];
374            }
375
376        }
377
378        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
379            ->setMethod( 'post' )
380            ->setId( 'renameuser' )
381            ->setSubmitTextMsg( 'renameusersubmit' )
382            ->setCancelTarget( $this->getPageTitle() )->showCancel( (bool)$warnings );
383
384        $this->getOutput()->addHTML( $htmlForm->prepareForm()->getHTML( false ) );
385    }
386
387    /**
388     * Return an array of subpages beginning with $search that this special page will accept.
389     *
390     * @param string $search Prefix to search for
391     * @param int $limit Maximum number of results to return (usually 10)
392     * @param int $offset Number of results to skip (usually 0)
393     * @return string[] Matching subpages
394     */
395    public function prefixSearchSubpages( $search, $limit, $offset ) {
396        $user = $this->userFactory->newFromName( $search );
397        if ( !$user ) {
398            // No prefix suggestion for invalid user
399            return [];
400        }
401        // Autocomplete subpage as user list - public to allow caching
402        return $this->userNamePrefixSearch->search( 'public', $search, $limit, $offset );
403    }
404
405    /** @inheritDoc */
406    protected function getGroupName() {
407        return 'users';
408    }
409}
410
411/**
412 * Retain the old class name for backwards compatibility.
413 * @deprecated since 1.41
414 */
415class_alias( SpecialRenameUser::class, 'SpecialRenameuser' );