Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.22% covered (warning)
61.22%
161 / 263
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialRenameUser
61.45% covered (warning)
61.45%
161 / 262
30.00% covered (danger)
30.00%
3 / 10
243.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
51.20% covered (warning)
51.20%
64 / 125
0.00% covered (danger)
0.00%
0 / 1
142.68
 getWarnings
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 showForm
73.13% covered (warning)
73.13%
49 / 67
0.00% covered (danger)
0.00%
0 / 1
6.70
 movePages
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 movePageAndSubpages
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 getMoveStatusHtml
45.45% covered (danger)
45.45%
10 / 22
0.00% covered (danger)
0.00%
0 / 1
6.60
 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 Language;
6use MediaWiki\CommentStore\CommentStore;
7use MediaWiki\Html\Html;
8use MediaWiki\HTMLForm\HTMLForm;
9use MediaWiki\MainConfigNames;
10use MediaWiki\Page\MovePageFactory;
11use MediaWiki\Permissions\PermissionManager;
12use MediaWiki\RenameUser\RenameuserSQL;
13use MediaWiki\SpecialPage\SpecialPage;
14use MediaWiki\Status\Status;
15use MediaWiki\Title\Title;
16use MediaWiki\Title\TitleFactory;
17use MediaWiki\User\UserFactory;
18use MediaWiki\User\UserNamePrefixSearch;
19use MediaWiki\User\UserNameUtils;
20use UserBlockedError;
21use Wikimedia\Rdbms\IConnectionProvider;
22
23/**
24 * Rename a user account.
25 *
26 * @ingroup SpecialPage
27 */
28class SpecialRenameUser extends SpecialPage {
29    private IConnectionProvider $dbConns;
30    private Language $contentLanguage;
31    private MovePageFactory $movePageFactory;
32    private PermissionManager $permissionManager;
33    private TitleFactory $titleFactory;
34    private UserFactory $userFactory;
35    private UserNamePrefixSearch $userNamePrefixSearch;
36    private UserNameUtils $userNameUtils;
37
38    /**
39     * @param IConnectionProvider $dbConns
40     * @param Language $contentLanguage
41     * @param MovePageFactory $movePageFactory
42     * @param PermissionManager $permissionManager
43     * @param TitleFactory $titleFactory
44     * @param UserFactory $userFactory
45     * @param UserNamePrefixSearch $userNamePrefixSearch
46     * @param UserNameUtils $userNameUtils
47     */
48    public function __construct(
49        IConnectionProvider $dbConns,
50        Language $contentLanguage,
51        MovePageFactory $movePageFactory,
52        PermissionManager $permissionManager,
53        TitleFactory $titleFactory,
54        UserFactory $userFactory,
55        UserNamePrefixSearch $userNamePrefixSearch,
56        UserNameUtils $userNameUtils
57    ) {
58        parent::__construct( 'Renameuser', 'renameuser' );
59
60        $this->dbConns = $dbConns;
61        $this->contentLanguage = $contentLanguage;
62        $this->movePageFactory = $movePageFactory;
63        $this->permissionManager = $permissionManager;
64        $this->titleFactory = $titleFactory;
65        $this->userFactory = $userFactory;
66        $this->userNamePrefixSearch = $userNamePrefixSearch;
67        $this->userNameUtils = $userNameUtils;
68    }
69
70    public function doesWrites() {
71        return true;
72    }
73
74    /**
75     * Show the special page
76     *
77     * @param null|string $par Parameter passed to the page
78     */
79    public function execute( $par ) {
80        $this->setHeaders();
81        $this->addHelpLink( 'Help:Renameuser' );
82
83        $this->checkPermissions();
84        $this->checkReadOnly();
85
86        $performer = $this->getUser();
87
88        $block = $performer->getBlock();
89        if ( $block ) {
90            throw new UserBlockedError( $block );
91        }
92
93        $out = $this->getOutput();
94        $out->addWikiMsg( 'renameuser-summary' );
95
96        $this->useTransactionalTimeLimit();
97
98        $request = $this->getRequest();
99
100        // This works as "/" is not valid in usernames
101        $userNames = $par !== null ? explode( '/', $par, 2 ) : [];
102
103        // Get the old name, applying minimal validation or canonicalization
104        $oldName = $request->getText( 'oldusername', $userNames[0] ?? '' );
105        $oldName = trim( str_replace( '_', ' ', $oldName ) );
106        $oldTitle = $this->titleFactory->makeTitle( NS_USER, $oldName );
107
108        // Get the new name and canonicalize it
109        $origNewName = $request->getText( 'newusername', $userNames[1] ?? '' );
110        $origNewName = trim( str_replace( '_', ' ', $origNewName ) );
111        // Force uppercase of new username, otherwise wikis
112        // with wgCapitalLinks=false can create lc usernames
113        $newTitle = $this->titleFactory->makeTitleSafe( NS_USER, $this->contentLanguage->ucfirst( $origNewName ) );
114        $newName = $newTitle ? $newTitle->getText() : '';
115
116        $reason = $request->getText( 'reason' );
117        $moveChecked = $request->getBool( 'movepages', !$request->wasPosted() );
118        $suppressChecked = $request->getCheck( 'suppressredirect' );
119
120        if ( $oldName !== '' && $newName !== '' && !$request->getCheck( 'confirmaction' ) ) {
121            $warnings = $this->getWarnings( $oldName, $newName );
122        } else {
123            $warnings = [];
124        }
125
126        $this->showForm( $oldName, $newName, $warnings, $reason, $moveChecked, $suppressChecked );
127
128        if ( $request->getText( 'wpEditToken' ) === '' ) {
129            # They probably haven't even submitted the form, so don't go further.
130            return;
131        }
132        if ( $warnings ) {
133            # Let user read warnings
134            return;
135        }
136        if (
137            !$request->wasPosted() ||
138            !$performer->matchEditToken( $request->getVal( 'wpEditToken' ) )
139        ) {
140            $out->addHTML( Html::errorBox( $out->msg( 'renameuser-error-request' )->parse() ) );
141
142            return;
143        }
144        if ( !$newTitle ) {
145            $out->addHTML( Html::errorBox(
146                $out->msg( 'renameusererrorinvalid' )->params( $request->getText( 'newusername' ) )->parse()
147            ) );
148
149            return;
150        }
151        if ( $oldName === $newName ) {
152            $out->addHTML( Html::errorBox( $out->msg( 'renameuser-error-same-user' )->parse() ) );
153
154            return;
155        }
156
157        // Do not act on temp users
158        if ( $this->userNameUtils->isTemp( $oldName ) ) {
159            $out->addHTML( Html::errorBox(
160                $out->msg( 'renameuser-error-temp-user' )->plaintextParams( $oldName )->parse()
161            ) );
162            return;
163        }
164        if ( $this->userNameUtils->isTemp( $newName ) ||
165            $this->userNameUtils->isTempReserved( $newName )
166        ) {
167            $out->addHTML( Html::errorBox(
168                $out->msg( 'renameuser-error-temp-user-reserved' )->plaintextParams( $newName )->parse()
169            ) );
170            return;
171        }
172
173        // Suppress username validation of old username
174        $oldUser = $this->userFactory->newFromName( $oldName, $this->userFactory::RIGOR_NONE );
175        $newUser = $this->userFactory->newFromName( $newName, $this->userFactory::RIGOR_CREATABLE );
176
177        // It won't be an object if for instance "|" is supplied as a value
178        if ( !$oldUser ) {
179            $out->addHTML( Html::errorBox(
180                $out->msg( 'renameusererrorinvalid' )->params( $oldTitle->getText() )->parse()
181            ) );
182
183            return;
184        }
185        if ( !$newUser ) {
186            $out->addHTML( Html::errorBox(
187                $out->msg( 'renameusererrorinvalid' )->params( $newTitle->getText() )->parse()
188            ) );
189
190            return;
191        }
192
193        // Check for the existence of lowercase old username in database.
194        // Until r19631 it was possible to rename a user to a name with first character as lowercase
195        if ( $oldName !== $this->contentLanguage->ucfirst( $oldName ) ) {
196            // old username was entered as lowercase -> check for existence in table 'user'
197            $dbr = $this->dbConns->getReplicaDatabase();
198            $uid = $dbr->newSelectQueryBuilder()
199                ->select( 'user_id' )
200                ->from( 'user' )
201                ->where( [ 'user_name' => $oldName ] )
202                ->caller( __METHOD__ )
203                ->fetchField();
204            if ( $uid === false ) {
205                if ( !$this->getConfig()->get( MainConfigNames::CapitalLinks ) ) {
206                    $uid = 0; // We are on a lowercase wiki but lowercase username does not exist
207                } else {
208                    // We are on a standard uppercase wiki, use normal
209                    $uid = $oldUser->idForName();
210                    $oldTitle = $this->titleFactory->makeTitleSafe( NS_USER, $oldUser->getName() );
211                    if ( !$oldTitle ) {
212                        $out->addHTML( Html::errorBox(
213                            $out->msg( 'renameusererrorinvalid' )->params( $oldName )->parse()
214                        ) );
215                        return;
216                    }
217                    $oldName = $oldTitle->getText();
218                }
219            }
220        } else {
221            // old username was entered as uppercase -> standard procedure
222            $uid = $oldUser->idForName();
223        }
224
225        if ( $uid === 0 ) {
226            $out->addHTML( Html::errorBox(
227                $out->msg( 'renameusererrordoesnotexist' )->params( $oldName )->parse()
228            ) );
229
230            return;
231        }
232
233        if ( $newUser->idForName() !== 0 ) {
234            $out->addHTML( Html::errorBox(
235                $out->msg( 'renameusererrorexists' )->params( $newName )->parse()
236            ) );
237
238            return;
239        }
240
241        if ( $oldUser->equals( $performer ) ) {
242            $out->addHTML( Html::errorBox(
243                $out->msg( 'renameuser-error-self-rename' )->parse()
244            ) );
245
246            return;
247        }
248
249        // Give other affected extensions a chance to validate or abort
250        if ( !$this->getHookRunner()->onRenameUserAbort( $uid, $oldName, $newName ) ) {
251            return;
252        }
253
254        // Do the heavy lifting...
255        $rename = new RenameuserSQL(
256            $oldTitle->getText(),
257            $newTitle->getText(),
258            $uid,
259            $this->getUser(),
260            [ 'reason' => $reason ]
261        );
262        if ( !$rename->rename() ) {
263            return;
264        }
265
266        // If this user is renaming themself, make sure that MovePage::move()
267        // doesn't make a bunch of null move edits under the old name!
268        if ( $performer->getId() === $uid ) {
269            $performer->setName( $newTitle->getText() );
270        }
271
272        // Move any user pages
273        if ( $moveChecked && $this->permissionManager->userHasRight( $performer, 'move' ) ) {
274            $suppressRedirect = $suppressChecked
275                && $this->permissionManager->userHasRight( $performer, 'suppressredirect' );
276            $this->movePages( $oldTitle, $newTitle, $suppressRedirect );
277        }
278
279        // Output success message stuff :)
280        $out->addHTML(
281            Html::successBox(
282                $out->msg( 'renameusersuccess' )
283                    ->params( $oldTitle->getText(), $newTitle->getText() )
284                    ->parse()
285            )
286        );
287    }
288
289    private function getWarnings( $oldName, $newName ) {
290        $warnings = [];
291        $oldUser = $this->userFactory->newFromName( $oldName, $this->userFactory::RIGOR_NONE );
292        if ( $oldUser && !$oldUser->isTemp() && $oldUser->getBlock() ) {
293            $warnings[] = [
294                'renameuser-warning-currentblock',
295                SpecialPage::getTitleFor( 'Log', 'block' )->getFullURL( [ 'page' => $oldName ] )
296            ];
297        }
298        $this->getHookRunner()->onRenameUserWarning( $oldName, $newName, $warnings );
299        return $warnings;
300    }
301
302    private function showForm( $oldName, $newName, $warnings, $reason, $moveChecked, $suppressChecked ) {
303        $performer = $this->getUser();
304
305        $formDescriptor = [
306            'oldusername' => [
307                'type' => 'user',
308                'name' => 'oldusername',
309                'label-message' => 'renameuserold',
310                'default' => $oldName,
311                'required' => true,
312            ],
313            'newusername' => [
314                'type' => 'text',
315                'name' => 'newusername',
316                'label-message' => 'renameusernew',
317                'default' => $newName,
318                'required' => true,
319            ],
320            'reason' => [
321                'type' => 'text',
322                'name' => 'reason',
323                'label-message' => 'renameuserreason',
324                'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
325                'maxlength-unit' => 'codepoints',
326                'infusable' => true,
327                'default' => $reason,
328                'required' => true,
329            ],
330        ];
331
332        if ( $this->permissionManager->userHasRight( $performer, 'move' ) ) {
333            $formDescriptor['confirm'] = [
334                'type' => 'check',
335                'id' => 'movepages',
336                'name' => 'movepages',
337                'label-message' => 'renameusermove',
338                'default' => $moveChecked,
339            ];
340        }
341        if ( $this->permissionManager->userHasRight( $performer, 'suppressredirect' ) ) {
342            $formDescriptor['suppressredirect'] = [
343                'type' => 'check',
344                'id' => 'suppressredirect',
345                'name' => 'suppressredirect',
346                'label-message' => 'renameusersuppress',
347                'default' => $suppressChecked,
348            ];
349        }
350
351        if ( $warnings ) {
352            $warningsHtml = [];
353            foreach ( $warnings as $warning ) {
354                $warningsHtml[] = is_array( $warning ) ?
355                    $this->msg( $warning[0] )->params( array_slice( $warning, 1 ) )->parse() :
356                    $this->msg( $warning )->parse();
357            }
358
359            $formDescriptor['renameuserwarnings'] = [
360                'type' => 'info',
361                'label-message' => 'renameuserwarnings',
362                'raw' => true,
363                'default' => Html::warningBox( '<ul><li>' .
364                    implode( '</li><li>', $warningsHtml ) . '</li></ul>' ),
365            ];
366
367            $formDescriptor['confirmaction'] = [
368                'type' => 'check',
369                'name' => 'confirmaction',
370                'id' => 'confirmaction',
371                'label-message' => 'renameuserconfirm',
372            ];
373        }
374
375        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
376            ->setMethod( 'post' )
377            ->setId( 'renameuser' )
378            ->setSubmitTextMsg( 'renameusersubmit' );
379
380        $this->getOutput()->addHTML( $htmlForm->prepareForm()->getHTML( false ) );
381    }
382
383    /**
384     * Move the specified user page, its associated talk page, and any subpages
385     *
386     * @param Title $oldTitle
387     * @param Title $newTitle
388     * @param bool $suppressRedirect
389     * @return void
390     */
391    private function movePages( Title $oldTitle, Title $newTitle, $suppressRedirect ) {
392        $output = $this->movePageAndSubpages( $oldTitle, $newTitle, $suppressRedirect );
393        $oldTalkTitle = $oldTitle->getTalkPageIfDefined();
394        $newTalkTitle = $newTitle->getTalkPageIfDefined();
395        if ( $oldTalkTitle && $newTalkTitle ) { // always true
396            $output .= $this->movePageAndSubpages( $oldTalkTitle, $newTalkTitle, $suppressRedirect );
397        }
398
399        if ( $output !== '' ) {
400            $this->getOutput()->addHTML( Html::rawElement( 'ul', [], $output ) );
401        }
402    }
403
404    /**
405     * Move a specified page and its subpages
406     *
407     * @param Title $oldTitle
408     * @param Title $newTitle
409     * @param bool $suppressRedirect
410     * @return string
411     */
412    private function movePageAndSubpages( Title $oldTitle, Title $newTitle, $suppressRedirect ) {
413        $performer = $this->getUser();
414        $logReason = $this->msg(
415            'renameuser-move-log', $oldTitle->getText(), $newTitle->getText()
416        )->inContentLanguage()->text();
417        $movePage = $this->movePageFactory->newMovePage( $oldTitle, $newTitle );
418
419        $output = '';
420        if ( $oldTitle->exists() ) {
421            $status = $movePage->moveIfAllowed( $performer, $logReason, !$suppressRedirect );
422            $output .= $this->getMoveStatusHtml( $status, $oldTitle, $newTitle );
423        }
424
425        $oldLength = strlen( $oldTitle->getText() );
426        $batchStatus = $movePage->moveSubpagesIfAllowed( $performer, $logReason, !$suppressRedirect );
427        foreach ( $batchStatus->getValue() as $titleText => $status ) {
428            $oldSubpageTitle = Title::newFromText( $titleText );
429            $newSubpageTitle = $newTitle->getSubpage(
430                substr( $oldSubpageTitle->getText(), $oldLength + 1 ) );
431            $output .= $this->getMoveStatusHtml( $status, $oldSubpageTitle, $newSubpageTitle );
432        }
433        return $output;
434    }
435
436    private function getMoveStatusHtml( Status $status, Title $oldTitle, Title $newTitle ) {
437        $linkRenderer = $this->getLinkRenderer();
438        if ( $status->hasMessage( 'articleexists' ) || $status->hasMessage( 'redirectexists' ) ) {
439            $link = $linkRenderer->makeKnownLink( $newTitle );
440            return Html::rawElement(
441                'li',
442                [ 'class' => 'mw-renameuser-pe' ],
443                $this->msg( 'renameuser-page-exists' )->rawParams( $link )->escaped()
444            );
445        } else {
446            if ( $status->isOK() ) {
447                // oldPage is not known in case of redirect suppression
448                $oldLink = $linkRenderer->makeLink( $oldTitle, null, [], [ 'redirect' => 'no' ] );
449
450                // newPage is always known because the move was successful
451                $newLink = $linkRenderer->makeKnownLink( $newTitle );
452
453                return Html::rawElement(
454                    'li',
455                    [ 'class' => 'mw-renameuser-pm' ],
456                    $this->msg( 'renameuser-page-moved' )->rawParams( $oldLink, $newLink )->escaped()
457                );
458            } else {
459                $oldLink = $linkRenderer->makeKnownLink( $oldTitle );
460                $newLink = $linkRenderer->makeLink( $newTitle );
461                return Html::rawElement(
462                    'li', [ 'class' => 'mw-renameuser-pu' ],
463                    $this->msg( 'renameuser-page-unmoved' )->rawParams( $oldLink, $newLink )->escaped()
464                );
465            }
466        }
467    }
468
469    /**
470     * Return an array of subpages beginning with $search that this special page will accept.
471     *
472     * @param string $search Prefix to search for
473     * @param int $limit Maximum number of results to return (usually 10)
474     * @param int $offset Number of results to skip (usually 0)
475     * @return string[] Matching subpages
476     */
477    public function prefixSearchSubpages( $search, $limit, $offset ) {
478        $user = $this->userFactory->newFromName( $search );
479        if ( !$user ) {
480            // No prefix suggestion for invalid user
481            return [];
482        }
483        // Autocomplete subpage as user list - public to allow caching
484        return $this->userNamePrefixSearch->search( 'public', $search, $limit, $offset );
485    }
486
487    protected function getGroupName() {
488        return 'users';
489    }
490}
491
492/**
493 * Retain the old class name for backwards compatibility.
494 * @deprecated since 1.41
495 */
496class_alias( SpecialRenameUser::class, 'SpecialRenameuser' );