Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 113
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
RenameUsersMatchingPattern
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 5
306
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 initServices
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
42
 renameUser
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
42
 waitForJobs
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\Maintenance;
4
5use IDBAccessObject;
6use Maintenance;
7use MediaWiki\Extension\CentralAuth\CentralAuthDatabaseManager;
8use MediaWiki\Extension\CentralAuth\CentralAuthServices;
9use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameFactory;
10use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameUserValidator;
11use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
12use MediaWiki\MediaWikiServices;
13use MediaWiki\User\TempUser\Pattern;
14use MediaWiki\User\User;
15use MediaWiki\User\UserFactory;
16use MediaWiki\User\UserRigorOptions;
17use Wikimedia\Rdbms\IExpression;
18
19$IP = getenv( 'MW_INSTALL_PATH' );
20if ( $IP === false ) {
21    $IP = __DIR__ . '/../../..';
22}
23require_once "$IP/maintenance/Maintenance.php";
24
25class RenameUsersMatchingPattern extends Maintenance {
26    /** @var CentralAuthDatabaseManager */
27    private $dbManager;
28
29    /** @var UserFactory */
30    private $userFactory;
31
32    private GlobalRenameFactory $globalRenameFactory;
33
34    /** @var GlobalRenameUserValidator */
35    private $validator;
36
37    /** @var User */
38    private $performer;
39
40    /** @var string */
41    private $reason;
42
43    /** @var bool */
44    private $dryRun;
45
46    /** @var bool */
47    private $suppressRedirect;
48
49    /** @var bool */
50    private $skipPageMoves;
51
52    public function __construct() {
53        parent::__construct();
54        $this->addDescription( 'Rename global users with a name matching a pattern. ' .
55            'This can be used to migrate to a temporary user (IP masking) configuration.' );
56        $this->addOption( 'from', 'A username pattern where $1 is ' .
57            'the wildcard standing in for any number of characters. All users ' .
58            'matching this pattern will be renamed.', true, true );
59        $this->addOption( 'to', 'A username pattern where $1 is ' .
60            'the part of the username matched by $1 in --from. Users will be ' .
61            ' renamed to this pattern.', true, true );
62        $this->addOption( 'performer', 'Performer of the rename action', false, true );
63        $this->addOption( 'reason', 'Reason of the rename', false, true );
64        $this->addOption( 'suppress-redirect', 'Don\'t create redirects when moving pages' );
65        $this->addOption( 'skip-page-moves', 'Don\'t move associated user pages' );
66        $this->addOption( 'dry-run', 'Don\'t actually rename the ' .
67            'users, just report what it would do.' );
68        $this->setBatchSize( 1000 );
69    }
70
71    private function initServices() {
72        $services = MediaWikiServices::getInstance();
73        $this->dbManager = CentralAuthServices::getDatabaseManager();
74        $this->userFactory = $services->getUserFactory();
75        $this->globalRenameFactory = $services->get( 'CentralAuth.GlobalRenameFactory' );
76        $this->validator = $services->get( 'CentralAuth.GlobalRenameUserValidator' );
77    }
78
79    public function execute() {
80        $this->initServices();
81
82        $fromPattern = new Pattern( 'from', $this->getOption( 'from' ) );
83        $toPattern = new Pattern( 'to', $this->getOption( 'to' ) );
84
85        $batchSize = $this->getBatchSize();
86
87        if ( $this->getOption( 'performer' ) === null ) {
88            $performer = User::newSystemUser( User::MAINTENANCE_SCRIPT_USER, [ 'steal' => true ] );
89        } else {
90            $performer = $this->userFactory->newFromName( $this->getOption( 'performer' ) );
91        }
92        if ( !$performer ) {
93            $this->error( "Unable to get performer account" );
94            return false;
95        }
96        $this->performer = $performer;
97
98        $this->reason = $this->getOption( 'reason', '' );
99
100        $this->suppressRedirect = $this->getOption( 'suppress-redirect' );
101        $this->skipPageMoves = $this->getOption( 'skip-page-moves' );
102        $this->dryRun = $this->getOption( 'dry-run' );
103
104        $dbr = $this->dbManager->getCentralReplicaDB();
105        $batchConds = [];
106        $numRenamed = 0;
107
108        do {
109            $res = $dbr->newSelectQueryBuilder()
110                ->select( 'gu_name' )
111                ->from( 'globaluser' )
112                ->where( $dbr->expr( 'gu_name', IExpression::LIKE, $fromPattern->toLikeValue( $dbr ) ) )
113                ->andWhere( $batchConds )
114                ->orderBy( 'gu_name' )
115                ->limit( $batchSize )
116                ->caller( __METHOD__ )
117                ->fetchResultSet();
118            foreach ( $res as $row ) {
119                $oldName = $row->gu_name;
120                $batchConds = [ $dbr->expr( 'gu_name', '>', $oldName ) ];
121                $variablePart = $fromPattern->extract( $oldName );
122                if ( $variablePart === null ) {
123                    $this->output( "Username \"fromName\" matched the LIKE " .
124                        "but does not seem to match the pattern" );
125                    continue;
126                }
127                $newName = $toPattern->generate( $variablePart );
128                $numRenamed += $this->renameUser( $oldName, $newName ) ? 1 : 0;
129                $this->waitForJobs();
130                $this->waitForReplication();
131            }
132        } while ( $res->numRows() === $batchSize );
133        $this->output( "Renamed $numRenamed user(s)\n" );
134        return true;
135    }
136
137    /**
138     * @param string $oldName
139     * @param string $newName
140     * @return bool True if the user was renamed
141     */
142    private function renameUser( $oldName, $newName ) {
143        $oldUser = $this->userFactory->newFromName( $oldName, UserRigorOptions::RIGOR_NONE );
144        $newUser = $this->userFactory->newFromName( $newName, UserRigorOptions::RIGOR_CREATABLE );
145        if ( !$oldUser ) {
146            $this->output( "Unable to rename \"$oldName\": invalid username\n" );
147            return false;
148        }
149        if ( !$newUser ) {
150            $this->output( "Unable to rename \"$oldName\" to \"$newName\": invalid target username\n" );
151            return false;
152        }
153
154        $status = $this->validator->validate( $oldUser, $newUser );
155        if ( !$status->isOK() ) {
156            $this->output( "Unable to rename \"$oldName\" to \"$newName\": " .
157                $status->getWikiText() . "\n" );
158            return false;
159        }
160
161        $oldCaUser = new CentralAuthUser( $oldName, IDBAccessObject::READ_LATEST );
162
163        $data = [
164            'movepages' => !$this->skipPageMoves,
165            'suppressredirects' => $this->suppressRedirect,
166            'reason' => $this->reason,
167            'force' => true,
168        ];
169
170        $globalRenameUser = $this->globalRenameFactory->newGlobalRenameUser(
171            $this->performer,
172            $oldCaUser,
173            $newName
174        );
175
176        if ( $this->dryRun ) {
177            $this->output( "Would rename \"$oldName\" to \"$newName\"\n" );
178            return true;
179        } else {
180            $status = $globalRenameUser->rename( $data );
181            if ( $status->isGood() ) {
182                $this->output( "Successfully queued rename of \"$oldName\" to \"$newName\"\n" );
183                return true;
184            } else {
185                $this->output( "Error renaming \"$oldName\" to \"$newName\": " .
186                    $status->getWikiText( false, false, 'en' ) . "\n" );
187                return false;
188            }
189        }
190    }
191
192    /**
193     * Wait until fewer than 15 rename jobs are pending
194     */
195    private function waitForJobs() {
196        while ( true ) {
197            $count = $this->dbManager->getCentralPrimaryDB()->newSelectQueryBuilder()
198                ->from( 'renameuser_status' )
199                ->limit( 15 )
200                ->caller( __METHOD__ )
201                ->fetchRowCount();
202            if ( $count < 15 ) {
203                break;
204            }
205            sleep( 5 );
206        }
207    }
208}
209
210$maintClass = RenameUsersMatchingPattern::class;
211require_once RUN_MAINTENANCE_IF_MAIN;