Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
LocalRenameJob
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 7
506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
42
 doRun
n/a
0 / 0
n/a
0 / 0
0
 setRenameUserStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRenameUser
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 done
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 updateStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 scheduleNextWiki
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\GlobalRename\LocalRenameJob;
4
5use Exception;
6use Job;
7use MediaWiki\Context\RequestContext;
8use MediaWiki\Extension\CentralAuth\CentralAuthServices;
9use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameUserStatus;
10use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
11use MediaWiki\Logger\LoggerFactory;
12use MediaWiki\MediaWikiServices;
13use MediaWiki\Title\Title;
14use MediaWiki\User\User;
15use MediaWiki\WikiMap\WikiMap;
16use Wikimedia\Rdbms\IDBAccessObject;
17use Wikimedia\ScopedCallback;
18
19/**
20 * Base class for jobs that change a user's
21 * name. Intended to be run on local wikis
22 * indvidually.
23 *
24 * Parameters:
25 * - from: current username
26 * - to: new username to rename to
27 * - renamer: username of the performer
28 * - ignorestatus: when true, the rename will be done even if another job is supposed to be
29 *   already doing it. This should only be used for stuck renames.
30 * - session: array of session data from RequestContext::exportSession()
31 */
32abstract class LocalRenameJob extends Job {
33
34    /**
35     * @var GlobalRenameUserStatus
36     */
37    private $renameuserStatus;
38
39    /**
40     * @param Title $title
41     * @param array $params
42     */
43    public function __construct( Title $title, $params ) {
44        parent::__construct( $this->command, $title, $params );
45    }
46
47    /**
48     * @throws Exception
49     */
50    public function run(): bool {
51        $this->setRenameUserStatus(
52            CentralAuthServices::getGlobalRenameFactory()
53                ->newGlobalRenameUserStatus( $this->params['to'] )
54        );
55
56        // Bail if it's already done or in progress. Use a locking read to block until the
57        // transaction adding this job is done, so we can see its changes. This is similar to
58        // the trick that the RenameUser extension does.
59        $status = $this->renameuserStatus->getStatus( IDBAccessObject::READ_LOCKING );
60        // Clear any REPEATABLE-READ snapshot in case the READ_LOCKING blocked above. We want
61        // regular non-locking SELECTs to see all the changes from that transaction we waited on.
62        // Making a new transaction also reduces deadlocks from the locking read.
63        // T145596
64        $fnameTrxOwner = get_class( $this ) . '::' . __FUNCTION__;
65        $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
66        $factory->commitPrimaryChanges( $fnameTrxOwner );
67
68        if ( empty( $this->params['ignorestatus'] ) ) {
69            if ( $status !== 'queued' && $status !== 'failed' ) {
70                $logger = LoggerFactory::getInstance( 'CentralAuth' );
71                $logger->info( 'Skipping duplicate rename from {oldName} to {newName}', [
72                    'oldName' => $this->params['from'],
73                    'newName' => $this->params['to'],
74                    'component' => 'GlobalRename',
75                    'status' => $status,
76                ] );
77                return true;
78            }
79        }
80
81        if ( isset( $this->params['session'] ) ) {
82            // Don't carry over users or sessions because it's going to be wrong
83            // across wikis
84            $this->params['session']['userId'] = 0;
85            $this->params['session']['sessionId'] = '';
86            $callback = RequestContext::importScopedSession( $this->params['session'] );
87            $this->addTeardownCallback( static function () use ( &$callback ) {
88                ScopedCallback::consume( $callback );
89            } );
90        }
91        try {
92            $this->doRun( $fnameTrxOwner );
93            $this->addTeardownCallback( [ $this, 'scheduleNextWiki' ] );
94        } catch ( Exception $e ) {
95            // This will lock the user out of their account until a sysadmin intervenes
96            $factory->rollbackPrimaryChanges( $fnameTrxOwner );
97            $this->updateStatus( 'failed' );
98            $factory->commitPrimaryChanges( $fnameTrxOwner );
99            // Record job failure in CentralAuth channel (T217211)
100            $logger = LoggerFactory::getInstance( 'CentralAuth' );
101            $logger->error( 'Failed to rename {oldName} to {newName} ({error})', [
102                'oldName' => $this->params['from'],
103                'newName' => $this->params['to'],
104                'component' => 'GlobalRename',
105                'error' => $e->getMessage()
106            ] );
107            throw $e;
108        }
109
110        return true;
111    }
112
113    /**
114     * Actually do the work for the job class
115     * @param string $fnameTrxOwner Caller name
116     */
117    abstract protected function doRun( $fnameTrxOwner );
118
119    /**
120     * @param GlobalRenameUserStatus $status
121     */
122    protected function setRenameUserStatus( GlobalRenameUserStatus $status ) {
123        $this->renameuserStatus = $status;
124    }
125
126    /**
127     * Get the user object for the user who is doing the renaming
128     * "Auto-create" if it doesn't exist yet.
129     * @return User
130     */
131    protected function getRenameUser() {
132        $user = User::newFromName( $this->params['renamer'] );
133        $userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();
134        // If the username is a reserved name, don't worry about the account
135        // existing, just use it.
136        if ( !$userNameUtils->isUsable( $user->getName() ) ) {
137            return $user;
138        }
139
140        $caUser = CentralAuthUser::getPrimaryInstance( $user );
141        // Race condition where the renamer isn't attached here, but
142        // someone creates an account in the meantime and then bad
143        // stuff could happen...
144        // For the meantime, just use a system account
145        if ( !$caUser->attachedOn( WikiMap::getCurrentWikiId() ) && $user->getId() !== 0 ) {
146            return User::newSystemUser( 'Global rename script', [ 'steal' => true ] );
147        }
148
149        if ( $user->getId() == 0 ) {
150            // No local user, lets "auto-create" one
151            if ( CentralAuthServices::getUtilityService()->autoCreateUser( $user )->isGood() ) {
152                // So the internal cache is reloaded
153                return User::newFromName( $user->getName() );
154            }
155
156            // Auto-creation didn't work, fallback on the system account.
157            return User::newSystemUser( 'Global rename script', [ 'steal' => true ] );
158        }
159
160        // Account is attached and exists, just use it :)
161        return $user;
162    }
163
164    protected function done() {
165        $this->renameuserStatus->done( WikiMap::getCurrentWikiId() );
166
167        $caNew = CentralAuthUser::getInstanceByName( $this->params['to'] );
168        $caNew->quickInvalidateCache();
169    }
170
171    /**
172     * @param string $status
173     */
174    protected function updateStatus( $status ) {
175        $this->renameuserStatus->updateStatus( WikiMap::getCurrentWikiId(), $status );
176    }
177
178    /**
179     * @param bool $status See Job::addTeardownCallback
180     */
181    protected function scheduleNextWiki( $status ) {
182        if ( $status === false ) {
183            // This will lock the user out of their account until a sysadmin intervenes.
184            $this->updateStatus( 'failed' );
185            // Bail out just in case the error would affect all renames and continuing would
186            // just put all wikis of the user in failure state. Running the rename for this
187            // wiki again (e.g. with fixStuckGlobalRename.php) will resume the job chain.
188            return;
189        }
190
191        $job = new static( $this->getTitle(), $this->getParams() );
192        $nextWiki = null;
193        $statuses = $this->renameuserStatus->getStatuses( IDBAccessObject::READ_LATEST );
194        foreach ( $statuses as $wiki => $status ) {
195            if ( $status === 'queued' && $wiki !== WikiMap::getCurrentWikiId() ) {
196                $nextWiki = $wiki;
197                break;
198            }
199        }
200        if ( $nextWiki ) {
201            MediaWikiServices::getInstance()->getJobQueueGroupFactory()->makeJobQueueGroup( $nextWiki )->push( $job );
202        }
203    }
204}