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