Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.48% covered (warning)
85.48%
106 / 124
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserFactory
85.48% covered (warning)
85.48%
106 / 124
50.00% covered (danger)
50.00%
7 / 14
46.14
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%
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.
87     * @return ?User User object, or null if the username is invalid (e.g. if it contains
88     *  illegal characters or is an IP address). If the username is not present in the database,
89     *  the result will be a user object with a name, a user id of 0, and default settings.
90     */
91    public function newFromName(
92        string $name,
93        string $validate = self::RIGOR_VALID
94    ): ?User {
95        // RIGOR_* constants are the same here and in the UserNameUtils class
96        $canonicalName = $this->userNameUtils->getCanonical( $name, $validate );
97        if ( $canonicalName === false ) {
98            return null;
99        }
100
101        $user = new User();
102        $user->mName = $canonicalName;
103        $user->mFrom = 'name';
104        $user->setItemLoaded( 'name' );
105        return $user;
106    }
107
108    /**
109     * Returns a new anonymous User based on ip.
110     *
111     * @since 1.35
112     *
113     * @param string|null $ip IP address
114     * @return User
115     */
116    public function newAnonymous( ?string $ip = null ): User {
117        if ( $ip ) {
118            if ( !$this->userNameUtils->isIP( $ip ) ) {
119                throw new InvalidArgumentException( 'Invalid IP address' );
120            }
121            $user = new User();
122            $user->setName( $ip );
123        } else {
124            $user = new User();
125        }
126        return $user;
127    }
128
129    /**
130     * Factory method for creation from a given user ID, replacing User::newFromId
131     *
132     * @since 1.35
133     *
134     * @param int $id Valid user ID
135     * @return User
136     */
137    public function newFromId( int $id ): User {
138        $user = new User();
139        $user->mId = $id;
140        $user->mFrom = 'id';
141        $user->setItemLoaded( 'id' );
142        return $user;
143    }
144
145    /**
146     * Factory method for creation from a given actor ID, replacing User::newFromActorId
147     *
148     * @since 1.35
149     *
150     * @param int $actorId
151     * @return User
152     */
153    public function newFromActorId( int $actorId ): User {
154        $user = new User();
155        $user->mActorId = $actorId;
156        $user->mFrom = 'actor';
157        $user->setItemLoaded( 'actor' );
158        return $user;
159    }
160
161    /**
162     * Factory method for creation from a given UserIdentity, replacing User::newFromIdentity
163     *
164     * @since 1.35
165     *
166     * @param UserIdentity $userIdentity
167     * @return User
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     * @param string $confirmationCode
277     * @param int $flags
278     * @return User|null
279     */
280    public function newFromConfirmationCode(
281        string $confirmationCode,
282        int $flags = IDBAccessObject::READ_NORMAL
283    ): ?User {
284        if ( ( $flags & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) {
285            $db = $this->loadBalancer->getConnection( DB_PRIMARY );
286        } else {
287            $db = $this->loadBalancer->getConnection( DB_REPLICA );
288        }
289
290        $id = $db->newSelectQueryBuilder()
291            ->select( 'user_id' )
292            ->from( 'user' )
293            ->where( [ 'user_email_token' => md5( $confirmationCode ) ] )
294            ->andWhere( $db->expr( 'user_email_token_expires', '>', $db->timestamp() ) )
295            ->recency( $flags )
296            ->caller( __METHOD__ )->fetchField();
297
298        if ( !$id ) {
299            return null;
300        }
301
302        return $this->newFromId( (int)$id );
303    }
304
305    /**
306     * @see User::newFromRow
307     *
308     * @since 1.36
309     *
310     * @param stdClass $row A row from the user table
311     * @param array|null $data Further data to load into the object
312     * @return User
313     */
314    public function newFromRow( $row, $data = null ): User {
315        return User::newFromRow( $row, $data );
316    }
317
318    /**
319     * @internal for transition from User to Authority as performer concept.
320     * @param Authority $authority
321     * @return User
322     */
323    public function newFromAuthority( Authority $authority ): User {
324        if ( $authority instanceof User ) {
325            return $authority;
326        }
327        return $this->newFromUserIdentity( $authority->getUser() );
328    }
329
330    /**
331     * Create a placeholder user for an anonymous user who will be upgraded to
332     * a temporary user. This will throw an exception if temp user autocreation
333     * is disabled.
334     *
335     * @since 1.39
336     * @return User
337     */
338    public function newTempPlaceholder(): User {
339        $user = new User();
340        $user->setName( $this->userNameUtils->getTempPlaceholder() );
341        return $user;
342    }
343
344    /**
345     * Create an unsaved temporary user with a previously acquired name or a placeholder name.
346     *
347     * @since 1.39
348     * @param ?string $name If null, a placeholder name is used
349     * @return User
350     */
351    public function newUnsavedTempUser( ?string $name ): User {
352        $user = new User();
353        $user->setName( $name ?? $this->userNameUtils->getTempPlaceholder() );
354        return $user;
355    }
356
357    /**
358     * Purge user related caches, "touch" the user table to invalidate further caches
359     * @since 1.41
360     * @param UserIdentity $userIdentity
361     */
362    public function invalidateCache( UserIdentity $userIdentity ): void {
363        if ( !$userIdentity->isRegistered() ) {
364            return;
365        }
366
367        $wikiId = $userIdentity->getWikiId();
368        if ( $wikiId === UserIdentity::LOCAL ) {
369            $legacyUser = $this->newFromUserIdentity( $userIdentity );
370            // Update user_touched within User class to manage state of User::mTouched for CAS check
371            $legacyUser->invalidateCache();
372        } else {
373            // cross-wiki invalidation
374            $userId = $userIdentity->getId( $wikiId );
375
376            $dbw = $this->getUserTableConnection( ILoadBalancer::DB_PRIMARY, $wikiId );
377            $dbw->newUpdateQueryBuilder()
378                ->update( 'user' )
379                ->set( [ 'user_touched' => $dbw->timestamp() ] )
380                ->where( [ 'user_id' => $userId ] )
381                ->caller( __METHOD__ )->execute();
382
383            $dbw->onTransactionPreCommitOrIdle(
384                static function () use ( $wikiId, $userId ) {
385                    User::purge( $wikiId, $userId );
386                },
387                __METHOD__
388            );
389        }
390    }
391
392    /**
393     * @param int $mode
394     * @param string|false $wikiId
395     * @return IDatabase
396     */
397    private function getUserTableConnection( $mode, $wikiId ): IDatabase {
398        if ( is_string( $wikiId ) && $this->loadBalancerFactory->getLocalDomainID() === $wikiId ) {
399            $wikiId = UserIdentity::LOCAL;
400        }
401
402        if ( $this->options->get( MainConfigNames::SharedDB ) &&
403            in_array( 'user', $this->options->get( MainConfigNames::SharedTables ) )
404        ) {
405            // The main LB is aliased for the shared database in Setup.php
406            $lb = $this->loadBalancer;
407        } else {
408            $lb = $this->loadBalancerFactory->getMainLB( $wikiId );
409        }
410
411        return $lb->getConnection( $mode, [], $wikiId );
412    }
413}