Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.98% |
482 / 497 |
|
70.00% |
21 / 30 |
CRAP | |
0.00% |
0 / 1 |
UserGroupManager | |
96.98% |
482 / 497 |
|
70.00% |
21 / 30 |
138 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
listAllGroups | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
listAllImplicitGroups | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
newGroupMembershipFromRow | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
loadGroupMembershipsFromArray | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
getUserImplicitGroups | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
7 | |||
getUserEffectiveGroups | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
5 | |||
getUserFormerGroups | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
getUserAutopromoteGroups | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
getUserAutopromoteOnceGroups | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
8.01 | |||
getUserPrivilegedGroups | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
5 | |||
recCheckCondition | |
85.19% |
23 / 27 |
|
0.00% |
0 / 1 |
16.83 | |||
checkCondition | |
97.67% |
42 / 43 |
|
0.00% |
0 / 1 |
17 | |||
addUserToAutopromoteOnceGroups | |
97.44% |
38 / 39 |
|
0.00% |
0 / 1 |
8 | |||
getUserGroups | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getUserGroupMemberships | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
6 | |||
addUserToGroup | |
96.05% |
73 / 76 |
|
0.00% |
0 / 1 |
16 | |||
addUserToMultipleGroups | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
removeUserFromGroup | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
7 | |||
newQueryBuilder | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
purgeExpired | |
97.44% |
38 / 39 |
|
0.00% |
0 / 1 |
5 | |||
expandChangeableGroupConfig | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
4.37 | |||
getGroupsChangeableByGroup | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
getGroupsChangeableBy | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
3 | |||
clearCache | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
setCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
clearUserCacheForKind | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getDBConnectionRefForQueryFlags | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getCacheKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
canUseCachedValues | |
85.71% |
6 / 7 |
|
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 | |
21 | namespace MediaWiki\User; |
22 | |
23 | use IDBAccessObject; |
24 | use InvalidArgumentException; |
25 | use JobQueueGroup; |
26 | use ManualLogEntry; |
27 | use MediaWiki\Config\ServiceOptions; |
28 | use MediaWiki\Deferred\DeferredUpdates; |
29 | use MediaWiki\HookContainer\HookContainer; |
30 | use MediaWiki\HookContainer\HookRunner; |
31 | use MediaWiki\MainConfigNames; |
32 | use MediaWiki\Parser\Sanitizer; |
33 | use MediaWiki\Permissions\Authority; |
34 | use MediaWiki\Permissions\GroupPermissionsLookup; |
35 | use MediaWiki\User\TempUser\TempUserConfig; |
36 | use MediaWiki\WikiMap\WikiMap; |
37 | use Psr\Log\LoggerInterface; |
38 | use UserGroupExpiryJob; |
39 | use Wikimedia\Assert\Assert; |
40 | use Wikimedia\IPUtils; |
41 | use Wikimedia\Rdbms\IConnectionProvider; |
42 | use Wikimedia\Rdbms\ILBFactory; |
43 | use Wikimedia\Rdbms\IReadableDatabase; |
44 | use Wikimedia\Rdbms\ReadOnlyMode; |
45 | use Wikimedia\Rdbms\SelectQueryBuilder; |
46 | |
47 | /** |
48 | * Manages user groups. |
49 | * @since 1.35 |
50 | */ |
51 | class 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(); |