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