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