Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.93% covered (warning)
59.93%
160 / 267
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialRenameUser
60.15% covered (warning)
60.15%
160 / 266
30.00% covered (danger)
30.00%
3 / 10
254.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
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
52.50% covered (warning)
52.50%
63 / 120
0.00% covered (danger)
0.00%
0 / 1
126.45
 getWarnings
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 showForm
64.94% covered (warning)
64.94%
50 / 77
0.00% covered (danger)
0.00%
0 / 1
7.55
 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 MediaWiki\CommentStore\CommentStore;
6use MediaWiki\Html\Html;
7use MediaWiki\HTMLForm\HTMLForm;
8use MediaWiki\MainConfigNames;
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 OOUI\FieldLayout;
20use OOUI\HtmlSnippet;
21use OOUI\MessageWidget;
22use UserBlockedError;
23use Wikimedia\Rdbms\IConnectionProvider;
24
25/**
26 * Rename a user account.
27 *
28 * @ingroup SpecialPage
29 */
30class SpecialRenameUser extends SpecialPage {
31    private IConnectionProvider $dbConns;
32    private MovePageFactory $movePageFactory;
33    private PermissionManager $permissionManager;
34    private TitleFactory $titleFactory;
35    private UserFactory $userFactory;
36    private UserNamePrefixSearch $userNamePrefixSearch;
37    private UserNameUtils $userNameUtils;
38
39    /**
40     * @param IConnectionProvider $dbConns
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        MovePageFactory $movePageFactory,
51        PermissionManager $permissionManager,
52        TitleFactory $titleFactory,
53        UserFactory $userFactory,
54        UserNamePrefixSearch $userNamePrefixSearch,
55        UserNameUtils $userNameUtils
56    ) {
57        parent::__construct( 'Renameuser', 'renameuser' );
58
59        $this->dbConns = $dbConns;
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->getContentLanguage()->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->getContentLanguage()->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( MainConfigNames::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        // Give other affected extensions a chance to validate or abort
240        if ( !$this->getHookRunner()->onRenameUserAbort( $uid, $oldName, $newName ) ) {
241            return;
242        }
243
244        // Do the heavy lifting...
245        $rename = new RenameuserSQL(
246            $oldTitle->getText(),
247            $newTitle->getText(),
248            $uid,
249            $this->getUser(),
250            [ 'reason' => $reason ]
251        );
252        if ( !$rename->rename() ) {
253            return;
254        }
255
256        // If this user is renaming themself, make sure that MovePage::move()
257        // doesn't make a bunch of null move edits under the old name!
258        if ( $performer->getId() === $uid ) {
259            $performer->setName( $newTitle->getText() );
260        }
261
262        // Move any user pages
263        if ( $moveChecked && $this->permissionManager->userHasRight( $performer, 'move' ) ) {
264            $suppressRedirect = $suppressChecked
265                && $this->permissionManager->userHasRight( $performer, 'suppressredirect' );
266            $this->movePages( $oldTitle, $newTitle, $suppressRedirect );
267        }
268
269        // Output success message stuff :)
270        $out->addHTML(
271            Html::successBox(
272                $out->msg( 'renameusersuccess' )
273                    ->params( $oldTitle->getText(), $newTitle->getText() )
274                    ->parse()
275            )
276        );
277    }
278
279    private function getWarnings( $oldName, $newName ) {
280        $warnings = [];
281        $oldUser = $this->userFactory->newFromName( $oldName, $this->userFactory::RIGOR_NONE );
282        if ( $oldUser && !$oldUser->isTemp() && $oldUser->getBlock() ) {
283            $warnings[] = [
284                'renameuser-warning-currentblock',
285                SpecialPage::getTitleFor( 'Log', 'block' )->getFullURL( [ 'page' => $oldName ] )
286            ];
287        }
288        $this->getHookRunner()->onRenameUserWarning( $oldName, $newName, $warnings );
289        return $warnings;
290    }
291
292    private function showForm( $oldName, $newName, $warnings, $reason, $moveChecked, $suppressChecked ) {
293        $performer = $this->getUser();
294
295        $formDescriptor = [
296            'oldusername' => [
297                'type' => 'user',
298                'name' => 'oldusername',
299                'label-message' => 'renameuserold',
300                'default' => $oldName,
301                'required' => true,
302                'excludetemp' => true,
303            ],
304            'newusername' => [
305                'type' => 'text',
306                'name' => 'newusername',
307                'label-message' => 'renameusernew',
308                'default' => $newName,
309                'required' => true,
310            ],
311            'reason' => [
312                'type' => 'text',
313                'name' => 'reason',
314                'label-message' => 'renameuserreason',
315                'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
316                'maxlength-unit' => 'codepoints',
317                'infusable' => true,
318                'default' => $reason,
319                'required' => true,
320            ],
321        ];
322
323        if ( $this->permissionManager->userHasRight( $performer, 'move' ) ) {
324            $formDescriptor['confirm'] = [
325                'type' => 'check',
326                'id' => 'movepages',
327                'name' => 'movepages',
328                'label-message' => 'renameusermove',
329                'default' => $moveChecked,
330            ];
331        }
332        if ( $this->permissionManager->userHasRight( $performer, 'suppressredirect' ) ) {
333            $formDescriptor['suppressredirect'] = [
334                'type' => 'check',
335                'id' => 'suppressredirect',
336                'name' => 'suppressredirect',
337                'label-message' => 'renameusersuppress',
338                'default' => $suppressChecked,
339            ];
340        }
341
342        if ( $warnings ) {
343            $warningsHtml = [];
344            foreach ( $warnings as $warning ) {
345                $warningsHtml[] = is_array( $warning ) ?
346                    $this->msg( $warning[0] )->params( array_slice( $warning, 1 ) )->parse() :
347                    $this->msg( $warning )->parse();
348            }
349
350            $formDescriptor['renameuserwarnings'] = [
351                'type' => 'info',
352                'label-message' => 'renameuserwarnings',
353                'raw' => true,
354                'rawrow' => true,
355                'default' => new FieldLayout(
356                    new MessageWidget( [
357                        'label' => new HtmlSnippet(
358                            '<ul><li>'
359                            . implode( '</li><li>', $warningsHtml )
360                            . '</li></ul>'
361                        ),
362                        'type' => 'warning',
363                    ] )
364                ),
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' );