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