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