Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.75% covered (warning)
81.75%
103 / 126
43.75% covered (danger)
43.75%
7 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserFactory
81.75% covered (warning)
81.75%
103 / 126
43.75% covered (danger)
43.75%
7 / 16
57.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 newFromName
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 newAnonymous
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 newFromNameOrIp
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 newFromId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 newFromActorId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 newFromUserIdentity
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 newFromAnyId
65.52% covered (warning)
65.52%
19 / 29
0.00% covered (danger)
0.00%
0 / 1
12.32
 newFromConfirmationCode
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 newFromRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newFromAuthority
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 newTempPlaceholder
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 newUnsavedTempUser
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 invalidateCache
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
3.01
 getUserTableConnection
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 isUserTableShared
0.00% covered (danger)
0.00%
0 / 2
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\Config\ServiceOptions;
11use MediaWiki\Logger\LoggerFactory;
12use MediaWiki\MainConfigNames;
13use MediaWiki\Permissions\Authority;
14use MediaWiki\User\TempUser\TempUserConfig;
15use RuntimeException;
16use stdClass;
17use Wikimedia\Rdbms\IDatabase;
18use Wikimedia\Rdbms\IDBAccessObject;
19use Wikimedia\Rdbms\ILBFactory;
20use Wikimedia\Rdbms\ILoadBalancer;
21
22/**
23 * Create User objects.
24 *
25 * This creates User objects and involves all the same global state,
26 * but wraps it in a service class to avoid static coupling, which
27 * eases mocking in unit tests.
28 *
29 * @since 1.35
30 * @ingroup User
31 */
32class UserFactory implements UserRigorOptions {
33
34    /**
35     * RIGOR_* constants are inherited from UserRigorOptions
36     */
37
38    /** @internal */
39    public const CONSTRUCTOR_OPTIONS = [
40        MainConfigNames::SharedDB,
41        MainConfigNames::SharedTables,
42    ];
43
44    private ILoadBalancer $loadBalancer;
45
46    private ?User $lastUserFromIdentity = null;
47
48    public function __construct(
49        private readonly ServiceOptions $options,
50        private readonly ILBFactory $loadBalancerFactory,
51        private readonly UserNameUtils $userNameUtils,
52        private readonly TempUserConfig $tempUserConfig
53    ) {
54        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
55        $this->loadBalancer = $loadBalancerFactory->getMainLB();
56    }
57
58    /**
59     * Factory method for creating users by name, replacing static User::newFromName
60     *
61     * This is slightly less efficient than newFromId(), so use newFromId() if
62     * you have both an ID and a name handy.
63     *
64     * @note unlike User::newFromName, this returns null instead of false for invalid usernames
65     *
66     * @since 1.35
67     * @since 1.36 returns null instead of false for invalid user names
68     *
69     * @param string $name Username, validated by Title::newFromText
70     * @param string $validate Validation strategy, one of the RIGOR_* constants. For no
71     *    validation, use RIGOR_NONE. If you just want to create valid user who can be either a named
72     *    user or an IP, consider using newFromNameOrIp() instead of calling this with RIGOR_NONE.
73     * @return ?User User object, or null if the username is invalid (e.g. if it contains
74     *  illegal characters or is an IP address). If the username is not present in the database,
75     *  the result will be a user object with a name, a user id of 0, and default settings.
76     */
77    public function newFromName(
78        string $name,
79        string $validate = self::RIGOR_VALID
80    ): ?User {
81        // RIGOR_* constants are the same here and in the UserNameUtils class
82        $canonicalName = $this->userNameUtils->getCanonical( $name, $validate );
83        if ( $canonicalName === false ) {
84            return null;
85        }
86
87        $user = new User();
88        $user->mName = $canonicalName;
89        $user->mFrom = 'name';
90        $user->setItemLoaded( 'name' );
91        return $user;
92    }
93
94    /**
95     * Returns a new anonymous User based on ip.
96     *
97     * @since 1.35
98     *
99     * @param string|null $ip IP address
100     * @return User
101     */
102    public function newAnonymous( ?string $ip = null ): User {
103        if ( $ip ) {
104            if ( !$this->userNameUtils->isIP( $ip ) ) {
105                throw new InvalidArgumentException( 'Invalid IP address' );
106            }
107            $user = new User();
108            $user->setName( $ip );
109        } else {
110            $user = new User();
111        }
112        return $user;
113    }
114
115    /**
116     * Returns either an anonymous or a named User based on the supplied argument
117     *
118     * If IP is supplied, an anonymous user will be created, otherwise a valid named user.
119     * If you don't want to have the named user validated, use self::newFromName().
120     * If you want to create a simple anonymous user without providing the IP, use self::newAnonymous()
121     *
122     * @since 1.44
123     *
124     * @param string $name IP address or username
125     * @return User|null
126     */
127    public function newFromNameOrIp( string $name ): ?User {
128        if ( $this->userNameUtils->isIP( $name ) ) {
129            return $this->newAnonymous( $name );
130        }
131
132        return $this->newFromName( $name );
133    }
134
135    /**
136     * Factory method for creation from a given user ID, replacing User::newFromId
137     *
138     * @since 1.35
139     *
140     * @param int $id Valid user ID
141     * @return User
142     */
143    public function newFromId( int $id ): User {
144        $user = new User();
145        $user->mId = $id;
146        $user->mFrom = 'id';
147        $user->setItemLoaded( 'id' );
148        return $user;
149    }
150
151    /**
152     * Factory method for creation from a given actor ID, replacing User::newFromActorId
153     *
154     * @since 1.35
155     */
156    public function newFromActorId( int $actorId ): User {
157        $user = new User();
158        $user->mActorId = $actorId;
159        $user->mFrom = 'actor';
160        $user->setItemLoaded( 'actor' );
161        return $user;
162    }
163
164    /**
165     * Factory method for creation from a given UserIdentity, replacing User::newFromIdentity
166     *
167     * @since 1.35
168     */
169    public function newFromUserIdentity( UserIdentity $userIdentity ): User {
170        if ( $userIdentity instanceof User ) {
171            return $userIdentity;
172        }
173
174        $id = $userIdentity->getId();
175        $name = $userIdentity->getName();
176        // Cache the $userIdentity we converted last. This avoids redundant conversion
177        // in cases where we would be converting the same UserIdentity over and over,
178        // for instance because we need to access data preferences when formatting
179        // timestamps in a listing.
180        if (
181            $this->lastUserFromIdentity
182            && $this->lastUserFromIdentity->getId() === $id
183            && $this->lastUserFromIdentity->getName() === $name
184            && $this->lastUserFromIdentity->getWikiId() === $userIdentity->getWikiId()
185        ) {
186            return $this->lastUserFromIdentity;
187        }
188
189        $this->lastUserFromIdentity = $this->newFromAnyId(
190            $id === 0 ? null : $id,
191            $name === '' ? null : $name,
192            null,
193            $userIdentity->getWikiId()
194        );
195
196        return $this->lastUserFromIdentity;
197    }
198
199    /**
200     * Factory method for creation from an ID, name, and/or actor ID, replacing User::newFromAnyId
201     *
202     * @note This does not check that the ID, name, and actor ID all correspond to
203     * the same user.
204     *
205     * @since 1.35
206     *
207     * @param ?int $userId
208     * @param ?string $userName
209     * @param ?int $actorId
210     * @param string|false $dbDomain
211     * @return User
212     * @throws InvalidArgumentException if none of userId, userName, and actorId are specified
213     */
214    public function newFromAnyId(
215        ?int $userId,
216        ?string $userName,
217        ?int $actorId = null,
218        $dbDomain = false
219    ): User {
220        // Stop-gap solution for the problem described in T222212.
221        // Force the User ID and Actor ID to zero for users loaded from the database
222        // of another wiki, to prevent subtle data corruption and confusing failure modes.
223        // FIXME this assumes the same username belongs to the same user on all wikis
224        if ( $dbDomain !== false ) {
225            LoggerFactory::getInstance( 'user' )->warning(
226                'UserFactory::newFromAnyId called with cross-wiki user data',
227                [ 'userId' => $userId, 'userName' => $userName, 'actorId' => $actorId,
228                  'dbDomain' => $dbDomain, 'exception' => new RuntimeException() ]
229            );
230            $userId = 0;
231            $actorId = 0;
232        }
233
234        $user = new User;
235        $user->mFrom = 'defaults';
236
237        if ( $actorId !== null ) {
238            $user->mActorId = $actorId;
239            if ( $actorId !== 0 ) {
240                $user->mFrom = 'actor';
241            }
242            $user->setItemLoaded( 'actor' );
243        }
244
245        if ( $userName !== null && $userName !== '' ) {
246            $user->mName = $userName;
247            $user->mFrom = 'name';
248            $user->setItemLoaded( 'name' );
249        }
250
251        if ( $userId !== null ) {
252            $user->mId = $userId;
253            if ( $userId !== 0 ) {
254                $user->mFrom = 'id';
255            }
256            $user->setItemLoaded( 'id' );
257        }
258
259        if ( $user->mFrom === 'defaults' ) {
260            throw new InvalidArgumentException(
261                'Cannot create a user with no name, no ID, and no actor ID'
262            );
263        }
264
265        return $user;
266    }
267
268    /**
269     * Factory method to fetch the user for a given email confirmation code, replacing User::newFromConfirmationCode
270     *
271     * This code is generated when an account is created or its e-mail address has changed.
272     * If the code is invalid or has expired, returns null.
273     *
274     * @since 1.35
275     */
276    public function newFromConfirmationCode(
277        string $confirmationCode,
278        int $flags = IDBAccessObject::READ_NORMAL
279    ): ?User {
280        if ( ( $flags & IDBAccessObject::READ_LATEST ) === IDBAccessObject::READ_LATEST ) {
281            $db = $this->loadBalancer->getConnection( DB_PRIMARY );
282        } else {
283            $db = $this->loadBalancer->getConnection( DB_REPLICA );
284        }
285
286        $id = $db->newSelectQueryBuilder()
287            ->select( 'user_id' )
288            ->from( 'user' )
289            ->where( [ 'user_email_token' => md5( $confirmationCode ) ] )
290            ->andWhere( $db->expr( 'user_email_token_expires', '>', $db->timestamp() ) )
291            ->recency( $flags )
292            ->caller( __METHOD__ )->fetchField();
293
294        if ( !$id ) {
295            return null;
296        }
297
298        return $this->newFromId( (int)$id );
299    }
300
301    /**
302     * @see User::newFromRow
303     *
304     * @since 1.36
305     *
306     * @param stdClass $row A row from the user table
307     * @param array|null $data Further data to load into the object
308     * @return User
309     */
310    public function newFromRow( $row, $data = null ): User {
311        return User::newFromRow( $row, $data );
312    }
313
314    /**
315     * @internal for transition from User to Authority as performer concept.
316     */
317    public function newFromAuthority( Authority $authority ): User {
318        if ( $authority instanceof User ) {
319            return $authority;
320        }
321        return $this->newFromUserIdentity( $authority->getUser() );
322    }
323
324    /**
325     * Create a placeholder user for an anonymous user who will be upgraded to
326     * a temporary user. This will throw an exception if temp user autocreation
327     * is disabled.
328     *
329     * @since 1.39
330     */
331    public function newTempPlaceholder(): User {
332        $user = new User();
333        $user->setName( $this->tempUserConfig->getPlaceholderName() );
334        return $user;
335    }
336
337    /**
338     * Create an unsaved temporary user with a previously acquired name or a placeholder name.
339     *
340     * @since 1.39
341     * @param ?string $name If null, a placeholder name is used
342     * @return User
343     */
344    public function newUnsavedTempUser( ?string $name ): User {
345        $user = new User();
346        $user->setName( $name ?? $this->tempUserConfig->getPlaceholderName() );
347        return $user;
348    }
349
350    /**
351     * Purge user-related caches, "touch" the user table to invalidate further caches
352     * @since 1.41
353     */
354    public function invalidateCache( UserIdentity $userIdentity ): void {
355        if ( !$userIdentity->isRegistered() ) {
356            return;
357        }
358
359        $wikiId = $userIdentity->getWikiId();
360        if ( $wikiId === UserIdentity::LOCAL ) {
361            $legacyUser = $this->newFromUserIdentity( $userIdentity );
362            // Update user_touched within User class to manage the state of User::mTouched for CAS check
363            $legacyUser->invalidateCache();
364        } else {
365            // cross-wiki invalidation
366            $userId = $userIdentity->getId( $wikiId );
367
368            $dbw = $this->getUserTableConnection( ILoadBalancer::DB_PRIMARY, $wikiId );
369            $dbw->newUpdateQueryBuilder()
370                ->update( 'user' )
371                ->set( [ 'user_touched' => $dbw->timestamp() ] )
372                ->where( [ 'user_id' => $userId ] )
373                ->caller( __METHOD__ )->execute();
374
375            $dbw->onTransactionPreCommitOrIdle(
376                static function () use ( $wikiId, $userId ) {
377                    User::purge( $wikiId, $userId );
378                },
379                __METHOD__
380            );
381        }
382    }
383
384    /**
385     * @param int $mode
386     * @param string|false $wikiId
387     * @return IDatabase
388     */
389    private function getUserTableConnection( $mode, $wikiId ): IDatabase {
390        if ( is_string( $wikiId ) && $this->loadBalancerFactory->getLocalDomainID() === $wikiId ) {
391            $wikiId = UserIdentity::LOCAL;
392        }
393
394        if ( $this->options->get( MainConfigNames::SharedDB ) &&
395            in_array( 'user', $this->options->get( MainConfigNames::SharedTables ) )
396        ) {
397            // The main LB is aliased for the shared database in Setup.php
398            $lb = $this->loadBalancer;
399        } else {
400            $lb = $this->loadBalancerFactory->getMainLB( $wikiId );
401        }
402
403        return $lb->getConnection( $mode, [], $wikiId );
404    }
405
406    /**
407     * Returns if the user table is shared with other wikis.
408     */
409    public function isUserTableShared(): bool {
410        return $this->options->get( MainConfigNames::SharedDB ) &&
411            in_array( 'user', $this->options->get( MainConfigNames::SharedTables ) );
412    }
413}