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