Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.05% |
494 / 509 |
|
70.00% |
21 / 30 |
CRAP | |
0.00% |
0 / 1 |
UserGroupManager | |
97.05% |
494 / 509 |
|
70.00% |
21 / 30 |
140 | |
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 | |
98.04% |
50 / 51 |
|
0.00% |
0 / 1 |
18 | |||
addUserToAutopromoteOnceGroups | |
97.67% |
42 / 43 |
|
0.00% |
0 / 1 |
9 | |||
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 InvalidArgumentException; |
24 | use JobQueueGroup; |
25 | use LogicException; |
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\IDBAccessObject; |
43 | use Wikimedia\Rdbms\ILBFactory; |
44 | use Wikimedia\Rdbms\IReadableDatabase; |
45 | use Wikimedia\Rdbms\ReadOnlyMode; |
46 | use Wikimedia\Rdbms\SelectQueryBuilder; |
47 | |
48 | /** |
49 | * Manages user groups. |
50 | * @since 1.35 |
51 | */ |
52 | class UserGroupManager { |
53 | |
54 | /** |
55 | * @internal For use by ServiceWiring |
56 | */ |
57 | public const CONSTRUCTOR_OPTIONS = [ |
58 | MainConfigNames::AddGroups, |
59 | MainConfigNames::AutoConfirmAge, |
60 | MainConfigNames::AutoConfirmCount, |
61 | MainConfigNames::Autopromote, |
62 | MainConfigNames::AutopromoteOnce, |
63 | MainConfigNames::AutopromoteOnceLogInRC, |
64 | MainConfigNames::AutopromoteOnceRCExcludedGroups, |
65 | MainConfigNames::EmailAuthentication, |
66 | MainConfigNames::ImplicitGroups, |
67 | MainConfigNames::GroupInheritsPermissions, |
68 | MainConfigNames::GroupPermissions, |
69 | MainConfigNames::GroupsAddToSelf, |
70 | MainConfigNames::GroupsRemoveFromSelf, |
71 | MainConfigNames::RevokePermissions, |
72 | MainConfigNames::RemoveGroups, |
73 | MainConfigNames::PrivilegedGroups, |
74 | ]; |
75 | |
76 | /** |
77 | * Logical operators recognized in $wgAutopromote. |
78 | * |
79 | * @since 1.42 |
80 | */ |
81 | public const VALID_OPS = [ '&', '|', '^', '!' ]; |
82 | |
83 | private ServiceOptions $options; |
84 | private IConnectionProvider $dbProvider; |
85 | private HookContainer $hookContainer; |
86 | private HookRunner $hookRunner; |
87 | private ReadOnlyMode $readOnlyMode; |
88 | private UserEditTracker $userEditTracker; |
89 | private GroupPermissionsLookup $groupPermissionsLookup; |
90 | private JobQueueGroup $jobQueueGroup; |
91 | private LoggerInterface $logger; |
92 | private TempUserConfig $tempUserConfig; |
93 | |
94 | /** @var callable[] */ |
95 | private $clearCacheCallbacks; |
96 | |
97 | /** @var string|false */ |
98 | private $wikiId; |
99 | |
100 | /** string key for implicit groups cache */ |
101 | private const CACHE_IMPLICIT = 'implicit'; |
102 | |
103 | /** string key for effective groups cache */ |
104 | private const CACHE_EFFECTIVE = 'effective'; |
105 | |
106 | /** string key for group memberships cache */ |
107 | private const CACHE_MEMBERSHIP = 'membership'; |
108 | |
109 | /** string key for former groups cache */ |
110 | private const CACHE_FORMER = 'former'; |
111 | |
112 | /** string key for former groups cache */ |
113 | private const CACHE_PRIVILEGED = 'privileged'; |
114 | |
115 | /** |
116 | * @var array Service caches, an assoc. array keyed after the user-keys generated |
117 | * by the getCacheKey method and storing values in the following format: |
118 | * |
119 | * userKey => [ |
120 | * self::CACHE_IMPLICIT => implicit groups cache |
121 | * self::CACHE_EFFECTIVE => effective groups cache |
122 | * self::CACHE_MEMBERSHIP => [ ] // Array of UserGroupMembership objects |
123 | * self::CACHE_FORMER => former groups cache |
124 | * self::CACHE_PRIVILEGED => privileged groups cache |
125 | * ] |
126 | */ |
127 | private $userGroupCache = []; |
128 | |
129 | /** |
130 | * @var array An assoc. array that stores query flags used to retrieve user groups |
131 | * from the database and is stored in the following format: |
132 | * |
133 | * userKey => [ |
134 | * self::CACHE_IMPLICIT => implicit groups query flag |
135 | * self::CACHE_EFFECTIVE => effective groups query flag |
136 | * self::CACHE_MEMBERSHIP => membership groups query flag |
137 | * self::CACHE_FORMER => former groups query flag |
138 | * self::CACHE_PRIVILEGED => privileged groups query flag |
139 | * ] |
140 | */ |
141 | private $queryFlagsUsedForCaching = []; |
142 | |
143 | /** |
144 | * @internal For use preventing an infinite loop when checking APCOND_BLOCKED |
145 | * @var array An assoc. array mapping the getCacheKey userKey to a bool indicating |
146 | * an ongoing condition check. |
147 | */ |
148 | private $recursionMap = []; |
149 | |
150 | /** |
151 | * @param ServiceOptions $options |
152 | * @param ReadOnlyMode $readOnlyMode |
153 | * @param ILBFactory $lbFactory |
154 | * @param HookContainer $hookContainer |
155 | * @param UserEditTracker $userEditTracker |
156 | * @param GroupPermissionsLookup $groupPermissionsLookup |
157 | * @param JobQueueGroup $jobQueueGroup |
158 | * @param LoggerInterface $logger |
159 | * @param TempUserConfig $tempUserConfig |
160 | * @param callable[] $clearCacheCallbacks |
161 | * @param string|false $wikiId |
162 | */ |
163 | public function __construct( |
164 | ServiceOptions $options, |
165 | ReadOnlyMode $readOnlyMode, |
166 | ILBFactory $lbFactory, |
167 | HookContainer $hookContainer, |
168 | UserEditTracker $userEditTracker, |
169 | GroupPermissionsLookup $groupPermissionsLookup, |
170 | JobQueueGroup $jobQueueGroup, |
171 | LoggerInterface $logger, |
172 | TempUserConfig $tempUserConfig, |
173 | array $clearCacheCallbacks = [], |
174 | $wikiId = UserIdentity::LOCAL |
175 | ) { |
176 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
177 | $this->options = $options; |
178 | $this->dbProvider = $lbFactory; |
179 | $this->hookContainer = $hookContainer; |
180 | $this->hookRunner = new HookRunner( $hookContainer ); |
181 | $this->userEditTracker = $userEditTracker; |
182 | $this->groupPermissionsLookup = $groupPermissionsLookup; |
183 | $this->jobQueueGroup = $jobQueueGroup; |
184 | $this->logger = $logger; |
185 | $this->tempUserConfig = $tempUserConfig; |
186 | $this->readOnlyMode = $readOnlyMode; |
187 | $this->clearCacheCallbacks = $clearCacheCallbacks; |
188 | $this->wikiId = $wikiId; |
189 | } |
190 | |
191 | /** |
192 | * Return the set of defined explicit groups. |
193 | * The implicit groups (by default *, 'user' and 'autoconfirmed') |
194 | * are not included, as they are defined automatically, not in the database. |
195 | * @return string[] internal group names |
196 | */ |
197 | public function listAllGroups(): array { |
198 | return array_values( array_unique( |
199 | array_diff( |
200 | array_merge( |
201 | array_keys( $this->options->get( MainConfigNames::GroupPermissions ) ), |
202 | array_keys( $this->options->get( MainConfigNames::RevokePermissions ) ), |
203 | array_keys( $this->options->get( MainConfigNames::GroupInheritsPermissions ) ) |
204 | ), |
205 | $this->listAllImplicitGroups() |
206 | ) |
207 | ) ); |
208 | } |
209 | |
210 | /** |
211 | * Get a list of all configured implicit groups |
212 | * @return string[] |
213 | */ |
214 | public function listAllImplicitGroups(): array { |
215 | return $this->options->get( MainConfigNames::ImplicitGroups ); |
216 | } |
217 | |
218 | /** |
219 | * Creates a new UserGroupMembership instance from $row. |
220 | * The fields required to build an instance could be |
221 | * found using getQueryInfo() method. |
222 | * |
223 | * @param \stdClass $row A database result object |
224 | * |
225 | * @return UserGroupMembership |
226 | */ |
227 | public function newGroupMembershipFromRow( \stdClass $row ): UserGroupMembership { |
228 | return new UserGroupMembership( |
229 | (int)$row->ug_user, |
230 | $row->ug_group, |
231 | $row->ug_expiry === null ? null : wfTimestamp( |
232 | TS_MW, |
233 | $row->ug_expiry |
234 | ) |
235 | ); |
236 | } |
237 | |
238 | /** |
239 | * Load the user groups cache from the provided user groups data |
240 | * @internal for use by the User object only |
241 | * @param UserIdentity $user |
242 | * @param array $userGroups an array of database query results |
243 | * @param int $queryFlags |
244 | */ |
245 | public function loadGroupMembershipsFromArray( |
246 | UserIdentity $user, |
247 | array $userGroups, |
248 | int $queryFlags = IDBAccessObject::READ_NORMAL |
249 | ) { |
250 | $user->assertWiki( $this->wikiId ); |
251 | $membershipGroups = []; |
252 | reset( $userGroups ); |
253 | foreach ( $userGroups as $row ) { |
254 | $ugm = $this->newGroupMembershipFromRow( $row ); |
255 | $membershipGroups[ $ugm->getGroup() ] = $ugm; |
256 | } |
257 | $this->setCache( |
258 | $this->getCacheKey( $user ), |
259 | self::CACHE_MEMBERSHIP, |
260 | $membershipGroups, |
261 | $queryFlags |
262 | ); |
263 | } |
264 | |
265 | /** |
266 | * Get the list of implicit group memberships this user has. |
267 | * |
268 | * This includes 'user' if logged in, '*' for all accounts, |
269 | * and autopromoted groups |
270 | * |
271 | * @param UserIdentity $user |
272 | * @param int $queryFlags |
273 | * @param bool $recache Whether to avoid the cache |
274 | * @return string[] internal group names |
275 | */ |
276 | public function getUserImplicitGroups( |
277 | UserIdentity $user, |
278 | int $queryFlags = IDBAccessObject::READ_NORMAL, |
279 | bool $recache = false |
280 | ): array { |
281 | $user->assertWiki( $this->wikiId ); |
282 | $userKey = $this->getCacheKey( $user ); |
283 | if ( $recache || |
284 | !isset( $this->userGroupCache[$userKey][self::CACHE_IMPLICIT] ) || |
285 | !$this->canUseCachedValues( $user, self::CACHE_IMPLICIT, $queryFlags ) |
286 | ) { |
287 | $groups = [ '*' ]; |
288 | if ( $this->tempUserConfig->isTempName( $user->getName() ) ) { |
289 | $groups[] = 'temp'; |
290 | } elseif ( $user->isRegistered() ) { |
291 | $groups[] = 'user'; |
292 | $groups = array_unique( array_merge( |
293 | $groups, |
294 | $this->getUserAutopromoteGroups( $user ) |
295 | ) ); |
296 | } |
297 | $this->setCache( $userKey, self::CACHE_IMPLICIT, $groups, $queryFlags ); |
298 | if ( $recache ) { |
299 | // Assure data consistency with rights/groups, |
300 | // as getUserEffectiveGroups() depends on this function |
301 | $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE ); |
302 | } |
303 | } |
304 | return $this->userGroupCache[$userKey][self::CACHE_IMPLICIT]; |
305 | } |
306 | |
307 | /** |
308 | * Get the list of implicit group memberships the user has. |
309 | * |
310 | * This includes all explicit groups, plus 'user' if logged in, |
311 | * '*' for all accounts, and autopromoted groups |
312 | * |
313 | * @param UserIdentity $user |
314 | * @param int $queryFlags |
315 | * @param bool $recache Whether to avoid the cache |
316 | * @return string[] internal group names |
317 | */ |
318 | public function getUserEffectiveGroups( |
319 | UserIdentity $user, |
320 | int $queryFlags = IDBAccessObject::READ_NORMAL, |
321 | bool $recache = false |
322 | ): array { |
323 | $user->assertWiki( $this->wikiId ); |
324 | $userKey = $this->getCacheKey( $user ); |
325 | // Ignore cache if the $recache flag is set, cached values can not be used |
326 | // or the cache value is missing |
327 | if ( $recache || |
328 | !$this->canUseCachedValues( $user, self::CACHE_EFFECTIVE, $queryFlags ) || |
329 | !isset( $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE] ) |
330 | ) { |
331 | $groups = array_unique( array_merge( |
332 | $this->getUserGroups( $user, $queryFlags ), // explicit groups |
333 | $this->getUserImplicitGroups( $user, $queryFlags, $recache ) // implicit groups |
334 | ) ); |
335 | // TODO: Deprecate passing out user object in the hook by introducing |
336 | // an alternative hook |
337 | if ( $this->hookContainer->isRegistered( 'UserEffectiveGroups' ) ) { |
338 | $userObj = User::newFromIdentity( $user ); |
339 | $userObj->load(); |
340 | // Hook for additional groups |
341 | $this->hookRunner->onUserEffectiveGroups( $userObj, $groups ); |
342 | } |
343 | // Force reindexation of groups when a hook has unset one of them |
344 | $effectiveGroups = array_values( array_unique( $groups ) ); |
345 | $this->setCache( $userKey, self::CACHE_EFFECTIVE, $effectiveGroups, $queryFlags ); |
346 | } |
347 | return $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE]; |
348 | } |
349 | |
350 | /** |
351 | * Returns the groups the user has belonged to. |
352 | * |
353 | * The user may still belong to the returned groups. Compare with |
354 | * getUserGroups(). |
355 | * |
356 | * The function will not return groups the user had belonged to before MW 1.17 |
357 | * |
358 | * @param UserIdentity $user |
359 | * @param int $queryFlags |
360 | * @return string[] Names of the groups the user has belonged to. |
361 | */ |
362 | public function getUserFormerGroups( |
363 | UserIdentity $user, |
364 | int $queryFlags = IDBAccessObject::READ_NORMAL |
365 | ): array { |
366 | $user->assertWiki( $this->wikiId ); |
367 | $userKey = $this->getCacheKey( $user ); |
368 | |
369 | if ( $this->canUseCachedValues( $user, self::CACHE_FORMER, $queryFlags ) && |
370 | isset( $this->userGroupCache[$userKey][self::CACHE_FORMER] ) |
371 | ) { |
372 | return $this->userGroupCache[$userKey][self::CACHE_FORMER]; |
373 | } |
374 | |
375 | if ( !$user->isRegistered() ) { |
376 | // Anon users don't have groups stored in the database |
377 | return []; |
378 | } |
379 | |
380 | $res = $this->getDBConnectionRefForQueryFlags( $queryFlags )->newSelectQueryBuilder() |
381 | ->select( 'ufg_group' ) |
382 | ->from( 'user_former_groups' ) |
383 | ->where( [ 'ufg_user' => $user->getId( $this->wikiId ) ] ) |
384 | ->caller( __METHOD__ ) |
385 | ->fetchResultSet(); |
386 | $formerGroups = []; |
387 | foreach ( $res as $row ) { |
388 | $formerGroups[] = $row->ufg_group; |
389 | } |
390 | $this->setCache( $userKey, self::CACHE_FORMER, $formerGroups, $queryFlags ); |
391 | |
392 | return $this->userGroupCache[$userKey][self::CACHE_FORMER]; |
393 | } |
394 | |
395 | /** |
396 | * Get the groups for the given user based on $wgAutopromote. |
397 | * |
398 | * @param UserIdentity $user The user to get the groups for |
399 | * @return string[] Array of groups to promote to. |
400 | * |
401 | * @see $wgAutopromote |
402 | */ |
403 | public function getUserAutopromoteGroups( UserIdentity $user ): array { |
404 | $user->assertWiki( $this->wikiId ); |
405 | $promote = []; |
406 | // TODO: remove the need for the full user object |
407 | $userObj = User::newFromIdentity( $user ); |
408 | if ( $userObj->isTemp() ) { |
409 | return []; |
410 | } |
411 | foreach ( $this->options->get( MainConfigNames::Autopromote ) as $group => $cond ) { |
412 | if ( $this->recCheckCondition( $cond, $userObj ) ) { |
413 | $promote[] = $group; |
414 | } |
415 | } |
416 | |
417 | $this->hookRunner->onGetAutoPromoteGroups( $userObj, $promote ); |
418 | return $promote; |
419 | } |
420 | |
421 | /** |
422 | * Get the groups for the given user based on the given criteria. |
423 | * |
424 | * Does not return groups the user already belongs to or has once belonged. |
425 | * |
426 | * @param UserIdentity $user The user to get the groups for |
427 | * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria) |
428 | * |
429 | * @return string[] Groups the user should be promoted to. |
430 | * |
431 | * @see $wgAutopromoteOnce |
432 | */ |
433 | public function getUserAutopromoteOnceGroups( |
434 | UserIdentity $user, |
435 | string $event |
436 | ): array { |
437 | $user->assertWiki( $this->wikiId ); |
438 | $autopromoteOnce = $this->options->get( MainConfigNames::AutopromoteOnce ); |
439 | $promote = []; |
440 | |
441 | if ( isset( $autopromoteOnce[$event] ) && count( $autopromoteOnce[$event] ) ) { |
442 | // TODO: remove the need for the full user object |
443 | $userObj = User::newFromIdentity( $user ); |
444 | if ( $userObj->isTemp() ) { |
445 | return []; |
446 | } |
447 | $currentGroups = $this->getUserGroups( $user ); |
448 | $formerGroups = $this->getUserFormerGroups( $user ); |
449 | foreach ( $autopromoteOnce[$event] as $group => $cond ) { |
450 | // Do not check if the user's already a member |
451 | if ( in_array( $group, $currentGroups ) ) { |
452 | continue; |
453 | } |
454 | // Do not autopromote if the user has belonged to the group |
455 | if ( in_array( $group, $formerGroups ) ) { |
456 | continue; |
457 | } |
458 | // Finally - check the conditions |
459 | if ( $this->recCheckCondition( $cond, $userObj ) ) { |
460 | $promote[] = $group; |
461 | } |
462 | } |
463 | } |
464 | |
465 | return $promote; |
466 | } |
467 | |
468 | /** |
469 | * Returns the list of privileged groups that $user belongs to. |
470 | * Privileged groups are ones that can be abused in a dangerous way. |
471 | * |
472 | * Depending on how extensions extend this method, it might return values |
473 | * that are not strictly user groups (ACL list names, etc.). |
474 | * It is meant for logging/auditing, not for passing to methods that expect group names. |
475 | * |
476 | * @param UserIdentity $user |
477 | * @param int $queryFlags |
478 | * @param bool $recache Whether to avoid the cache |
479 | * @return string[] |
480 | * @since 1.41 (also backported to 1.39.5 and 1.40.1) |
481 | * @see $wgPrivilegedGroups |
482 | * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGetPrivilegedGroups |
483 | */ |
484 | public function getUserPrivilegedGroups( |
485 | UserIdentity $user, |
486 | int $queryFlags = IDBAccessObject::READ_NORMAL, |
487 | bool $recache = false |
488 | ): array { |
489 | $userKey = $this->getCacheKey( $user ); |
490 | |
491 | if ( !$recache && |
492 | $this->canUseCachedValues( $user, self::CACHE_PRIVILEGED, $queryFlags ) && |
493 | isset( $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED] ) |
494 | ) { |
495 | return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED]; |
496 | } |
497 | |
498 | if ( !$user->isRegistered() ) { |
499 | return []; |
500 | } |
501 | |
502 | $groups = array_intersect( |
503 | $this->getUserEffectiveGroups( $user, $queryFlags, $recache ), |
504 | $this->options->get( 'PrivilegedGroups' ) |
505 | ); |
506 | |
507 | $this->hookRunner->onUserPrivilegedGroups( $user, $groups ); |
508 | |
509 | $this->setCache( |
510 | $this->getCacheKey( $user ), |
511 | self::CACHE_PRIVILEGED, |
512 | array_values( array_unique( $groups ) ), |
513 | $queryFlags |
514 | ); |
515 | |
516 | return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED]; |
517 | } |
518 | |
519 | /** |
520 | * Recursively check a condition. Conditions are in the form |
521 | * [ '&' or '|' or '^' or '!', cond1, cond2, ... ] |
522 | * where cond1, cond2, ... are themselves conditions; *OR* |
523 | * APCOND_EMAILCONFIRMED, *OR* |
524 | * [ APCOND_EMAILCONFIRMED ], *OR* |
525 | * [ APCOND_EDITCOUNT, number of edits ], *OR* |
526 | * [ APCOND_AGE, seconds since registration ], *OR* |
527 | * similar constructs defined by extensions. |
528 | * This function evaluates the former type recursively, and passes off to |
529 | * checkCondition for evaluation of the latter type. |
530 | * |
531 | * If you change the logic of this method, please update |
532 | * ApiQuerySiteinfo::appendAutoPromote(), as it depends on this method. |
533 | * |
534 | * @param mixed $cond A condition, possibly containing other conditions |
535 | * @param User $user The user to check the conditions against |
536 | * |
537 | * @return bool Whether the condition is true |
538 | */ |
539 | private function recCheckCondition( $cond, User $user ): bool { |
540 | if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], self::VALID_OPS ) ) { |
541 | // Recursive condition |
542 | if ( $cond[0] == '&' ) { // AND (all conds pass) |
543 | foreach ( array_slice( $cond, 1 ) as $subcond ) { |
544 | if ( !$this->recCheckCondition( $subcond, $user ) ) { |
545 | return false; |
546 | } |
547 | } |
548 | |
549 | return true; |
550 | } elseif ( $cond[0] == '|' ) { // OR (at least one cond passes) |
551 | foreach ( array_slice( $cond, 1 ) as $subcond ) { |
552 | if ( $this->recCheckCondition( $subcond, $user ) ) { |
553 | return true; |
554 | } |
555 | } |
556 | |
557 | return false; |
558 | } elseif ( $cond[0] == '^' ) { // XOR (exactly one cond passes) |
559 | if ( count( $cond ) > 3 ) { |
560 | $this->logger->warning( |
561 | 'recCheckCondition() given XOR ("^") condition on three or more conditions.' . |
562 | ' Check your $wgAutopromote and $wgAutopromoteOnce settings.' |
563 | ); |
564 | } |
565 | return $this->recCheckCondition( $cond[1], $user ) |
566 | xor $this->recCheckCondition( $cond[2], $user ); |
567 | } elseif ( $cond[0] == '!' ) { // NOT (no conds pass) |
568 | foreach ( array_slice( $cond, 1 ) as $subcond ) { |
569 | if ( $this->recCheckCondition( $subcond, $user ) ) { |
570 | return false; |
571 | } |
572 | } |
573 | |
574 | return true; |
575 | } |
576 | } |
577 | // If we got here, the array presumably does not contain other conditions; |
578 | // it's not recursive. Pass it off to checkCondition. |
579 | if ( !is_array( $cond ) ) { |
580 | $cond = [ $cond ]; |
581 | } |
582 | |
583 | return $this->checkCondition( $cond, $user ); |
584 | } |
585 | |
586 | /** |
587 | * As recCheckCondition, but *not* recursive. The only valid conditions |
588 | * are those whose first element is one of APCOND_* defined in Defines.php. |
589 | * Other types will throw an exception if no extension evaluates them. |
590 | * |
591 | * @param array $cond A condition, which must not contain other conditions |
592 | * @param User $user The user to check the condition against |
593 | * @return bool Whether the condition is true for the user |
594 | * @throws InvalidArgumentException if autopromote condition was not recognized. |
595 | * @throws LogicException if APCOND_BLOCKED is checked again before returning a result. |
596 | */ |
597 | private function checkCondition( array $cond, User $user ): bool { |
598 | if ( count( $cond ) < 1 ) { |
599 | return false; |
600 | } |
601 | |
602 | switch ( $cond[0] ) { |
603 | case APCOND_EMAILCONFIRMED: |
604 | if ( Sanitizer::validateEmail( $user->getEmail() ) ) { |
605 | if ( $this->options->get( MainConfigNames::EmailAuthentication ) ) { |
606 | return (bool)$user->getEmailAuthenticationTimestamp(); |
607 | } else { |
608 | return true; |
609 | } |
610 | } |
611 | return false; |
612 | case APCOND_EDITCOUNT: |
613 | $reqEditCount = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmCount ); |
614 | |
615 | // T157718: Avoid edit count lookup if specified edit count is 0 or invalid |
616 | if ( $reqEditCount <= 0 ) { |
617 | return true; |
618 | } |
619 | return (int)$this->userEditTracker->getUserEditCount( $user ) >= $reqEditCount; |
620 | case APCOND_AGE: |
621 | $reqAge = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmAge ); |
622 | $age = time() - (int)wfTimestampOrNull( TS_UNIX, $user->getRegistration() ); |
623 | return $age >= $reqAge; |
624 | case APCOND_AGE_FROM_EDIT: |
625 | $age = time() - (int)wfTimestampOrNull( |
626 | TS_UNIX, $this->userEditTracker->getFirstEditTimestamp( $user ) ); |
627 | return $age >= $cond[1]; |
628 | case APCOND_INGROUPS: |
629 | $groups = array_slice( $cond, 1 ); |
630 | return count( array_intersect( $groups, $this->getUserGroups( $user ) ) ) == count( $groups ); |
631 | case APCOND_ISIP: |
632 | return $cond[1] == $user->getRequest()->getIP(); |
633 | case APCOND_IPINRANGE: |
634 | return IPUtils::isInRange( $user->getRequest()->getIP(), $cond[1] ); |
635 | case APCOND_BLOCKED: |
636 | // Because checking for ipblock-exempt leads back to here (thus infinite recursion), |
637 | // we if we've been here before for this user without having returned a value. |
638 | // See T270145 and T349608 |
639 | $userKey = $this->getCacheKey( $user ); |
640 | if ( $this->recursionMap[$userKey] ?? false ) { |
641 | throw new LogicException( |
642 | "Unexpected recursion! APCOND_BLOCKED is being checked during" . |
643 | " an existing APCOND_BLOCKED check for \"{$user->getName()}\"!" |
644 | ); |
645 | } |
646 | $this->recursionMap[$userKey] = true; |
647 | // Setting the second parameter here to true prevents us from getting back here |
648 | // during standard MediaWiki core behavior |
649 | $block = $user->getBlock( IDBAccessObject::READ_LATEST, true ); |
650 | $this->recursionMap[$userKey] = false; |
651 | |
652 | return $block && $block->isSitewide(); |
653 | case APCOND_ISBOT: |
654 | return in_array( 'bot', $this->groupPermissionsLookup |
655 | ->getGroupPermissions( $this->getUserGroups( $user ) ) ); |
656 | default: |
657 | $result = null; |
658 | $this->hookRunner->onAutopromoteCondition( $cond[0], |
659 | // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args |
660 | array_slice( $cond, 1 ), $user, $result ); |
661 | if ( $result === null ) { |
662 | throw new InvalidArgumentException( |
663 | "Unrecognized condition {$cond[0]} for autopromotion!" |
664 | ); |
665 | } |
666 | |
667 | return (bool)$result; |
668 | } |
669 | } |
670 | |
671 | /** |
672 | * Add the user to the group if he/she meets given criteria. |
673 | * |
674 | * Contrary to autopromotion by $wgAutopromote, the group will be |
675 | * possible to remove manually via Special:UserRights. In such case it |
676 | * will not be re-added automatically. The user will also not lose the |
677 | * group if they no longer meet the criteria. |
678 | * |
679 | * @param UserIdentity $user User to add to the groups |
680 | * @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria) |
681 | * |
682 | * @return string[] Array of groups the user has been promoted to. |
683 | * |
684 | * @see $wgAutopromoteOnce |
685 | */ |
686 | public function addUserToAutopromoteOnceGroups( |
687 | UserIdentity $user, |
688 | string $event |
689 | ): array { |
690 | $user->assertWiki( $this->wikiId ); |
691 | Assert::precondition( |
692 | !$this->wikiId || WikiMap::isCurrentWikiDbDomain( $this->wikiId ), |
693 | __METHOD__ . " is not supported for foreign wikis: {$this->wikiId} used" |
694 | ); |
695 | |
696 | if ( |
697 | $this->readOnlyMode->isReadOnly( $this->wikiId ) || |
698 | !$user->isRegistered() || |
699 | $this->tempUserConfig->isTempName( $user->getName() ) |
700 | ) { |
701 | return []; |
702 | } |
703 | |
704 | $toPromote = $this->getUserAutopromoteOnceGroups( $user, $event ); |
705 | if ( $toPromote === [] ) { |
706 | return []; |
707 | } |
708 | |
709 | $userObj = User::newFromIdentity( $user ); |
710 | if ( !$userObj->checkAndSetTouched() ) { |
711 | return []; // raced out (bug T48834) |
712 | } |
713 | |
714 | $oldGroups = $this->getUserGroups( $user ); // previous groups |
715 | $oldUGMs = $this->getUserGroupMemberships( $user ); |
716 | $this->addUserToMultipleGroups( $user, $toPromote ); |
717 | $newGroups = array_merge( $oldGroups, $toPromote ); // all groups |
718 | $newUGMs = $this->getUserGroupMemberships( $user ); |
719 | |
720 | // update groups in external authentication database |
721 | // TODO: deprecate passing full User object to hook |
722 | $this->hookRunner->onUserGroupsChanged( |
723 | $userObj, |
724 | $toPromote, [], |
725 | false, |
726 | false, |
727 | $oldUGMs, |
728 | $newUGMs |
729 | ); |
730 | |
731 | $logEntry = new ManualLogEntry( 'rights', 'autopromote' ); |
732 | $logEntry->setPerformer( $user ); |
733 | $logEntry->setTarget( $userObj->getUserPage() ); |
734 | $logEntry->setParameters( [ |
735 | '4::oldgroups' => $oldGroups, |
736 | '5::newgroups' => $newGroups, |
737 | ] ); |
738 | $logid = $logEntry->insert(); |
739 | |
740 | // Allow excluding autopromotions into select groups from RecentChanges (T377829). |
741 | $groupsToShowInRC = array_diff( |
742 | $toPromote, |
743 | $this->options->get( MainConfigNames::AutopromoteOnceRCExcludedGroups ) |
744 | ); |
745 | |
746 | if ( $this->options->get( MainConfigNames::AutopromoteOnceLogInRC ) && count( $groupsToShowInRC ) ) { |
747 | $logEntry->publish( $logid ); |
748 | } |
749 | |
750 | return $toPromote; |
751 | } |
752 | |
753 | /** |
754 | * Get the list of explicit group memberships this user has. |
755 | * The implicit * and user groups are not included. |
756 | * |
757 | * @param UserIdentity $user |
758 | * @param int $queryFlags |
759 | * @return string[] |
760 | */ |
761 | public function getUserGroups( |
762 | UserIdentity $user, |
763 | int $queryFlags = IDBAccessObject::READ_NORMAL |
764 | ): array { |
765 | return array_keys( $this->getUserGroupMemberships( $user, $queryFlags ) ); |
766 | } |
767 | |
768 | /** |
769 | * Loads and returns UserGroupMembership objects for all the groups a user currently |
770 | * belongs to. |
771 | * |
772 | * @param UserIdentity $user the user to search for |
773 | * @param int $queryFlags |
774 | * @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object) |
775 | */ |
776 | public function getUserGroupMemberships( |
777 | UserIdentity $user, |
778 | int $queryFlags = IDBAccessObject::READ_NORMAL |
779 | ): array { |
780 | $user->assertWiki( $this->wikiId ); |
781 | $userKey = $this->getCacheKey( $user ); |
782 | |
783 | if ( $this->canUseCachedValues( $user, self::CACHE_MEMBERSHIP, $queryFlags ) && |
784 | isset( $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP] ) |
785 | ) { |
786 | /** @suppress PhanTypeMismatchReturn */ |
787 | return $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP]; |
788 | } |
789 | |
790 | if ( !$user->isRegistered() ) { |
791 | // Anon users don't have groups stored in the database |
792 | return []; |
793 | } |
794 | |
795 | $queryBuilder = $this->newQueryBuilder( $this->getDBConnectionRefForQueryFlags( $queryFlags ) ); |
796 | $res = $queryBuilder |
797 | ->where( [ 'ug_user' => $user->getId( $this->wikiId ) ] ) |
798 | ->caller( __METHOD__ ) |
799 | ->fetchResultSet(); |
800 | |
801 | $ugms = []; |
802 | foreach ( $res as $row ) { |
803 | $ugm = $this->newGroupMembershipFromRow( $row ); |
804 | if ( !$ugm->isExpired() ) { |
805 | $ugms[$ugm->getGroup()] = $ugm; |
806 | } |
807 | } |
808 | ksort( $ugms ); |
809 | |
810 | $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $ugms, $queryFlags ); |
811 | |
812 | return $ugms; |
813 | } |
814 | |
815 | /** |
816 | * Add the user to the given group. This takes immediate effect. |
817 | * If the user is already in the group, the expiry time will be updated to the new |
818 | * expiry time. (If $expiry is omitted or null, the membership will be altered to |
819 | * never expire.) |
820 | * |
821 | * @param UserIdentity $user |
822 | * @param string $group Name of the group to add |
823 | * @param string|null $expiry Optional expiry timestamp in any format acceptable to |
824 | * wfTimestamp(), or null if the group assignment should not expire |
825 | * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT |
826 | * |
827 | * @throws InvalidArgumentException |
828 | * @return bool |
829 | */ |
830 | public function addUserToGroup( |
831 | UserIdentity $user, |
832 | string $group, |
833 | ?string $expiry = null, |
834 | bool $allowUpdate = false |
835 | ): bool { |
836 | $user->assertWiki( $this->wikiId ); |
837 | if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) { |
838 | return false; |
839 | } |
840 | |
841 | $isTemp = $this->tempUserConfig->isTempName( $user->getName() ); |
842 | if ( !$user->isRegistered() ) { |
843 | throw new InvalidArgumentException( |
844 | 'UserGroupManager::addUserToGroup() needs a positive user ID. ' . |
845 | 'Perhaps addUserToGroup() was called before the user was added to the database.' |
846 | ); |
847 | } |
848 | if ( $isTemp ) { |
849 | throw new InvalidArgumentException( |
850 | 'UserGroupManager::addUserToGroup() cannot be called on a temporary user.' |
851 | ); |
852 | } |
853 | |
854 | if ( $expiry ) { |
855 | $expiry = wfTimestamp( TS_MW, $expiry ); |
856 | } |
857 | |
858 | // TODO: Deprecate passing out user object in the hook by introducing |
859 | // an alternative hook |
860 | if ( $this->hookContainer->isRegistered( 'UserAddGroup' ) ) { |
861 | $userObj = User::newFromIdentity( $user ); |
862 | $userObj->load(); |
863 | if ( !$this->hookRunner->onUserAddGroup( $userObj, $group, $expiry ) ) { |
864 | return false; |
865 | } |
866 | } |
867 | |
868 | $oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST ); |
869 | $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId ); |
870 | |
871 | $dbw->startAtomic( __METHOD__ ); |
872 | $dbw->newInsertQueryBuilder() |
873 | ->insertInto( 'user_groups' ) |
874 | ->ignore() |
875 | ->row( [ |
876 | 'ug_user' => $user->getId( $this->wikiId ), |
877 | 'ug_group' => $group, |
878 | 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null, |
879 | ] ) |
880 | ->caller( __METHOD__ )->execute(); |
881 | |
882 | $affected = $dbw->affectedRows(); |
883 | if ( !$affected ) { |
884 | // Conflicting row already exists; it should be overridden if it is either expired |
885 | // or if $allowUpdate is true and the current row is different than the loaded row. |
886 | $conds = [ |
887 | 'ug_user' => $user->getId( $this->wikiId ), |
888 | 'ug_group' => $group |
889 | ]; |
890 | if ( $allowUpdate ) { |
891 | // Update the current row if its expiry does not match that of the loaded row |
892 | $conds[] = $expiry |
893 | ? $dbw->expr( 'ug_expiry', '=', null ) |
894 | ->or( 'ug_expiry', '!=', $dbw->timestamp( $expiry ) ) |
895 | : $dbw->expr( 'ug_expiry', '!=', null ); |
896 | } else { |
897 | // Update the current row if it is expired |
898 | $conds[] = $dbw->expr( 'ug_expiry', '<', $dbw->timestamp() ); |
899 | } |
900 | $dbw->newUpdateQueryBuilder() |
901 | ->update( 'user_groups' ) |
902 | ->set( [ 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null ] ) |
903 | ->where( $conds ) |
904 | ->caller( __METHOD__ )->execute(); |
905 | $affected = $dbw->affectedRows(); |
906 | } |
907 | $dbw->endAtomic( __METHOD__ ); |
908 | |
909 | // Purge old, expired memberships from the DB |
910 | DeferredUpdates::addCallableUpdate( function ( $fname ) { |
911 | $dbr = $this->dbProvider->getReplicaDatabase( $this->wikiId ); |
912 | $hasExpiredRow = (bool)$dbr->newSelectQueryBuilder() |
913 | ->select( '1' ) |
914 | ->from( 'user_groups' ) |
915 | ->where( [ $dbr->expr( 'ug_expiry', '<', $dbr->timestamp() ) ] ) |
916 | ->caller( $fname ) |
917 | ->fetchField(); |
918 | if ( $hasExpiredRow ) { |
919 | $this->jobQueueGroup->push( new UserGroupExpiryJob( [] ) ); |
920 | } |
921 | } ); |
922 | |
923 | if ( $affected > 0 ) { |
924 | $oldUgms[$group] = new UserGroupMembership( $user->getId( $this->wikiId ), $group, $expiry ); |
925 | if ( !$oldUgms[$group]->isExpired() ) { |
926 | $this->setCache( |
927 | $this->getCacheKey( $user ), |
928 | self::CACHE_MEMBERSHIP, |
929 | $oldUgms, |
930 | IDBAccessObject::READ_LATEST |
931 | ); |
932 | $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE ); |
933 | } |
934 | foreach ( $this->clearCacheCallbacks as $callback ) { |
935 | $callback( $user ); |
936 | } |
937 | return true; |
938 | } |
939 | return false; |
940 | } |
941 | |
942 | /** |
943 | * Add the user to the given list of groups. |
944 | * |
945 | * @since 1.37 |
946 | * |
947 | * @param UserIdentity $user |
948 | * @param string[] $groups Names of the groups to add |
949 | * @param string|null $expiry Optional expiry timestamp in any format acceptable to |
950 | * wfTimestamp(), or null if the group assignment should not expire |
951 | * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT |
952 | * |
953 | * @throws InvalidArgumentException |
954 | */ |
955 | public function addUserToMultipleGroups( |
956 | UserIdentity $user, |
957 | array $groups, |
958 | ?string $expiry = null, |
959 | bool $allowUpdate = false |
960 | ) { |
961 | foreach ( $groups as $group ) { |
962 | $this->addUserToGroup( $user, $group, $expiry, $allowUpdate ); |
963 | } |
964 | } |
965 | |
966 | /** |
967 | * Remove the user from the given group. This takes immediate effect. |
968 | * |
969 | * @param UserIdentity $user |
970 | * @param string $group Name of the group to remove |
971 | * @throws InvalidArgumentException |
972 | * @return bool |
973 | */ |
974 | public function removeUserFromGroup( UserIdentity $user, string $group ): bool { |
975 | $user->assertWiki( $this->wikiId ); |
976 | // TODO: Deprecate passing out user object in the hook by introducing |
977 | // an alternative hook |
978 | if ( $this->hookContainer->isRegistered( 'UserRemoveGroup' ) ) { |
979 | $userObj = User::newFromIdentity( $user ); |
980 | $userObj->load(); |
981 | if ( !$this->hookRunner->onUserRemoveGroup( $userObj, $group ) ) { |
982 | return false; |
983 | } |
984 | } |
985 | |
986 | if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) { |
987 | return false; |
988 | } |
989 | |
990 | if ( !$user->isRegistered() ) { |
991 | throw new InvalidArgumentException( |
992 | 'UserGroupManager::removeUserFromGroup() needs a positive user ID. ' . |
993 | 'Perhaps removeUserFromGroup() was called before the user was added to the database.' |
994 | ); |
995 | } |
996 | |
997 | $oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST ); |
998 | $oldFormerGroups = $this->getUserFormerGroups( $user, IDBAccessObject::READ_LATEST ); |
999 | $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId ); |
1000 | $dbw->newDeleteQueryBuilder() |
1001 | ->deleteFrom( 'user_groups' ) |
1002 | ->where( [ 'ug_user' => $user->getId( $this->wikiId ), 'ug_group' => $group ] ) |
1003 | ->caller( __METHOD__ )->execute(); |
1004 | |
1005 | if ( !$dbw->affectedRows() ) { |
1006 | return false; |
1007 | } |
1008 | // Remember that the user was in this group |
1009 | $dbw->newInsertQueryBuilder() |
1010 | ->insertInto( 'user_former_groups' ) |
1011 | ->ignore() |
1012 | ->row( [ 'ufg_user' => $user->getId( $this->wikiId ), 'ufg_group' => $group ] ) |
1013 | ->caller( __METHOD__ )->execute(); |
1014 | |
1015 | unset( $oldUgms[$group] ); |
1016 | $userKey = $this->getCacheKey( $user ); |
1017 | $this->setCache( $userKey, self::CACHE_MEMBERSHIP, $oldUgms, IDBAccessObject::READ_LATEST ); |
1018 | $oldFormerGroups[] = $group; |
1019 | $this->setCache( $userKey, self::CACHE_FORMER, $oldFormerGroups, IDBAccessObject::READ_LATEST ); |
1020 | $this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE ); |
1021 | foreach ( $this->clearCacheCallbacks as $callback ) { |
1022 | $callback( $user ); |
1023 | } |
1024 | return true; |
1025 | } |
1026 | |
1027 | /** |
1028 | * Return the query builder to build upon and query |
1029 | * |
1030 | * @param IReadableDatabase $db |
1031 | * @return SelectQueryBuilder |
1032 | * @internal |
1033 | */ |
1034 | public function newQueryBuilder( IReadableDatabase $db ): SelectQueryBuilder { |
1035 | return $db->newSelectQueryBuilder() |
1036 | ->select( [ |
1037 | 'ug_user', |
1038 | 'ug_group', |
1039 | 'ug_expiry', |
1040 | ] ) |
1041 | ->from( 'user_groups' ); |
1042 | } |
1043 | |
1044 | /** |
1045 | * Purge expired memberships from the user_groups table |
1046 | * @internal |
1047 | * @note this could be slow and is intended for use in a background job |
1048 | * @return int|false false if purging wasn't attempted (e.g. because of |
1049 | * readonly), the number of rows purged (might be 0) otherwise |
1050 | */ |
1051 | public function purgeExpired() { |
1052 | if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) { |
1053 | return false; |
1054 | } |
1055 | |
1056 | $ticket = $this->dbProvider->getEmptyTransactionTicket( __METHOD__ ); |
1057 | $dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId ); |
1058 | |
1059 | $lockKey = "{$dbw->getDomainID()}:UserGroupManager:purge"; // per-wiki |
1060 | $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 ); |
1061 | if ( !$scopedLock ) { |
1062 | return false; // already running |
1063 | } |
1064 | |
1065 | $now = time(); |
1066 | $purgedRows = 0; |
1067 | do { |
1068 | $dbw->startAtomic( __METHOD__ ); |
1069 | $res = $this->newQueryBuilder( $dbw ) |
1070 | ->where( [ $dbw->expr( 'ug_expiry', '<', $dbw->timestamp( $now ) ) ] ) |
1071 | ->forUpdate() |
1072 | ->limit( 100 ) |
1073 | ->caller( __METHOD__ ) |
1074 | ->fetchResultSet(); |
1075 | |
1076 | if ( $res->numRows() > 0 ) { |
1077 | $insertData = []; // array of users/groups to insert to user_former_groups |
1078 | $deleteCond = []; // array for deleting the rows that are to be moved around |
1079 | foreach ( $res as $row ) { |
1080 | $insertData[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ]; |
1081 | $deleteCond[] = $dbw |
1082 | ->expr( 'ug_user', '=', $row->ug_user ) |
1083 | ->and( 'ug_group', '=', $row->ug_group ); |
1084 | } |
1085 | // Delete the rows we're about to move |
1086 | $dbw->newDeleteQueryBuilder() |
1087 | ->deleteFrom( 'user_groups' ) |
1088 | ->where( $dbw->orExpr( $deleteCond ) ) |
1089 | ->caller( __METHOD__ )->execute(); |
1090 | // Push the groups to user_former_groups |
1091 | $dbw->newInsertQueryBuilder() |
1092 | ->insertInto( 'user_former_groups' ) |
1093 | ->ignore() |
1094 | ->rows( $insertData ) |
1095 | ->caller( __METHOD__ )->execute(); |
1096 | // Count how many rows were purged |
1097 | $purgedRows += $res->numRows(); |
1098 | } |
1099 | |
1100 | $dbw->endAtomic( __METHOD__ ); |
1101 | |
1102 | $this->dbProvider->commitAndWaitForReplication( __METHOD__, $ticket ); |
1103 | } while ( $res->numRows() > 0 ); |
1104 | return $purgedRows; |
1105 | } |
1106 | |
1107 | /** |
1108 | * @param array $config |
1109 | * @param string $group |
1110 | * @return string[] |
1111 | */ |
1112 | private function expandChangeableGroupConfig( array $config, string $group ): array { |
1113 | if ( empty( $config[$group] ) ) { |
1114 | return []; |
1115 | } elseif ( $config[$group] === true ) { |
1116 | // You get everything |
1117 | return $this->listAllGroups(); |
1118 | } elseif ( is_array( $config[$group] ) ) { |
1119 | return $config[$group]; |
1120 | } |
1121 | return []; |
1122 | } |
1123 | |
1124 | /** |
1125 | * Returns an array of the groups that a particular group can add/remove. |
1126 | * |
1127 | * @since 1.37 |
1128 | * @param string $group The group to check for whether it can add/remove |
1129 | * @return array [ |
1130 | * 'add' => [ addablegroups ], |
1131 | * 'remove' => [ removablegroups ], |
1132 | * 'add-self' => [ addablegroups to self ], |
1133 | * 'remove-self' => [ removable groups from self ] ] |
1134 | */ |
1135 | public function getGroupsChangeableByGroup( string $group ): array { |
1136 | return [ |
1137 | 'add' => $this->expandChangeableGroupConfig( |
1138 | $this->options->get( MainConfigNames::AddGroups ), $group |
1139 | ), |
1140 | 'remove' => $this->expandChangeableGroupConfig( |
1141 | $this->options->get( MainConfigNames::RemoveGroups ), $group |
1142 | ), |
1143 | 'add-self' => $this->expandChangeableGroupConfig( |
1144 | $this->options->get( MainConfigNames::GroupsAddToSelf ), $group |
1145 | ), |
1146 | 'remove-self' => $this->expandChangeableGroupConfig( |
1147 | $this->options->get( MainConfigNames::GroupsRemoveFromSelf ), $group |
1148 | ), |
1149 | ]; |
1150 | } |
1151 | |
1152 | /** |
1153 | * Returns an array of groups that this $actor can add and remove. |
1154 | * |
1155 | * @since 1.37 |
1156 | * @param Authority $authority |
1157 | * @return array [ |
1158 | * 'add' => [ addablegroups ], |
1159 | * 'remove' => [ removablegroups ], |
1160 | * 'add-self' => [ addablegroups to self ], |
1161 | * 'remove-self' => [ removable groups from self ] |
1162 | * ] |
1163 | * @phan-return array{add:list<string>,remove:list<string>,add-self:list<string>,remove-self:list<string>} |
1164 | */ |
1165 | public function getGroupsChangeableBy( Authority $authority ): array { |
1166 | if ( $authority->isAllowed( 'userrights' ) ) { |
1167 | // This group gives the right to modify everything (reverse- |
1168 | // compatibility with old "userrights lets you change |
1169 | // everything") |
1170 | // Using array_merge to make the groups reindexed |
1171 | $all = array_merge( $this->listAllGroups() ); |
1172 | return [ |
1173 | 'add' => $all, |
1174 | 'remove' => $all, |
1175 | 'add-self' => [], |
1176 | 'remove-self' => [] |
1177 | ]; |
1178 | } |
1179 | |
1180 | // Okay, it's not so simple, we will have to go through the arrays |
1181 | $groups = [ |
1182 | 'add' => [], |
1183 | 'remove' => [], |
1184 | 'add-self' => [], |
1185 | 'remove-self' => [] |
1186 | ]; |
1187 | $actorGroups = $this->getUserEffectiveGroups( $authority->getUser() ); |
1188 | |
1189 | foreach ( $actorGroups as $actorGroup ) { |
1190 | $groups = array_merge_recursive( |
1191 | $groups, $this->getGroupsChangeableByGroup( $actorGroup ) |
1192 | ); |
1193 | $groups['add'] = array_unique( $groups['add'] ); |
1194 | $groups['remove'] = array_unique( $groups['remove'] ); |
1195 | $groups['add-self'] = array_unique( $groups['add-self'] ); |
1196 | $groups['remove-self'] = array_unique( $groups['remove-self'] ); |
1197 | } |
1198 | return $groups; |
1199 | } |
1200 | |
1201 | /** |
1202 | * Cleans cached group memberships for a given user |
1203 | * |
1204 | * @param UserIdentity $user |
1205 | */ |
1206 | public function clearCache( UserIdentity $user ) { |
1207 | $user->assertWiki( $this->wikiId ); |
1208 | $userKey = $this->getCacheKey( $user ); |
1209 | unset( $this->userGroupCache[$userKey] ); |
1210 | unset( $this->queryFlagsUsedForCaching[$userKey] ); |
1211 | } |
1212 | |
1213 | /** |
1214 | * Sets cached group memberships and query flags for a given user |
1215 | * |
1216 | * @param string $userKey |
1217 | * @param string $cacheKind one of self::CACHE_KIND_* constants |
1218 | * @param array $groupValue |
1219 | * @param int $queryFlags |
1220 | */ |
1221 | private function setCache( |
1222 | string $userKey, |
1223 | string $cacheKind, |
1224 | array $groupValue, |
1225 | int $queryFlags |
1226 | ) { |
1227 | $this->userGroupCache[$userKey][$cacheKind] = $groupValue; |
1228 | $this->queryFlagsUsedForCaching[$userKey][$cacheKind] = $queryFlags; |
1229 | } |
1230 | |
1231 | /** |
1232 | * Clears a cached group membership and query key for a given user |
1233 | * |
1234 | * @param UserIdentity $user |
1235 | * @param string $cacheKind one of self::CACHE_* constants |
1236 | */ |
1237 | private function clearUserCacheForKind( UserIdentity $user, string $cacheKind ) { |
1238 | $userKey = $this->getCacheKey( $user ); |
1239 | unset( $this->userGroupCache[$userKey][$cacheKind] ); |
1240 | unset( $this->queryFlagsUsedForCaching[$userKey][$cacheKind] ); |
1241 | } |
1242 | |
1243 | /** |
1244 | * @param int $recency a bit field composed of IDBAccessObject::READ_XXX flags |
1245 | * @return IReadableDatabase |
1246 | */ |
1247 | private function getDBConnectionRefForQueryFlags( int $recency ): IReadableDatabase { |
1248 | if ( ( IDBAccessObject::READ_LATEST & $recency ) == IDBAccessObject::READ_LATEST ) { |
1249 | return $this->dbProvider->getPrimaryDatabase( $this->wikiId ); |
1250 | } |
1251 | return $this->dbProvider->getReplicaDatabase( $this->wikiId ); |
1252 | } |
1253 | |
1254 | /** |
1255 | * Gets a unique key for various caches. |
1256 | * @param UserIdentity $user |
1257 | * @return string |
1258 | */ |
1259 | private function getCacheKey( UserIdentity $user ): string { |
1260 | return $user->isRegistered() ? "u:{$user->getId( $this->wikiId )}" : "anon:{$user->getName()}"; |
1261 | } |
1262 | |
1263 | /** |
1264 | * Determines if it's ok to use cached options values for a given user and query flags |
1265 | * @param UserIdentity $user |
1266 | * @param string $cacheKind one of self::CACHE_* constants |
1267 | * @param int $queryFlags |
1268 | * @return bool |
1269 | */ |
1270 | private function canUseCachedValues( |
1271 | UserIdentity $user, |
1272 | string $cacheKind, |
1273 | int $queryFlags |
1274 | ): bool { |
1275 | if ( !$user->isRegistered() ) { |
1276 | // Anon users don't have groups stored in the database, |
1277 | // so $queryFlags are ignored. |
1278 | return true; |
1279 | } |
1280 | if ( $queryFlags >= IDBAccessObject::READ_LOCKING ) { |
1281 | return false; |
1282 | } |
1283 | $userKey = $this->getCacheKey( $user ); |
1284 | $queryFlagsUsed = $this->queryFlagsUsedForCaching[$userKey][$cacheKind] ?? IDBAccessObject::READ_NONE; |
1285 | return $queryFlagsUsed >= $queryFlags; |
1286 | } |
1287 | } |