Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.68% covered (warning)
88.68%
235 / 265
68.00% covered (warning)
68.00%
17 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
ActorStore
88.68% covered (warning)
88.68%
235 / 265
68.00% covered (warning)
68.00%
17 / 25
89.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 newActorFromRow
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 newActorFromRowFields
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 deleteUserIdentityFromCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActorById
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getUserIdentityByName
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getUserIdentityByUserId
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 attachActorId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 detachActorId
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 findActorId
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 findActorIdByName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 findActorIdInternal
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 acquireActorId
70.83% covered (warning)
70.83%
17 / 24
0.00% covered (danger)
0.00%
0 / 1
4.40
 createNewActor
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 acquireSystemActorId
81.25% covered (warning)
81.25%
26 / 32
0.00% covered (danger)
0.00%
0 / 1
7.32
 deleteActor
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
3.07
 normalizeUserName
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 validateActorForInsertion
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 setUpRollbackHandler
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 checkDatabaseDomain
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getUnknownActor
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 newSelectQueryBuilder
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 setAllowCreateIpActors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deprecateInvalidCrossWikiParam
12.50% covered (danger)
12.50%
1 / 8
0.00% covered (danger)
0.00%
0 / 1
4.68
 wikiIdToString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use InvalidArgumentException;
10use MediaWiki\Block\HideUserUtils;
11use MediaWiki\DAO\WikiAwareEntity;
12use MediaWiki\Exception\CannotCreateActorException;
13use MediaWiki\User\TempUser\TempUserConfig;
14use Psr\Log\LoggerInterface;
15use stdClass;
16use Wikimedia\Assert\Assert;
17use Wikimedia\IPUtils;
18use Wikimedia\Rdbms\DBQueryError;
19use Wikimedia\Rdbms\IDatabase;
20use Wikimedia\Rdbms\IDBAccessObject;
21use Wikimedia\Rdbms\ILoadBalancer;
22use Wikimedia\Rdbms\IReadableDatabase;
23
24/**
25 * Service to read or write data in the actor table.
26 *
27 * @since 1.36
28 * @ingroup User
29 */
30class ActorStore implements UserIdentityLookup, ActorNormalization {
31
32    public const UNKNOWN_USER_NAME = 'Unknown user';
33
34    private const LOCAL_CACHE_SIZE = 100;
35
36    private ActorCache $cache;
37
38    private bool $allowCreateIpActors;
39
40    /**
41     * @param ILoadBalancer $loadBalancer
42     * @param UserNameUtils $userNameUtils
43     * @param TempUserConfig $tempUserConfig
44     * @param LoggerInterface $logger
45     * @param HideUserUtils $hideUserUtils
46     * @param string|false $wikiId
47     */
48    public function __construct(
49        private readonly ILoadBalancer $loadBalancer,
50        private readonly UserNameUtils $userNameUtils,
51        private readonly TempUserConfig $tempUserConfig,
52        private readonly LoggerInterface $logger,
53        private readonly HideUserUtils $hideUserUtils,
54        private $wikiId = WikiAwareEntity::LOCAL
55    ) {
56        Assert::parameterType( [ 'string', 'false' ], $wikiId, '$wikiId' );
57
58        $this->wikiId = $wikiId;
59
60        $this->cache = new ActorCache( self::LOCAL_CACHE_SIZE );
61
62        $this->allowCreateIpActors = !$this->tempUserConfig->isEnabled();
63    }
64
65    /**
66     * Instantiate a new UserIdentity object based on a $row from the actor table.
67     *
68     * Use this method when an actor row was already fetched from the DB via a join.
69     * This method just constructs a new instance and does not try fetching missing
70     * values from the DB again, use {@link UserIdentityLookup} for that.
71     *
72     * @param stdClass $row with the following fields:
73     *  - int actor_id
74     *  - string actor_name
75     *  - int|null actor_user
76     * @return UserIdentity
77     * @throws InvalidArgumentException
78     */
79    public function newActorFromRow( stdClass $row ): UserIdentity {
80        $actorId = (int)$row->actor_id;
81        $userId = isset( $row->actor_user ) ? (int)$row->actor_user : 0;
82        if ( $actorId === 0 ) {
83            throw new InvalidArgumentException( "Actor ID is 0 for {$row->actor_name} and {$userId}" );
84        }
85
86        $normalizedName = $this->normalizeUserName( $row->actor_name );
87        if ( $normalizedName === null ) {
88            $this->logger->warning( 'Encountered invalid actor name in database', [
89                'user_id' => $userId,
90                'actor_id' => $actorId,
91                'actor_name' => $row->actor_name,
92                'wiki_id' => $this->wikiId ?: 'local'
93            ] );
94            // TODO: once we have guaranteed db only contains valid actor names,
95            // we can skip normalization here - T273933
96            if ( $row->actor_name === '' ) {
97                throw new InvalidArgumentException( "Actor name can not be empty for {$userId} and {$actorId}" );
98            }
99        }
100
101        $actor = new UserIdentityValue( $userId, $row->actor_name, $this->wikiId );
102        $this->cache->add( $actorId, $actor );
103        return $actor;
104    }
105
106    /**
107     * Instantiate a new UserIdentity object based on field values from a DB row.
108     *
109     * Until {@link ActorMigration} is completed, the actor table joins alias actor field names
110     * to legacy field names. This method is convenience to construct the UserIdentity based on
111     * legacy field names. It's more relaxed with typing then ::newFromRow to better support legacy
112     * code, so always prefer ::newFromRow in new code. Eventually, once {@link ActorMigration}
113     * is completed and all queries use explicit join with actor table, this method will be
114     * deprecated and removed.
115     *
116     * @throws InvalidArgumentException
117     * @param int|null $userId
118     * @param string|null $name
119     * @param int|null $actorId
120     * @return UserIdentity
121     */
122    public function newActorFromRowFields( $userId, $name, $actorId ): UserIdentity {
123        // For backwards compatibility we are quite relaxed about what to accept,
124        // but try not to create entirely incorrect objects. As we move more code
125        // from ActorMigration aliases to proper join with the actor table,
126        // we should use ::newActorFromRow more, and eventually deprecate this method.
127        $userId = $userId === null ? 0 : (int)$userId;
128        $name ??= '';
129        if ( $actorId === null ) {
130            throw new InvalidArgumentException( "Actor ID is null for {$name} and {$userId}" );
131        }
132        if ( (int)$actorId === 0 ) {
133            throw new InvalidArgumentException( "Actor ID is 0 for {$name} and {$userId}" );
134        }
135
136        $normalizedName = $this->normalizeUserName( $name );
137        if ( $normalizedName === null ) {
138            $this->logger->warning( 'Encountered invalid actor name in database', [
139                'user_id' => $userId,
140                'actor_id' => $actorId,
141                'actor_name' => $name,
142                'wiki_id' => $this->wikiId ?: 'local'
143            ] );
144            // TODO: once we have guaranteed the DB entries only exist for normalized names,
145            // we can skip normalization here - T273933
146            if ( $name === '' ) {
147                throw new InvalidArgumentException( "Actor name can not be empty for {$userId} and {$actorId}" );
148            }
149        }
150
151        $actorId = (int)$actorId;
152        $actor = new UserIdentityValue(
153            $userId,
154            $name,
155            $this->wikiId
156        );
157
158        $this->cache->add( $actorId, $actor );
159        return $actor;
160    }
161
162    /**
163     * @internal for use in User object only
164     */
165    public function deleteUserIdentityFromCache( UserIdentity $actor ) {
166        $this->cache->remove( $actor );
167    }
168
169    /**
170     * Find an actor by $id.
171     *
172     * @param int $actorId
173     * @param IReadableDatabase $db The database connection to operate on.
174     *        The database must correspond to ActorStore's wiki ID.
175     * @return UserIdentity|null Returns null if no actor with this $actorId exists in the database.
176     */
177    public function getActorById( int $actorId, IReadableDatabase $db ): ?UserIdentity {
178        $this->checkDatabaseDomain( $db );
179
180        if ( !$actorId ) {
181            return null;
182        }
183
184        return $this->cache->getActor( ActorCache::KEY_ACTOR_ID, $actorId ) ??
185            $this->newSelectQueryBuilder( $db )
186                ->caller( __METHOD__ )
187                ->conds( [ 'actor_id' => $actorId ] )
188                ->fetchUserIdentity() ??
189            // The actor ID mostly comes from DB, so if we can't find an actor by ID,
190            // it's most likely due to lagged DB replica and not because it doesn't exist.
191            // Probably we just inserted it? Try the primary database.
192            $this->newSelectQueryBuilder( IDBAccessObject::READ_LATEST )
193                ->caller( __METHOD__ )
194                ->conds( [ 'actor_id' => $actorId ] )
195                ->fetchUserIdentity();
196    }
197
198    /** @inheritDoc */
199    public function getUserIdentityByName(
200        string $name,
201        int $queryFlags = IDBAccessObject::READ_NORMAL
202    ): ?UserIdentity {
203        $normalizedName = $this->normalizeUserName( $name );
204        if ( $normalizedName === null ) {
205            return null;
206        }
207
208        return $this->cache->getActor( ActorCache::KEY_USER_NAME, $normalizedName ) ??
209            $this->newSelectQueryBuilder( $queryFlags )
210                ->caller( __METHOD__ )
211                ->whereUserNames( $normalizedName )
212                ->fetchUserIdentity();
213    }
214
215    /** @inheritDoc */
216    public function getUserIdentityByUserId(
217        int $userId,
218        int $queryFlags = IDBAccessObject::READ_NORMAL
219    ): ?UserIdentity {
220        if ( !$userId ) {
221            return null;
222        }
223
224        return $this->cache->getActor( ActorCache::KEY_USER_ID, $userId ) ??
225            $this->newSelectQueryBuilder( $queryFlags )
226                ->caller( __METHOD__ )
227                ->whereUserIds( $userId )
228                ->fetchUserIdentity();
229    }
230
231    /**
232     * Attach the actor ID to $user for backwards compatibility.
233     *
234     * @todo remove this method when no longer needed (T273974).
235     *
236     * @param UserIdentity $user
237     * @param int $id
238     * @param bool $assigned whether a new actor ID was just assigned.
239     */
240    private function attachActorId( UserIdentity $user, int $id, bool $assigned ) {
241        if ( $user instanceof User ) {
242            $user->setActorId( $id );
243            if ( $assigned ) {
244                $user->invalidateCache();
245            }
246        }
247    }
248
249    /**
250     * Detach the actor ID from $user for backwards compatibility.
251     *
252     * @todo remove this method when no longer needed (T273974).
253     */
254    private function detachActorId( UserIdentity $user ) {
255        if ( $user instanceof User ) {
256            $user->setActorId( 0 );
257        }
258    }
259
260    /**
261     * Find the actor_id of the given $user.
262     *
263     * @param UserIdentity $user
264     * @param IReadableDatabase $db The database connection to operate on.
265     *        The database must correspond to ActorStore's wiki ID.
266     * @return int|null
267     */
268    public function findActorId( UserIdentity $user, IReadableDatabase $db ): ?int {
269        // TODO: we want to assert this user belongs to the correct wiki,
270        // but User objects are always local and we used to use them
271        // on a non-local DB connection. We need to first deprecate this
272        // possibility and then throw on mismatching User object - T273972
273        // $user->assertWiki( $this->wikiId );
274        $this->deprecateInvalidCrossWikiParam( $user );
275
276        // TODO: In the future we would be able to assume UserIdentity name is ok
277        // and will be able to skip normalization here - T273933
278        $name = $this->normalizeUserName( $user->getName() );
279        if ( $name === null ) {
280            $this->logger->warning( 'Encountered a UserIdentity with invalid name', [
281                'user_name' => $user->getName()
282            ] );
283            return null;
284        }
285
286        $id = $this->findActorIdInternal( $name, $db );
287
288        // Set the actor ID in the User object. To be removed, see T274148.
289        if ( $id && $user instanceof User ) {
290            $user->setActorId( $id );
291        }
292
293        return $id;
294    }
295
296    /**
297     * Find the actor_id of the given $name.
298     *
299     * @param string $name
300     * @param IReadableDatabase $db The database connection to operate on.
301     *        The database must correspond to ActorStore's wiki ID.
302     * @return int|null
303     */
304    public function findActorIdByName( $name, IReadableDatabase $db ): ?int {
305        $name = $this->normalizeUserName( $name );
306        if ( $name === null ) {
307            return null;
308        }
309
310        return $this->findActorIdInternal( $name, $db );
311    }
312
313    /**
314     * Find actor_id of the given $user using the passed $db connection.
315     *
316     * @param string $name
317     * @param IReadableDatabase $db The database connection to operate on.
318     *        The database must correspond to ActorStore's wiki ID.
319     * @param bool $lockInShareMode
320     * @return int|null
321     */
322    private function findActorIdInternal(
323        string $name,
324        IReadableDatabase $db,
325        bool $lockInShareMode = false
326    ): ?int {
327        // Note: UserIdentity::getActorId will be deprecated and removed,
328        // and this is the replacement for it. Can't call User::getActorId, cause
329        // User always thinks it's local, so we could end up fetching the ID
330        // from the wrong database.
331
332        $cachedValue = $this->cache->getActorId( ActorCache::KEY_USER_NAME, $name );
333        if ( $cachedValue ) {
334            return $cachedValue;
335        }
336
337        $queryBuilder = $db->newSelectQueryBuilder()
338            ->select( [ 'actor_user', 'actor_name', 'actor_id' ] )
339            ->from( 'actor' )
340            ->where( [ 'actor_name' => $name ] );
341        if ( $lockInShareMode ) {
342            $queryBuilder->lockInShareMode();
343        }
344
345        $row = $queryBuilder->caller( __METHOD__ )->fetchRow();
346
347        if ( !$row || !$row->actor_id ) {
348            return null;
349        }
350        // to cache row
351        $this->newActorFromRow( $row );
352
353        return (int)$row->actor_id;
354    }
355
356    /**
357     * Attempt to assign an actor ID to the given $user. If it is already assigned,
358     * return the existing ID.
359     *
360     * @note If called within a transaction, the returned ID might become invalid
361     * if the transaction is rolled back, so it should not be passed outside the
362     * transaction context.
363     *
364     * @param UserIdentity $user
365     * @param IDatabase $dbw The database connection to acquire the ID from.
366     *        The database must correspond to ActorStore's wiki ID.
367     * @return int actor ID greater than 0
368     * @throws CannotCreateActorException if no actor ID has been assigned to this $user
369     */
370    public function acquireActorId( UserIdentity $user, IDatabase $dbw ): int {
371        $this->checkDatabaseDomain( $dbw );
372        [ $userId, $userName ] = $this->validateActorForInsertion( $user );
373
374        // allow cache to be used, because if it is in the cache, it already has an actor ID
375        $existingActorId = $this->findActorIdInternal( $userName, $dbw );
376        if ( $existingActorId ) {
377            $this->attachActorId( $user, $existingActorId, false );
378            return $existingActorId;
379        }
380
381        $dbw->newInsertQueryBuilder()
382            ->insertInto( 'actor' )
383            ->ignore()
384            ->row( [ 'actor_user' => $userId, 'actor_name' => $userName ] )
385            ->caller( __METHOD__ )->execute();
386
387        if ( $dbw->affectedRows() ) {
388            $actorId = $dbw->insertId();
389        } else {
390            // Outdated cache?
391            // Use LOCK IN SHARE MODE to bypass any MySQL REPEATABLE-READ snapshot.
392            $actorId = $this->findActorIdInternal( $userName, $dbw, true );
393            if ( !$actorId ) {
394                throw new CannotCreateActorException(
395                    'Failed to create actor ID for ' .
396                        'user_id={userId} user_name="{userName}"',
397                    [ 'userId' => $userId, 'userName' => $userName ]
398                );
399            }
400        }
401
402        $this->attachActorId( $user, $actorId, true );
403        // Cache row we've just created
404        $cachedUserIdentity = $this->newActorFromRowFields( $userId, $userName, $actorId );
405        $this->setUpRollbackHandler( $dbw, $cachedUserIdentity, $user );
406        return $actorId;
407    }
408
409    /**
410     * Create a new actor for the given $user. If an actor with this name already exists,
411     * this method throws.
412     *
413     * @note If called within a transaction, the returned ID might become invalid
414     * if the transaction is rolled back, so it should not be passed outside the
415     * transaction context.
416     *
417     * @param UserIdentity $user
418     * @param IDatabase $dbw
419     * @return int actor ID greater than 0
420     * @throws CannotCreateActorException if an actor with this name already exists.
421     * @internal for use in user account creation only.
422     */
423    public function createNewActor( UserIdentity $user, IDatabase $dbw ): int {
424        $this->checkDatabaseDomain( $dbw );
425        [ $userId, $userName ] = $this->validateActorForInsertion( $user );
426
427        try {
428            $dbw->newInsertQueryBuilder()
429                ->insertInto( 'actor' )
430                ->row( [ 'actor_user' => $userId, 'actor_name' => $userName ] )
431                ->caller( __METHOD__ )->execute();
432        } catch ( DBQueryError $e ) {
433            // We rely on the database to crash on unique actor_name constraint.
434            throw new CannotCreateActorException( $e->getMessage() );
435        }
436        $actorId = $dbw->insertId();
437
438        $this->attachActorId( $user, $actorId, true );
439        // Cache row we've just created
440        $cachedUserIdentity = $this->newActorFromRowFields( $userId, $userName, $actorId );
441        $this->setUpRollbackHandler( $dbw, $cachedUserIdentity, $user );
442
443        return $actorId;
444    }
445
446    /**
447     * Attempt to assign an ID to an actor for a system user. If an actor ID already
448     * exists, return it.
449     *
450     * @note For reserved usernames this method will overwrite the user ID of the
451     * existing anon actor.
452     *
453     * @note If called within a transaction, the returned ID might become invalid
454     * if the transaction is rolled back, so it should not be passed outside the
455     * transaction context.
456     *
457     * @param UserIdentity $user
458     * @param IDatabase $dbw
459     * @return int actor ID greater than zero
460     * @throws CannotCreateActorException if the existing actor is associated with the registered user.
461     * @internal for use in user account creation only.
462     */
463    public function acquireSystemActorId( UserIdentity $user, IDatabase $dbw ): int {
464        $this->checkDatabaseDomain( $dbw );
465        [ $userId, $userName ] = $this->validateActorForInsertion( $user );
466
467        $existingActorId = $this->findActorIdInternal( $userName, $dbw );
468        if ( $existingActorId ) {
469            // It certainly will be cached if we just found it.
470            $existingActor = $this->cache->getActor( ActorCache::KEY_ACTOR_ID, $existingActorId );
471
472            // If we already have an existing actor with a matching user ID
473            // just return it, nothing to do here.
474            if ( $existingActor->getId( $this->wikiId ) === $user->getId( $this->wikiId ) ) {
475                return $existingActorId;
476            }
477
478            // Allow overwriting user ID for already existing actor with reserved user name, see T236444
479            if ( $this->userNameUtils->isUsable( $userName ) || $existingActor->isRegistered() ) {
480                throw new CannotCreateActorException(
481                    'Cannot replace user for existing actor: ' .
482                        'actor_id={existingActorId}, new user_id={userId}',
483                    [ 'existingActorId' => $existingActorId, 'userId' => $userId ]
484                );
485            }
486        }
487        $dbw->newInsertQueryBuilder()
488            ->insertInto( 'actor' )
489            ->row( [ 'actor_name' => $userName, 'actor_user' => $userId ] )
490            ->onDuplicateKeyUpdate()
491            ->uniqueIndexFields( [ 'actor_name' ] )
492            ->set( [ 'actor_user' => $userId ] )
493            ->caller( __METHOD__ )->execute();
494        if ( !$dbw->affectedRows() ) {
495            throw new CannotCreateActorException(
496                'Failed to replace user for actor: ' .
497                    'actor_id={existingActorId}, new user_id={userId}',
498                [ 'existingActorId' => $existingActorId, 'userId' => $userId ]
499            );
500        }
501        $actorId = $dbw->insertId() ?: $existingActorId;
502
503        $this->cache->remove( $user );
504        $this->attachActorId( $user, $actorId, true );
505        // Cache row we've just created
506        $cachedUserIdentity = $this->newActorFromRowFields( $userId, $userName, $actorId );
507        $this->setUpRollbackHandler( $dbw, $cachedUserIdentity, $user );
508        return $actorId;
509    }
510
511    /**
512     * Delete the actor from the actor table
513     *
514     * @warning this method does very limited validation and is extremely
515     * dangerous since it can break referential integrity of the database
516     * if used incorrectly. Use at your own risk!
517     *
518     * @since 1.37
519     * @param UserIdentity $actor
520     * @param IDatabase $dbw
521     * @return bool true on success, false if nothing was deleted.
522     */
523    public function deleteActor( UserIdentity $actor, IDatabase $dbw ): bool {
524        $this->checkDatabaseDomain( $dbw );
525        $this->deprecateInvalidCrossWikiParam( $actor );
526
527        $normalizedName = $this->normalizeUserName( $actor->getName() );
528        if ( $normalizedName === null ) {
529            throw new InvalidArgumentException(
530                "Unable to normalize the provided actor name {$actor->getName()}"
531            );
532        }
533        $dbw->newDeleteQueryBuilder()
534            ->deleteFrom( 'actor' )
535            ->where( [ 'actor_name' => $normalizedName ] )
536            ->caller( __METHOD__ )->execute();
537        if ( $dbw->affectedRows() !== 0 ) {
538            $this->cache->remove( $actor );
539            return true;
540        }
541        return false;
542    }
543
544    /**
545     * Returns a canonical form of username suitable for storage.
546     *
547     * @internal
548     * @param string $name
549     *
550     * @return string|null
551     */
552    public function normalizeUserName( string $name ): ?string {
553        if ( $this->userNameUtils->isIP( $name ) ) {
554            return IPUtils::sanitizeIP( $name );
555        } elseif ( ExternalUserNames::isExternal( $name ) ) {
556            // TODO: ideally, we should probably canonicalize external usernames,
557            // but it was not done before, so we can not start doing it unless we
558            // fix existing DB rows - T273933
559            return $name;
560        } else {
561            $normalized = $this->userNameUtils->getCanonical( $name );
562            return $normalized === false ? null : $normalized;
563        }
564    }
565
566    /**
567     * Validates actor before insertion.
568     *
569     * @param UserIdentity $user
570     * @return array [ $normalizedUserId, $normalizedName ]
571     */
572    private function validateActorForInsertion( UserIdentity $user ): array {
573        // TODO: we want to assert this user belongs to the correct wiki,
574        // but User objects are always local and we used to use them
575        // on a non-local DB connection. We need to first deprecate this
576        // possibility and then throw on mismatching User object - T273972
577        // $user->assertWiki( $this->wikiId );
578        $this->deprecateInvalidCrossWikiParam( $user );
579
580        $userName = $this->normalizeUserName( $user->getName() );
581        if ( $userName === null || $userName === '' ) {
582            $userIdForErrorMessage = $user->getId( $this->wikiId );
583            throw new CannotCreateActorException(
584                'Cannot create an actor for a user with no name: ' .
585                    'user_id={userId} user_name="{userName}"',
586                [ 'userId' => $userIdForErrorMessage, 'userName' => $user->getName() ]
587            );
588        }
589
590        $userId = $user->getId( $this->wikiId ) ?: null;
591        if ( $userId === null && $this->userNameUtils->isUsable( $user->getName() ) ) {
592            throw new CannotCreateActorException(
593                'Cannot create an actor for a usable name that is not an existing user: ' .
594                    'user_name="{userName}"',
595                [ 'userName' => $user->getName() ]
596            );
597        }
598
599        if ( !$this->allowCreateIpActors && $this->userNameUtils->isIP( $userName ) ) {
600            throw new CannotCreateActorException(
601                'Cannot create an actor for an IP user when temporary accounts are enabled'
602            );
603        }
604        return [ $userId, $userName ];
605    }
606
607    /**
608     * Clear in-process caches if the transaction gets rolled back.
609     */
610    private function setUpRollbackHandler(
611        IDatabase $dbw,
612        UserIdentity $cachedActor,
613        UserIdentity $originalActor
614    ) {
615        if ( $dbw->trxLevel() ) {
616            // If called within a transaction and it was rolled back, the cached actor ID
617            // becomes invalid, so cache needs to be invalidated as well. See T277795.
618            $dbw->onTransactionResolution(
619                function ( int $trigger ) use ( $cachedActor, $originalActor ) {
620                    if ( $trigger === IDatabase::TRIGGER_ROLLBACK ) {
621                        $this->cache->remove( $cachedActor );
622                        $this->detachActorId( $originalActor );
623                    }
624                },
625                __METHOD__
626            );
627        }
628    }
629
630    /**
631     * Throws an exception if the given database connection does not belong to the wiki this
632     * ActorStore is bound to.
633     */
634    private function checkDatabaseDomain( IReadableDatabase $db ) {
635        $dbDomain = $db->getDomainID();
636        $storeDomain = $this->loadBalancer->resolveDomainID( $this->wikiId );
637        if ( $dbDomain !== $storeDomain ) {
638            throw new InvalidArgumentException(
639                "DB connection domain '$dbDomain' does not match '$storeDomain'"
640            );
641        }
642    }
643
644    /**
645     * In case all reasonable attempts of initializing a proper actor from the
646     * database have failed, entities can be attributed to special 'Unknown user' actor.
647     */
648    public function getUnknownActor(): UserIdentity {
649        $actor = $this->getUserIdentityByName( self::UNKNOWN_USER_NAME );
650        if ( $actor ) {
651            return $actor;
652        }
653        $actor = new UserIdentityValue( 0, self::UNKNOWN_USER_NAME, $this->wikiId );
654
655        $db = $this->loadBalancer->getConnection( DB_PRIMARY, [], $this->wikiId );
656        $this->acquireActorId( $actor, $db );
657        return $actor;
658    }
659
660    /**
661     * @inheritDoc
662     */
663    public function newSelectQueryBuilder( $dbOrQueryFlags = IDBAccessObject::READ_NORMAL ): UserSelectQueryBuilder {
664        if ( $dbOrQueryFlags instanceof IReadableDatabase ) {
665            [ $db, $flags ] = [ $dbOrQueryFlags, IDBAccessObject::READ_NORMAL ];
666            $this->checkDatabaseDomain( $db );
667        } else {
668            if ( ( $dbOrQueryFlags & IDBAccessObject::READ_LATEST ) === IDBAccessObject::READ_LATEST ) {
669                $db = $this->loadBalancer->getConnection( DB_PRIMARY, [], $this->wikiId );
670            } else {
671                $db = $this->loadBalancer->getConnection( DB_REPLICA, [], $this->wikiId );
672            }
673            $flags = $dbOrQueryFlags;
674        }
675
676        $builder = new UserSelectQueryBuilder(
677            $db,
678            $this,
679            $this->tempUserConfig,
680            $this->hideUserUtils
681        );
682        return $builder->recency( $flags );
683    }
684
685    /**
686     * @internal For use immediately after construction only
687     * @param bool $allow
688     */
689    public function setAllowCreateIpActors( bool $allow ): void {
690        $this->allowCreateIpActors = $allow;
691    }
692
693    /**
694     * Emits a deprecation warning if $user does not belong to the
695     * same wiki this store belongs to.
696     */
697    private function deprecateInvalidCrossWikiParam( UserIdentity $user ) {
698        if ( $user->getWikiId() !== $this->wikiId ) {
699            $expected = $this->wikiIdToString( $user->getWikiId() );
700            $actual = $this->wikiIdToString( $this->wikiId );
701            wfDeprecatedMsg(
702                'Deprecated passing invalid cross-wiki user. ' .
703                "Expected: {$expected}, Actual: {$actual}.",
704                '1.37'
705            );
706        }
707    }
708
709    /**
710     * Convert $wikiId to a string for logging.
711     *
712     * @param string|false $wikiId
713     * @return string
714     */
715    private function wikiIdToString( $wikiId ): string {
716        return $wikiId === WikiAwareEntity::LOCAL ? 'the local wiki' : "'{$wikiId}'";
717    }
718}