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