Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.98% covered (success)
96.98%
482 / 497
70.00% covered (warning)
70.00%
21 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserGroupManager
96.98% covered (success)
96.98%
482 / 497
70.00% covered (warning)
70.00%
21 / 30
138
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 listAllGroups
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 listAllImplicitGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newGroupMembershipFromRow
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 loadGroupMembershipsFromArray
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getUserImplicitGroups
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 getUserEffectiveGroups
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 getUserFormerGroups
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 getUserAutopromoteGroups
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 getUserAutopromoteOnceGroups
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 getUserPrivilegedGroups
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 recCheckCondition
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
16.83
 checkCondition
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
17
 addUserToAutopromoteOnceGroups
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
8
 getUserGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserGroupMemberships
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 addUserToGroup
96.05% covered (success)
96.05%
73 / 76
0.00% covered (danger)
0.00%
0 / 1
16
 addUserToMultipleGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 removeUserFromGroup
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
7
 newQueryBuilder
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 purgeExpired
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
5
 expandChangeableGroupConfig
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getGroupsChangeableByGroup
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getGroupsChangeableBy
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 clearCache
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 clearUserCacheForKind
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDBConnectionRefForQueryFlags
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 canUseCachedValues
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
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 IDBAccessObject;
24use InvalidArgumentException;
25use JobQueueGroup;
26use ManualLogEntry;
27use MediaWiki\Config\ServiceOptions;
28use MediaWiki\Deferred\DeferredUpdates;
29use MediaWiki\HookContainer\HookContainer;
30use MediaWiki\HookContainer\HookRunner;
31use MediaWiki\MainConfigNames;
32use MediaWiki\Parser\Sanitizer;
33use MediaWiki\Permissions\Authority;
34use MediaWiki\Permissions\GroupPermissionsLookup;
35use MediaWiki\User\TempUser\TempUserConfig;
36use MediaWiki\WikiMap\WikiMap;
37use Psr\Log\LoggerInterface;
38use UserGroupExpiryJob;
39use Wikimedia\Assert\Assert;
40use Wikimedia\IPUtils;
41use Wikimedia\Rdbms\IConnectionProvider;
42use Wikimedia\Rdbms\ILBFactory;
43use Wikimedia\Rdbms\IReadableDatabase;
44use Wikimedia\Rdbms\ReadOnlyMode;
45use Wikimedia\Rdbms\SelectQueryBuilder;
46
47/**
48 * Manages user groups.
49 * @since 1.35
50 */
51class UserGroupManager {
52
53    /**
54     * @internal For use by ServiceWiring
55     */
56    public const CONSTRUCTOR_OPTIONS = [
57        MainConfigNames::AddGroups,
58        MainConfigNames::AutoConfirmAge,
59        MainConfigNames::AutoConfirmCount,
60        MainConfigNames::Autopromote,
61        MainConfigNames::AutopromoteOnce,
62        MainConfigNames::AutopromoteOnceLogInRC,
63        MainConfigNames::EmailAuthentication,
64        MainConfigNames::ImplicitGroups,
65        MainConfigNames::GroupInheritsPermissions,
66        MainConfigNames::GroupPermissions,
67        MainConfigNames::GroupsAddToSelf,
68        MainConfigNames::GroupsRemoveFromSelf,
69        MainConfigNames::RevokePermissions,
70        MainConfigNames::RemoveGroups,
71        MainConfigNames::PrivilegedGroups,
72    ];
73
74    /**
75     * Logical operators recognized in $wgAutopromote.
76     *
77     * @since 1.42
78     */
79    public const VALID_OPS = [ '&', '|', '^', '!' ];
80
81    private ServiceOptions $options;
82    private IConnectionProvider $dbProvider;
83    private HookContainer $hookContainer;
84    private HookRunner $hookRunner;
85    private ReadOnlyMode $readOnlyMode;
86    private UserEditTracker $userEditTracker;
87    private GroupPermissionsLookup $groupPermissionsLookup;
88    private JobQueueGroup $jobQueueGroup;
89    private LoggerInterface $logger;
90    private TempUserConfig $tempUserConfig;
91
92    /** @var callable[] */
93    private $clearCacheCallbacks;
94
95    /** @var string|false */
96    private $wikiId;
97
98    /** string key for implicit groups cache */
99    private const CACHE_IMPLICIT = 'implicit';
100
101    /** string key for effective groups cache */
102    private const CACHE_EFFECTIVE = 'effective';
103
104    /** string key for group memberships cache */
105    private const CACHE_MEMBERSHIP = 'membership';
106
107    /** string key for former groups cache */
108    private const CACHE_FORMER = 'former';
109
110    /** string key for former groups cache */
111    private const CACHE_PRIVILEGED = 'privileged';
112
113    /**
114     * @var array Service caches, an assoc. array keyed after the user-keys generated
115     * by the getCacheKey method and storing values in the following format:
116     *
117     * userKey => [
118     *   self::CACHE_IMPLICIT => implicit groups cache
119     *   self::CACHE_EFFECTIVE => effective groups cache
120     *   self::CACHE_MEMBERSHIP => [ ] // Array of UserGroupMembership objects
121     *   self::CACHE_FORMER => former groups cache
122     *   self::CACHE_PRIVILEGED => privileged groups cache
123     * ]
124     */
125    private $userGroupCache = [];
126
127    /**
128     * @var array An assoc. array that stores query flags used to retrieve user groups
129     * from the database and is stored in the following format:
130     *
131     * userKey => [
132     *   self::CACHE_IMPLICIT => implicit groups query flag
133     *   self::CACHE_EFFECTIVE => effective groups query flag
134     *   self::CACHE_MEMBERSHIP => membership groups query flag
135     *   self::CACHE_FORMER => former groups query flag
136     *   self::CACHE_PRIVILEGED => privileged groups query flag
137     * ]
138     */
139    private $queryFlagsUsedForCaching = [];
140
141    /**
142     * @param ServiceOptions $options
143     * @param ReadOnlyMode $readOnlyMode
144     * @param ILBFactory $lbFactory
145     * @param HookContainer $hookContainer
146     * @param UserEditTracker $userEditTracker
147     * @param GroupPermissionsLookup $groupPermissionsLookup
148     * @param JobQueueGroup $jobQueueGroup
149     * @param LoggerInterface $logger
150     * @param TempUserConfig $tempUserConfig
151     * @param callable[] $clearCacheCallbacks
152     * @param string|false $wikiId
153     */
154    public function __construct(
155        ServiceOptions $options,
156        ReadOnlyMode $readOnlyMode,
157        ILBFactory $lbFactory,
158        HookContainer $hookContainer,
159        UserEditTracker $userEditTracker,
160        GroupPermissionsLookup $groupPermissionsLookup,
161        JobQueueGroup $jobQueueGroup,
162        LoggerInterface $logger,
163        TempUserConfig $tempUserConfig,
164        array $clearCacheCallbacks = [],
165        $wikiId = UserIdentity::LOCAL
166    ) {
167        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
168        $this->options = $options;
169        $this->dbProvider = $lbFactory;
170        $this->hookContainer = $hookContainer;
171        $this->hookRunner = new HookRunner( $hookContainer );
172        $this->userEditTracker = $userEditTracker;
173        $this->groupPermissionsLookup = $groupPermissionsLookup;
174        $this->jobQueueGroup = $jobQueueGroup;
175        $this->logger = $logger;
176        $this->tempUserConfig = $tempUserConfig;
177        $this->readOnlyMode = $readOnlyMode;
178        $this->clearCacheCallbacks = $clearCacheCallbacks;
179        $this->wikiId = $wikiId;
180    }
181
182    /**
183     * Return the set of defined explicit groups.
184     * The implicit groups (by default *, 'user' and 'autoconfirmed')
185     * are not included, as they are defined automatically, not in the database.
186     * @return string[] internal group names
187     */
188    public function listAllGroups(): array {
189        return array_values( array_unique(
190            array_diff(
191                array_merge(
192                    array_keys( $this->options->get( MainConfigNames::GroupPermissions ) ),
193                    array_keys( $this->options->get( MainConfigNames::RevokePermissions ) ),
194                    array_keys( $this->options->get( MainConfigNames::GroupInheritsPermissions ) )
195                ),
196                $this->listAllImplicitGroups()
197            )
198        ) );
199    }
200
201    /**
202     * Get a list of all configured implicit groups
203     * @return string[]
204     */
205    public function listAllImplicitGroups(): array {
206        return $this->options->get( MainConfigNames::ImplicitGroups );
207    }
208
209    /**
210     * Creates a new UserGroupMembership instance from $row.
211     * The fields required to build an instance could be
212     * found using getQueryInfo() method.
213     *
214     * @param \stdClass $row A database result object
215     *
216     * @return UserGroupMembership
217     */
218    public function newGroupMembershipFromRow( \stdClass $row ): UserGroupMembership {
219        return new UserGroupMembership(
220            (int)$row->ug_user,
221            $row->ug_group,
222            $row->ug_expiry === null ? null : wfTimestamp(
223                TS_MW,
224                $row->ug_expiry
225            )
226        );
227    }
228
229    /**
230     * Load the user groups cache from the provided user groups data
231     * @internal for use by the User object only
232     * @param UserIdentity $user
233     * @param array $userGroups an array of database query results
234     * @param int $queryFlags
235     */
236    public function loadGroupMembershipsFromArray(
237        UserIdentity $user,
238        array $userGroups,
239        int $queryFlags = IDBAccessObject::READ_NORMAL
240    ) {
241        $user->assertWiki( $this->wikiId );
242        $membershipGroups = [];
243        reset( $userGroups );
244        foreach ( $userGroups as $row ) {
245            $ugm = $this->newGroupMembershipFromRow( $row );
246            $membershipGroups[ $ugm->getGroup() ] = $ugm;
247        }
248        $this->setCache(
249            $this->getCacheKey( $user ),
250            self::CACHE_MEMBERSHIP,
251            $membershipGroups,
252            $queryFlags
253        );
254    }
255
256    /**
257     * Get the list of implicit group memberships this user has.
258     *
259     * This includes 'user' if logged in, '*' for all accounts,
260     * and autopromoted groups
261     *
262     * @param UserIdentity $user
263     * @param int $queryFlags
264     * @param bool $recache Whether to avoid the cache
265     * @return string[] internal group names
266     */
267    public function getUserImplicitGroups(
268        UserIdentity $user,
269        int $queryFlags = IDBAccessObject::READ_NORMAL,
270        bool $recache = false
271    ): array {
272        $user->assertWiki( $this->wikiId );
273        $userKey = $this->getCacheKey( $user );
274        if ( $recache ||
275            !isset( $this->userGroupCache[$userKey][self::CACHE_IMPLICIT] ) ||
276            !$this->canUseCachedValues( $user, self::CACHE_IMPLICIT, $queryFlags )
277        ) {
278            $groups = [ '*' ];
279            if ( $this->tempUserConfig->isTempName( $user->getName() ) ) {
280                $groups[] = 'temp';
281            } elseif ( $user->isRegistered() ) {
282                $groups[] = 'user';
283                $groups = array_unique( array_merge(
284                    $groups,
285                    $this->getUserAutopromoteGroups( $user )
286                ) );
287            }
288            $this->setCache( $userKey, self::CACHE_IMPLICIT, $groups, $queryFlags );
289            if ( $recache ) {
290                // Assure data consistency with rights/groups,
291                // as getUserEffectiveGroups() depends on this function
292                $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
293            }
294        }
295        return $this->userGroupCache[$userKey][self::CACHE_IMPLICIT];
296    }
297
298    /**
299     * Get the list of implicit group memberships the user has.
300     *
301     * This includes all explicit groups, plus 'user' if logged in,
302     * '*' for all accounts, and autopromoted groups
303     *
304     * @param UserIdentity $user
305     * @param int $queryFlags
306     * @param bool $recache Whether to avoid the cache
307     * @return string[] internal group names
308     */
309    public function getUserEffectiveGroups(
310        UserIdentity $user,
311        int $queryFlags = IDBAccessObject::READ_NORMAL,
312        bool $recache = false
313    ): array {
314        $user->assertWiki( $this->wikiId );
315        $userKey = $this->getCacheKey( $user );
316        // Ignore cache if the $recache flag is set, cached values can not be used
317        // or the cache value is missing
318        if ( $recache ||
319            !$this->canUseCachedValues( $user, self::CACHE_EFFECTIVE, $queryFlags ) ||
320            !isset( $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE] )
321        ) {
322            $groups = array_unique( array_merge(
323                $this->getUserGroups( $user, $queryFlags ), // explicit groups
324                $this->getUserImplicitGroups( $user, $queryFlags, $recache ) // implicit groups
325            ) );
326            // TODO: Deprecate passing out user object in the hook by introducing
327            // an alternative hook
328            if ( $this->hookContainer->isRegistered( 'UserEffectiveGroups' ) ) {
329                $userObj = User::newFromIdentity( $user );
330                $userObj->load();
331                // Hook for additional groups
332                $this->hookRunner->onUserEffectiveGroups( $userObj, $groups );
333            }
334            // Force reindexation of groups when a hook has unset one of them
335            $effectiveGroups = array_values( array_unique( $groups ) );
336            $this->setCache( $userKey, self::CACHE_EFFECTIVE, $effectiveGroups, $queryFlags );
337        }
338        return $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE];
339    }
340
341    /**
342     * Returns the groups the user has belonged to.
343     *
344     * The user may still belong to the returned groups. Compare with
345     * getUserGroups().
346     *
347     * The function will not return groups the user had belonged to before MW 1.17
348     *
349     * @param UserIdentity $user
350     * @param int $queryFlags
351     * @return string[] Names of the groups the user has belonged to.
352     */
353    public function getUserFormerGroups(
354        UserIdentity $user,
355        int $queryFlags = IDBAccessObject::READ_NORMAL
356    ): array {
357        $user->assertWiki( $this->wikiId );
358        $userKey = $this->getCacheKey( $user );
359
360        if ( $this->canUseCachedValues( $user, self::CACHE_FORMER, $queryFlags ) &&
361            isset( $this->userGroupCache[$userKey][self::CACHE_FORMER] )
362        ) {
363            return $this->userGroupCache[$userKey][self::CACHE_FORMER];
364        }
365
366        if ( !$user->isRegistered() ) {
367            // Anon users don't have groups stored in the database
368            return [];
369        }
370
371        $res = $this->getDBConnectionRefForQueryFlags( $queryFlags )->newSelectQueryBuilder()
372            ->select( 'ufg_group' )
373            ->from( 'user_former_groups' )
374            ->where( [ 'ufg_user' => $user->getId( $this->wikiId ) ] )
375            ->caller( __METHOD__ )
376            ->fetchResultSet();
377        $formerGroups = [];
378        foreach ( $res as $row ) {
379            $formerGroups[] = $row->ufg_group;
380        }
381        $this->setCache( $userKey, self::CACHE_FORMER, $formerGroups, $queryFlags );
382
383        return $this->userGroupCache[$userKey][self::CACHE_FORMER];
384    }
385
386    /**
387     * Get the groups for the given user based on $wgAutopromote.
388     *
389     * @param UserIdentity $user The user to get the groups for
390     * @return string[] Array of groups to promote to.
391     *
392     * @see $wgAutopromote
393     */
394    public function getUserAutopromoteGroups( UserIdentity $user ): array {
395        $user->assertWiki( $this->wikiId );
396        $promote = [];
397        // TODO: remove the need for the full user object
398        $userObj = User::newFromIdentity( $user );
399        if ( $userObj->isTemp() ) {
400            return [];
401        }
402        foreach ( $this->options->get( MainConfigNames::Autopromote ) as $group => $cond ) {
403            if ( $this->recCheckCondition( $cond, $userObj ) ) {
404                $promote[] = $group;
405            }
406        }
407
408        $this->hookRunner->onGetAutoPromoteGroups( $userObj, $promote );
409        return $promote;
410    }
411
412    /**
413     * Get the groups for the given user based on the given criteria.
414     *
415     * Does not return groups the user already belongs to or has once belonged.
416     *
417     * @param UserIdentity $user The user to get the groups for
418     * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria)
419     *
420     * @return string[] Groups the user should be promoted to.
421     *
422     * @see $wgAutopromoteOnce
423     */
424    public function getUserAutopromoteOnceGroups(
425        UserIdentity $user,
426        string $event
427    ): array {
428        $user->assertWiki( $this->wikiId );
429        $autopromoteOnce = $this->options->get( MainConfigNames::AutopromoteOnce );
430        $promote = [];
431
432        if ( isset( $autopromoteOnce[$event] ) && count( $autopromoteOnce[$event] ) ) {
433            // TODO: remove the need for the full user object
434            $userObj = User::newFromIdentity( $user );
435            if ( $userObj->isTemp() ) {
436                return [];
437            }
438            $currentGroups = $this->getUserGroups( $user );
439            $formerGroups = $this->getUserFormerGroups( $user );
440            foreach ( $autopromoteOnce[$event] as $group => $cond ) {
441                // Do not check if the user's already a member
442                if ( in_array( $group, $currentGroups ) ) {
443                    continue;
444                }
445                // Do not autopromote if the user has belonged to the group
446                if ( in_array( $group, $formerGroups ) ) {
447                    continue;
448                }
449                // Finally - check the conditions
450                if ( $this->recCheckCondition( $cond, $userObj ) ) {
451                    $promote[] = $group;
452                }
453            }
454        }
455
456        return $promote;
457    }
458
459    /**
460     * Returns the list of privileged groups that $user belongs to.
461     * Privileged groups are ones that can be abused in a dangerous way.
462     *
463     * Depending on how extensions extend this method, it might return values
464     * that are not strictly user groups (ACL list names, etc.).
465     * It is meant for logging/auditing, not for passing to methods that expect group names.
466     *
467     * @param UserIdentity $user
468     * @param int $queryFlags
469     * @param bool $recache Whether to avoid the cache
470     * @return string[]
471     * @since 1.41 (also backported to 1.39.5 and 1.40.1)
472     * @see $wgPrivilegedGroups
473     * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGetPrivilegedGroups
474     */
475    public function getUserPrivilegedGroups(
476        UserIdentity $user,
477        int $queryFlags = IDBAccessObject::READ_NORMAL,
478        bool $recache = false
479    ): array {
480        $userKey = $this->getCacheKey( $user );
481
482        if ( !$recache &&
483            $this->canUseCachedValues( $user, self::CACHE_PRIVILEGED, $queryFlags ) &&
484            isset( $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED] )
485        ) {
486            return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
487        }
488
489        if ( !$user->isRegistered() ) {
490            return [];
491        }
492
493        $groups = array_intersect(
494            $this->getUserEffectiveGroups( $user, $queryFlags, $recache ),
495            $this->options->get( 'PrivilegedGroups' )
496        );
497
498        $this->hookRunner->onUserPrivilegedGroups( $user, $groups );
499
500        $this->setCache(
501            $this->getCacheKey( $user ),
502            self::CACHE_PRIVILEGED,
503            array_values( array_unique( $groups ) ),
504            $queryFlags
505        );
506
507        return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
508    }
509
510    /**
511     * Recursively check a condition.  Conditions are in the form
512     *   [ '&' or '|' or '^' or '!', cond1, cond2, ... ]
513     * where cond1, cond2, ... are themselves conditions; *OR*
514     *   APCOND_EMAILCONFIRMED, *OR*
515     *   [ APCOND_EMAILCONFIRMED ], *OR*
516     *   [ APCOND_EDITCOUNT, number of edits ], *OR*
517     *   [ APCOND_AGE, seconds since registration ], *OR*
518     *   similar constructs defined by extensions.
519     * This function evaluates the former type recursively, and passes off to
520     * checkCondition for evaluation of the latter type.
521     *
522     * If you change the logic of this method, please update
523     * ApiQuerySiteinfo::appendAutoPromote(), as it depends on this method.
524     *
525     * @param mixed $cond A condition, possibly containing other conditions
526     * @param User $user The user to check the conditions against
527     *
528     * @return bool Whether the condition is true
529     */
530    private function recCheckCondition( $cond, User $user ): bool {
531        if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], self::VALID_OPS ) ) {
532            // Recursive condition
533            if ( $cond[0] == '&' ) { // AND (all conds pass)
534                foreach ( array_slice( $cond, 1 ) as $subcond ) {
535                    if ( !$this->recCheckCondition( $subcond, $user ) ) {
536                        return false;
537                    }
538                }
539
540                return true;
541            } elseif ( $cond[0] == '|' ) { // OR (at least one cond passes)
542                foreach ( array_slice( $cond, 1 ) as $subcond ) {
543                    if ( $this->recCheckCondition( $subcond, $user ) ) {
544                        return true;
545                    }
546                }
547
548                return false;
549            } elseif ( $cond[0] == '^' ) { // XOR (exactly one cond passes)
550                if ( count( $cond ) > 3 ) {
551                    $this->logger->warning(
552                        'recCheckCondition() given XOR ("^") condition on three or more conditions.' .
553                        ' Check your $wgAutopromote and $wgAutopromoteOnce settings.'
554                    );
555                }
556                return $this->recCheckCondition( $cond[1], $user )
557                    xor $this->recCheckCondition( $cond[2], $user );
558            } elseif ( $cond[0] == '!' ) { // NOT (no conds pass)
559                foreach ( array_slice( $cond, 1 ) as $subcond ) {
560                    if ( $this->recCheckCondition( $subcond, $user ) ) {
561                        return false;
562                    }
563                }
564
565                return true;
566            }
567        }
568        // If we got here, the array presumably does not contain other conditions;
569        // it's not recursive. Pass it off to checkCondition.
570        if ( !is_array( $cond ) ) {
571            $cond = [ $cond ];
572        }
573
574        return $this->checkCondition( $cond, $user );
575    }
576
577    /**
578     * As recCheckCondition, but *not* recursive.  The only valid conditions
579     * are those whose first element is one of APCOND_* defined in Defines.php.
580     * Other types will throw an exception if no extension evaluates them.
581     *
582     * @param array $cond A condition, which must not contain other conditions
583     * @param User $user The user to check the condition against
584     * @return bool Whether the condition is true for the user
585     * @throws InvalidArgumentException if autopromote condition was not recognized.
586     */
587    private function checkCondition( array $cond, User $user ): bool {
588        if ( count( $cond ) < 1 ) {
589            return false;
590        }
591
592        switch ( $cond[0] ) {
593            case APCOND_EMAILCONFIRMED:
594                if ( Sanitizer::validateEmail( $user->getEmail() ) ) {
595                    if ( $this->options->get( MainConfigNames::EmailAuthentication ) ) {
596                        return (bool)$user->getEmailAuthenticationTimestamp();
597                    } else {
598                        return true;
599                    }
600                }
601                return false;
602            case APCOND_EDITCOUNT:
603                $reqEditCount = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmCount );
604
605                // T157718: Avoid edit count lookup if specified edit count is 0 or invalid
606                if ( $reqEditCount <= 0 ) {
607                    return true;
608                }
609                return (int)$this->userEditTracker->getUserEditCount( $user ) >= $reqEditCount;
610            case APCOND_AGE:
611                $reqAge = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmAge );
612                $age = time() - (int)wfTimestampOrNull( TS_UNIX, $user->getRegistration() );
613                return $age >= $reqAge;
614            case APCOND_AGE_FROM_EDIT:
615                $age = time() - (int)wfTimestampOrNull(
616                    TS_UNIX, $this->userEditTracker->getFirstEditTimestamp( $user ) );
617                return $age >= $cond[1];
618            case APCOND_INGROUPS:
619                $groups = array_slice( $cond, 1 );
620                return count( array_intersect( $groups, $this->getUserGroups( $user ) ) ) == count( $groups );
621            case APCOND_ISIP:
622                return $cond[1] == $user->getRequest()->getIP();
623            case APCOND_IPINRANGE:
624                return IPUtils::isInRange( $user->getRequest()->getIP(), $cond[1] );
625            case APCOND_BLOCKED:
626                // Because checking for ipblock-exempt leads back to here (thus infinite recursion),
627                // we stop checking for ipblock-exempt via here. We do this by setting the second
628                // param to true.
629                // See T270145.
630                $block = $user->getBlock( IDBAccessObject::READ_LATEST, true );
631                return $block && $block->isSitewide();
632            case APCOND_ISBOT:
633                return in_array( 'bot', $this->groupPermissionsLookup
634                    ->getGroupPermissions( $this->getUserGroups( $user ) ) );
635            default:
636                $result = null;
637                $this->hookRunner->onAutopromoteCondition( $cond[0],
638                    // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
639                    array_slice( $cond, 1 ), $user, $result );
640                if ( $result === null ) {
641                    throw new InvalidArgumentException(
642                        "Unrecognized condition {$cond[0]} for autopromotion!"
643                    );
644                }
645
646                return (bool)$result;
647        }
648    }
649
650    /**
651     * Add the user to the group if he/she meets given criteria.
652     *
653     * Contrary to autopromotion by $wgAutopromote, the group will be
654     *   possible to remove manually via Special:UserRights. In such case it
655     *   will not be re-added automatically. The user will also not lose the
656     *   group if they no longer meet the criteria.
657     *
658     * @param UserIdentity $user User to add to the groups
659     * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria)
660     *
661     * @return string[] Array of groups the user has been promoted to.
662     *
663     * @see $wgAutopromoteOnce
664     */
665    public function addUserToAutopromoteOnceGroups(
666        UserIdentity $user,
667        string $event
668    ): array {
669        $user->assertWiki( $this->wikiId );
670        Assert::precondition(
671            !$this->wikiId || WikiMap::isCurrentWikiDbDomain( $this->wikiId ),
672            __METHOD__ . " is not supported for foreign wikis: {$this->wikiId} used"
673        );
674
675        if (
676            $this->readOnlyMode->isReadOnly( $this->wikiId ) ||
677            !$user->isRegistered() ||
678            $this->tempUserConfig->isTempName( $user->getName() )
679        ) {
680            return [];
681        }
682
683        $toPromote = $this->getUserAutopromoteOnceGroups( $user, $event );
684        if ( $toPromote === [] ) {
685            return [];
686        }
687
688        $userObj = User::newFromIdentity( $user );
689        if ( !$userObj->checkAndSetTouched() ) {
690            return []; // raced out (bug T48834)
691        }
692
693        $oldGroups = $this->getUserGroups( $user ); // previous groups
694        $oldUGMs = $this->getUserGroupMemberships( $user );
695        $this->addUserToMultipleGroups( $user, $toPromote );
696        $newGroups = array_merge( $oldGroups, $toPromote ); // all groups
697        $newUGMs = $this->getUserGroupMemberships( $user );
698
699        // update groups in external authentication database
700        // TODO: deprecate passing full User object to hook
701        $this->hookRunner->onUserGroupsChanged(
702            $userObj,
703            $toPromote, [],
704            false,
705            false,
706            $oldUGMs,
707            $newUGMs
708        );
709
710        $logEntry = new ManualLogEntry( 'rights', 'autopromote' );
711        $logEntry->setPerformer( $user );
712        $logEntry->setTarget( $userObj->getUserPage() );
713        $logEntry->setParameters( [
714            '4::oldgroups' => $oldGroups,
715            '5::newgroups' => $newGroups,
716        ] );
717        $logid = $logEntry->insert();
718        if ( $this->options->get( MainConfigNames::AutopromoteOnceLogInRC ) ) {
719            $logEntry->publish( $logid );
720        }
721
722        return $toPromote;
723    }
724
725    /**
726     * Get the list of explicit group memberships this user has.
727     * The implicit * and user groups are not included.
728     *
729     * @param UserIdentity $user
730     * @param int $queryFlags
731     * @return string[]
732     */
733    public function getUserGroups(
734        UserIdentity $user,
735        int $queryFlags = IDBAccessObject::READ_NORMAL
736    ): array {
737        return array_keys( $this->getUserGroupMemberships( $user, $queryFlags ) );
738    }
739
740    /**
741     * Loads and returns UserGroupMembership objects for all the groups a user currently
742     * belongs to.
743     *
744     * @param UserIdentity $user the user to search for
745     * @param int $queryFlags
746     * @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object)
747     */
748    public function getUserGroupMemberships(
749        UserIdentity $user,
750        int $queryFlags = IDBAccessObject::READ_NORMAL
751    ): array {
752        $user->assertWiki( $this->wikiId );
753        $userKey = $this->getCacheKey( $user );
754
755        if ( $this->canUseCachedValues( $user, self::CACHE_MEMBERSHIP, $queryFlags ) &&
756            isset( $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP] )
757        ) {
758            /** @suppress PhanTypeMismatchReturn */
759            return $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP];
760        }
761
762        if ( !$user->isRegistered() ) {
763            // Anon users don't have groups stored in the database
764            return [];
765        }
766
767        $queryBuilder = $this->newQueryBuilder( $this->getDBConnectionRefForQueryFlags( $queryFlags ) );
768        $res = $queryBuilder
769            ->where( [ 'ug_user' => $user->getId( $this->wikiId ) ] )
770            ->caller( __METHOD__ )
771            ->fetchResultSet();
772
773        $ugms = [];
774        foreach ( $res as $row ) {
775            $ugm = $this->newGroupMembershipFromRow( $row );
776            if ( !$ugm->isExpired() ) {
777                $ugms[$ugm->getGroup()] = $ugm;
778            }
779        }
780        ksort( $ugms );
781
782        $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $ugms, $queryFlags );
783
784        return $ugms;
785    }
786
787    /**
788     * Add the user to the given group. This takes immediate effect.
789     * If the user is already in the group, the expiry time will be updated to the new
790     * expiry time. (If $expiry is omitted or null, the membership will be altered to
791     * never expire.)
792     *
793     * @param UserIdentity $user
794     * @param string $group Name of the group to add
795     * @param string|null $expiry Optional expiry timestamp in any format acceptable to
796     *   wfTimestamp(), or null if the group assignment should not expire
797     * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
798     *
799     * @throws InvalidArgumentException
800     * @return bool
801     */
802    public function addUserToGroup(
803        UserIdentity $user,
804        string $group,
805        string $expiry = null,
806        bool $allowUpdate = false
807    ): bool {
808        $user->assertWiki( $this->wikiId );
809        if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
810            return false;
811        }
812
813        $isTemp = $this->tempUserConfig->isTempName( $user->getName() );
814        if ( !$user->isRegistered() ) {
815            throw new InvalidArgumentException(
816                'UserGroupManager::addUserToGroup() needs a positive user ID. ' .
817                'Perhaps addUserToGroup() was called before the user was added to the database.'
818            );
819        }
820        if ( $isTemp ) {
821            throw new InvalidArgumentException(
822                'UserGroupManager::addUserToGroup() cannot be called on a temporary user.'
823            );
824        }
825
826        if ( $expiry ) {
827            $expiry = wfTimestamp( TS_MW, $expiry );
828        }
829
830        // TODO: Deprecate passing out user object in the hook by introducing
831        // an alternative hook
832        if ( $this->hookContainer->isRegistered( 'UserAddGroup' ) ) {
833            $userObj = User::newFromIdentity( $user );
834            $userObj->load();
835            if ( !$this->hookRunner->onUserAddGroup( $userObj, $group, $expiry ) ) {
836                return false;
837            }
838        }
839
840        $oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST );
841        $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
842
843        $dbw->startAtomic( __METHOD__ );
844        $dbw->newInsertQueryBuilder()
845            ->insertInto( 'user_groups' )
846            ->ignore()
847            ->row( [
848                'ug_user' => $user->getId( $this->wikiId ),
849                'ug_group' => $group,
850                'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null,
851            ] )
852            ->caller( __METHOD__ )->execute();
853
854        $affected = $dbw->affectedRows();
855        if ( !$affected ) {
856            // Conflicting row already exists; it should be overridden if it is either expired
857            // or if $allowUpdate is true and the current row is different than the loaded row.
858            $conds = [
859                'ug_user' => $user->getId( $this->wikiId ),
860                'ug_group' => $group
861            ];
862            if ( $allowUpdate ) {
863                // Update the current row if its expiry does not match that of the loaded row
864                $conds[] = $expiry
865                    ? $dbw->expr( 'ug_expiry', '=', null )
866                        ->or( 'ug_expiry', '!=', $dbw->timestamp( $expiry ) )
867                    : $dbw->expr( 'ug_expiry', '!=', null );
868            } else {
869                // Update the current row if it is expired
870                $conds[] = $dbw->expr( 'ug_expiry', '<', $dbw->timestamp() );
871            }
872            $dbw->newUpdateQueryBuilder()
873                ->update( 'user_groups' )
874                ->set( [ 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null ] )
875                ->where( $conds )
876                ->caller( __METHOD__ )->execute();
877            $affected = $dbw->affectedRows();
878        }
879        $dbw->endAtomic( __METHOD__ );
880
881        // Purge old, expired memberships from the DB
882        DeferredUpdates::addCallableUpdate( function ( $fname ) {
883            $dbr = $this->dbProvider->getReplicaDatabase( $this->wikiId );
884            $hasExpiredRow = (bool)$dbr->newSelectQueryBuilder()
885                ->select( '1' )
886                ->from( 'user_groups' )
887                ->where( [ $dbr->expr( 'ug_expiry', '<', $dbr->timestamp() ) ] )
888                ->caller( $fname )
889                ->fetchField();