Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.89% covered (warning)
86.89%
106 / 122
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
RenameUsersMatchingPattern
86.89% covered (warning)
86.89%
106 / 122
40.00% covered (danger)
40.00%
2 / 5
25.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 initServices
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 execute
94.12% covered (success)
94.12%
48 / 51
0.00% covered (danger)
0.00%
0 / 1
9.02
 renameUser
82.61% covered (warning)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
5.13
 movePageAndSubpages
65.38% covered (warning)
65.38%
17 / 26
0.00% covered (danger)
0.00%
0 / 1
9.03
1<?php
2
3use MediaWiki\Maintenance\Maintenance;
4use MediaWiki\Page\MovePageFactory;
5use MediaWiki\RenameUser\RenameuserSQL;
6use MediaWiki\Status\Status;
7use MediaWiki\Title\TitleFactory;
8use MediaWiki\User\TempUser\Pattern;
9use MediaWiki\User\User;
10use MediaWiki\User\UserFactory;
11use Wikimedia\Rdbms\IExpression;
12
13// @codeCoverageIgnoreStart
14require_once __DIR__ . '/Maintenance.php';
15// @codeCoverageIgnoreEnd
16
17class RenameUsersMatchingPattern extends Maintenance {
18    /** @var UserFactory */
19    private $userFactory;
20
21    /** @var MovePageFactory */
22    private $movePageFactory;
23
24    /** @var TitleFactory */
25    private $titleFactory;
26
27    /** @var User */
28    private $performer;
29
30    /** @var string */
31    private $reason;
32
33    /** @var bool */
34    private $dryRun;
35
36    /** @var bool */
37    private $suppressRedirect;
38
39    /** @var bool */
40    private $skipPageMoves;
41
42    public function __construct() {
43        parent::__construct();
44
45        $this->addDescription( 'Rename users with a name matching a pattern. ' .
46            'This can be used to migrate to a temporary user (IP masking) configuration.' );
47        $this->addOption( 'from', 'A username pattern where $1 is ' .
48            'the wildcard standing in for any number of characters. All users ' .
49            'matching this pattern will be renamed.', true, true );
50        $this->addOption( 'to', 'A username pattern where $1 is ' .
51            'the part of the username matched by $1 in --from. Users will be ' .
52            ' renamed to this pattern.', true, true );
53        $this->addOption( 'performer', 'Performer of the rename action', false, true );
54        $this->addOption( 'reason', 'Reason of the rename', false, true );
55        $this->addOption( 'suppress-redirect', 'Don\'t create redirects when moving pages' );
56        $this->addOption( 'skip-page-moves', 'Don\'t move associated user pages' );
57        $this->addOption( 'dry-run', 'Don\'t actually rename the ' .
58            'users, just report what it would do.' );
59        $this->setBatchSize( 1000 );
60    }
61
62    private function initServices() {
63        $services = $this->getServiceContainer();
64        if ( $services->getCentralIdLookupFactory()->getNonLocalLookup() ) {
65            $this->fatalError( "This script cannot be run when CentralAuth is enabled." );
66        }
67        $this->userFactory = $services->getUserFactory();
68        $this->movePageFactory = $services->getMovePageFactory();
69        $this->titleFactory = $services->getTitleFactory();
70    }
71
72    public function execute() {
73        $this->initServices();
74
75        $fromPattern = new Pattern( 'from', $this->getOption( 'from' ) );
76        $toPattern = new Pattern( 'to', $this->getOption( 'to' ) );
77
78        if ( $this->getOption( 'performer' ) === null ) {
79            $performer = User::newSystemUser( User::MAINTENANCE_SCRIPT_USER, [ 'steal' => true ] );
80        } else {
81            $performer = $this->userFactory->newFromName( $this->getOption( 'performer' ) );
82        }
83        if ( !$performer ) {
84            $this->fatalError( "Unable to get performer account" );
85        }
86        $this->performer = $performer;
87
88        $this->reason = $this->getOption( 'reason', '' );
89        $this->dryRun = $this->getOption( 'dry-run' );
90        $this->suppressRedirect = $this->getOption( 'suppress-redirect' );
91        $this->skipPageMoves = $this->getOption( 'skip-page-moves' );
92
93        $dbr = $this->getReplicaDB();
94        $batchConds = [];
95        $batchSize = $this->getBatchSize();
96        $numRenamed = 0;
97        do {
98            $res = $dbr->newSelectQueryBuilder()
99                ->select( [ 'user_name' ] )
100                ->from( 'user' )
101                ->where( $dbr->expr( 'user_name', IExpression::LIKE, $fromPattern->toLikeValue( $dbr ) ) )
102                ->andWhere( $batchConds )
103                ->orderBy( 'user_name' )
104                ->limit( $batchSize )
105                ->caller( __METHOD__ )
106                ->fetchResultSet();
107
108            foreach ( $res as $row ) {
109                $oldName = $row->user_name;
110                $batchConds = [ $dbr->expr( 'user_name', '>', $oldName ) ];
111                $variablePart = $fromPattern->extract( $oldName );
112                if ( $variablePart === null ) {
113                    $this->output( "Username \"fromName\" matched the LIKE " .
114                        "but does not seem to match the pattern" );
115                    continue;
116                }
117                $newName = $toPattern->generate( $variablePart );
118
119                // Canonicalize
120                $newTitle = $this->titleFactory->makeTitleSafe( NS_USER, $newName );
121                $newUser = $this->userFactory->newFromName( $newName );
122                if ( !$newTitle || !$newUser ) {
123                    $this->output( "Cannot rename \"$oldName\" " .
124                        "because \"$newName\" is not a valid title\n" );
125                    continue;
126                }
127                $newName = $newTitle->getText();
128
129                // Check destination existence
130                if ( $newUser->isRegistered() ) {
131                    $this->output( "Cannot rename \"$oldName\" " .
132                        "because \"$newName\" already exists\n" );
133                    continue;
134                }
135
136                $numRenamed += $this->renameUser( $oldName, $newName ) ? 1 : 0;
137                $this->waitForReplication();
138            }
139        } while ( $res->numRows() === $batchSize );
140        $this->output( "Renamed $numRenamed user(s)\n" );
141        return true;
142    }
143
144    /**
145     * @param string $oldName
146     * @param string $newName
147     * @return bool True if the user was renamed
148     */
149    private function renameUser( $oldName, $newName ) {
150        $id = $this->userFactory->newFromName( $oldName )->getId();
151        if ( !$id ) {
152            $this->output( "Cannot rename non-existent user \"$oldName\"" );
153        }
154
155        if ( $this->dryRun ) {
156            $this->output( "$oldName would be renamed to $newName\n" );
157        } else {
158            $renamer = new RenameuserSQL(
159                $oldName,
160                $newName,
161                $id,
162                $this->performer,
163                [
164                    'reason' => $this->reason
165                ]
166            );
167
168            if ( !$renamer->rename() ) {
169                $this->output( "Unable to rename $oldName" );
170                return false;
171            } else {
172                $this->output( "$oldName was successfully renamed to $newName.\n" );
173            }
174        }
175
176        if ( $this->skipPageMoves ) {
177            return true;
178        }
179
180        $this->movePageAndSubpages( NS_USER, 'User', $oldName, $newName );
181        $this->movePageAndSubpages( NS_USER_TALK, 'User talk', $oldName, $newName );
182        return true;
183    }
184
185    private function movePageAndSubpages( $ns, $nsName, $oldName, $newName ) {
186        $oldTitle = $this->titleFactory->makeTitleSafe( $ns, $oldName );
187        if ( !$oldTitle ) {
188            $this->output( "[[$nsName:$oldName]] is an invalid title, can't move it.\n" );
189            return true;
190        }
191        $newTitle = $this->titleFactory->makeTitleSafe( $ns, $newName );
192        if ( !$newTitle ) {
193            $this->output( "[[$nsName:$newName]] is an invalid title, can't move to it.\n" );
194            return true;
195        }
196
197        $movePage = $this->movePageFactory->newMovePage( $oldTitle, $newTitle );
198        $movePage->setMaximumMovedPages( -1 );
199
200        $logMessage = wfMessage(
201            'renameuser-move-log', $oldName, $newName
202        )->inContentLanguage()->text();
203
204        if ( $this->dryRun ) {
205            if ( $oldTitle->exists() ) {
206                $this->output( "Would move [[$nsName:$oldName]] to [[$nsName:$newName]].\n" );
207            }
208        } else {
209            if ( $oldTitle->exists() ) {
210                $status = $movePage->move(
211                    $this->performer, $logMessage, !$this->suppressRedirect );
212            } else {
213                $status = Status::newGood();
214            }
215            $status->merge( $movePage->moveSubpages(
216                $this->performer, $logMessage, !$this->suppressRedirect ) );
217            if ( !$status->isGood() ) {
218                $this->output( "Failed to rename user page\n" );
219                $this->error( $status );
220            }
221        }
222        return true;
223    }
224}
225
226// @codeCoverageIgnoreStart
227$maintClass = RenameUsersMatchingPattern::class;
228require_once RUN_MAINTENANCE_IF_MAIN;
229// @codeCoverageIgnoreEnd