wiki.techinc.nl/includes/user/UserGroupManager.php
daniel b3b70624c9 Authority: expose user block info
Expose info about user blocks from Authority. This allows calling code
to provide more detailed information to the user about why they are
denied some action on the wiki.

Bug: T271494
Change-Id: Ia84e469888866d72752aad355292666c31e12bad
2021-06-30 13:42:21 +02:00

1167 lines
35 KiB
PHP

<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\User;
use ConfiguredReadOnlyMode;
use DBAccessObjectUtils;
use DeferredUpdates;
use IDBAccessObject;
use InvalidArgumentException;
use JobQueueGroup;
use ManualLogEntry;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\GroupPermissionsLookup;
use Psr\Log\LoggerInterface;
use ReadOnlyMode;
use Sanitizer;
use User;
use UserGroupExpiryJob;
use UserGroupMembership;
use WikiMap;
use Wikimedia\Assert\Assert;
use Wikimedia\IPUtils;
use Wikimedia\Rdbms\DBConnRef;
use Wikimedia\Rdbms\ILBFactory;
use Wikimedia\Rdbms\ILoadBalancer;
/**
* Managers user groups.
* @since 1.35
*/
class UserGroupManager implements IDBAccessObject {
/**
* @internal For use by ServiceWiring
*/
public const CONSTRUCTOR_OPTIONS = [
'AddGroups',
'Autopromote',
'AutopromoteOnce',
'AutopromoteOnceLogInRC',
'EmailAuthentication',
'ImplicitGroups',
'GroupPermissions',
'GroupsAddToSelf',
'GroupsRemoveFromSelf',
'RevokePermissions',
'RemoveGroups',
];
/** @var ServiceOptions */
private $options;
/** @var ILBFactory */
private $loadBalancerFactory;
/** @var ILoadBalancer */
private $loadBalancer;
/** @var HookContainer */
private $hookContainer;
/** @var HookRunner */
private $hookRunner;
/** @var ReadOnlyMode */
private $readOnlyMode;
/** @var UserEditTracker */
private $userEditTracker;
/** @var GroupPermissionsLookup */
private $groupPermissionsLookup;
/** @var LoggerInterface */
private $logger;
/** @var callable[] */
private $clearCacheCallbacks;
/** @var string|false */
private $dbDomain;
/** string key for implicit groups cache */
private const CACHE_IMPLICIT = 'implicit';
/** string key for effective groups cache */
private const CACHE_EFFECTIVE = 'effective';
/** string key for group memberships cache */
private const CACHE_MEMBERSHIP = 'membership';
/** string key for former groups cache */
private const CACHE_FORMER = 'former';
/**
* @var array Service caches, an assoc. array keyed after the user-keys generated
* by the getCacheKey method and storing values in the following format:
*
* userKey => [
* self::CACHE_IMPLICIT => implicit groups cache
* self::CACHE_EFFECTIVE => effective groups cache
* self::CACHE_MEMBERSHIP => [ ] // Array of UserGroupMembership objects
* self::CACHE_FORMER => former groups cache
* ]
*/
private $userGroupCache = [];
/**
* @var array An assoc. array that stores query flags used to retrieve user groups
* from the database and is stored in the following format:
*
* userKey => [
* self::CACHE_IMPLICIT => implicit groups query flag
* self::CACHE_EFFECTIVE => effective groups query flag
* self::CACHE_MEMBERSHIP => membership groups query flag
* self::CACHE_FORMER => former groups query flag
* ]
*/
private $queryFlagsUsedForCaching = [];
/**
* @param ServiceOptions $options
* @param ConfiguredReadOnlyMode $configuredReadOnlyMode
* @param ILBFactory $loadBalancerFactory
* @param HookContainer $hookContainer
* @param UserEditTracker $userEditTracker
* @param GroupPermissionsLookup $groupPermissionsLookup
* @param LoggerInterface $logger
* @param callable[] $clearCacheCallbacks
* @param string|bool $dbDomain
*/
public function __construct(
ServiceOptions $options,
ConfiguredReadOnlyMode $configuredReadOnlyMode,
ILBFactory $loadBalancerFactory,
HookContainer $hookContainer,
UserEditTracker $userEditTracker,
GroupPermissionsLookup $groupPermissionsLookup,
LoggerInterface $logger,
array $clearCacheCallbacks = [],
$dbDomain = false
) {
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->options = $options;
$this->loadBalancerFactory = $loadBalancerFactory;
$this->loadBalancer = $loadBalancerFactory->getMainLB( $dbDomain );
$this->hookContainer = $hookContainer;
$this->hookRunner = new HookRunner( $hookContainer );
$this->userEditTracker = $userEditTracker;
$this->groupPermissionsLookup = $groupPermissionsLookup;
$this->logger = $logger;
// Can't just inject ROM since we LB can be for foreign wiki
$this->readOnlyMode = new ReadOnlyMode( $configuredReadOnlyMode, $this->loadBalancer );
$this->clearCacheCallbacks = $clearCacheCallbacks;
$this->dbDomain = $dbDomain;
}
/**
* Return the set of defined explicit groups.
* The implicit groups (by default *, 'user' and 'autoconfirmed')
* are not included, as they are defined automatically, not in the database.
* @return string[] internal group names
*/
public function listAllGroups() : array {
return array_values( array_diff(
array_merge(
array_keys( $this->options->get( 'GroupPermissions' ) ),
array_keys( $this->options->get( 'RevokePermissions' ) )
),
$this->listAllImplicitGroups()
) );
}
/**
* Get a list of all configured implicit groups
* @return string[]
*/
public function listAllImplicitGroups() : array {
return $this->options->get( 'ImplicitGroups' );
}
/**
* Creates a new UserGroupMembership instance from $row.
* The fields required to build an instance could be
* found using getQueryInfo() method.
*
* @param \stdClass $row A database result object
*
* @return UserGroupMembership
*/
public function newGroupMembershipFromRow( \stdClass $row ) : UserGroupMembership {
return new UserGroupMembership(
(int)$row->ug_user,
$row->ug_group,
$row->ug_expiry === null ? null : wfTimestamp(
TS_MW,
$row->ug_expiry
)
);
}
/**
* Load the user groups cache from the provided user groups data
* @internal for use by the User object only
* @param UserIdentity $user
* @param array $userGroups an array of database query results
* @param int $queryFlags
*/
public function loadGroupMembershipsFromArray(
UserIdentity $user,
array $userGroups,
int $queryFlags = self::READ_NORMAL
) {
$membershipGroups = [];
reset( $userGroups );
foreach ( $userGroups as $row ) {
$ugm = $this->newGroupMembershipFromRow( $row );
$membershipGroups[ $ugm->getGroup() ] = $ugm;
}
$this->setCache(
$this->getCacheKey( $user ),
self::CACHE_MEMBERSHIP,
$membershipGroups,
$queryFlags
);
}
/**
* Get the list of implicit group memberships this user has.
*
* This includes 'user' if logged in, '*' for all accounts,
* and autopromoted groups
*
* @param UserIdentity $user
* @param int $queryFlags
* @param bool $recache Whether to avoid the cache
* @return string[] internal group names
*/
public function getUserImplicitGroups(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL,
bool $recache = false
) : array {
$userKey = $this->getCacheKey( $user );
if ( $recache ||
!isset( $this->userGroupCache[$userKey][self::CACHE_IMPLICIT] ) ||
!$this->canUseCachedValues( $user, self::CACHE_IMPLICIT, $queryFlags )
) {
$groups = [ '*' ];
if ( $user->isRegistered() ) {
$groups[] = 'user';
$groups = array_unique( array_merge(
$groups,
$this->getUserAutopromoteGroups( $user )
) );
}
$this->setCache( $userKey, self::CACHE_IMPLICIT, $groups, $queryFlags );
if ( $recache ) {
// Assure data consistency with rights/groups,
// as getEffectiveGroups() depends on this function
$this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
}
}
return $this->userGroupCache[$userKey][self::CACHE_IMPLICIT];
}
/**
* Get the list of implicit group memberships the user has.
*
* This includes all explicit groups, plus 'user' if logged in,
* '*' for all accounts, and autopromoted groups
*
* @param UserIdentity $user
* @param int $queryFlags
* @param bool $recache Whether to avoid the cache
* @return string[] internal group names
*/
public function getUserEffectiveGroups(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL,
bool $recache = false
) : array {
$userKey = $this->getCacheKey( $user );
// Ignore cache if the $recache flag is set, cached values can not be used
// or the cache value is missing
if ( $recache ||
!$this->canUseCachedValues( $user, self::CACHE_EFFECTIVE, $queryFlags ) ||
!isset( $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE] )
) {
$groups = array_unique( array_merge(
$this->getUserGroups( $user, $queryFlags ), // explicit groups
$this->getUserImplicitGroups( $user, $queryFlags, $recache ) // implicit groups
) );
// TODO: Deprecate passing out user object in the hook by introducing
// an alternative hook
if ( $this->hookContainer->isRegistered( 'UserEffectiveGroups' ) ) {
$userObj = User::newFromIdentity( $user );
$userObj->load();
// Hook for additional groups
$this->hookRunner->onUserEffectiveGroups( $userObj, $groups );
}
// Force reindexation of groups when a hook has unset one of them
$effectiveGroups = array_values( array_unique( $groups ) );
$this->setCache( $userKey, self::CACHE_EFFECTIVE, $effectiveGroups, $queryFlags );
}
return $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE];
}
/**
* Returns the groups the user has belonged to.
*
* The user may still belong to the returned groups. Compare with getGroups().
*
* The function will not return groups the user had belonged to before MW 1.17
*
* @param UserIdentity $user
* @param int $queryFlags
* @return string[] Names of the groups the user has belonged to.
*/
public function getUserFormerGroups(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL
) : array {
$userKey = $this->getCacheKey( $user );
if ( $this->canUseCachedValues( $user, self::CACHE_FORMER, $queryFlags ) &&
isset( $this->userGroupCache[$userKey][self::CACHE_FORMER] )
) {
return $this->userGroupCache[$userKey][self::CACHE_FORMER];
}
if ( !$user->isRegistered() ) {
// Anon users don't have groups stored in the database
return [];
}
$db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
$res = $db->select(
'user_former_groups',
[ 'ufg_group' ],
[ 'ufg_user' => $user->getId() ],
__METHOD__
);
$formerGroups = [];
foreach ( $res as $row ) {
$formerGroups[] = $row->ufg_group;
}
$this->setCache( $userKey, self::CACHE_FORMER, $formerGroups, $queryFlags );
return $this->userGroupCache[$userKey][self::CACHE_FORMER];
}
/**
* Get the groups for the given user based on $wgAutopromote.
*
* @param UserIdentity $user The user to get the groups for
* @return string[] Array of groups to promote to.
*
* @see $wgAutopromote
*/
public function getUserAutopromoteGroups( UserIdentity $user ) : array {
$promote = [];
// TODO: remove the need for the full user object
$userObj = User::newFromIdentity( $user );
foreach ( $this->options->get( 'Autopromote' ) as $group => $cond ) {
if ( $this->recCheckCondition( $cond, $userObj ) ) {
$promote[] = $group;
}
}
$this->hookRunner->onGetAutoPromoteGroups( $userObj, $promote );
return $promote;
}
/**
* Get the groups for the given user based on the given criteria.
*
* Does not return groups the user already belongs to or has once belonged.
*
* @param UserIdentity $user The user to get the groups for
* @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria)
*
* @return string[] Groups the user should be promoted to.
*
* @see $wgAutopromoteOnce
*/
public function getUserAutopromoteOnceGroups(
UserIdentity $user,
string $event
) : array {
$autopromoteOnce = $this->options->get( 'AutopromoteOnce' );
$promote = [];
if ( isset( $autopromoteOnce[$event] ) && count( $autopromoteOnce[$event] ) ) {
$currentGroups = $this->getUserGroups( $user );
$formerGroups = $this->getUserFormerGroups( $user );
// TODO: remove the need for the full user object
$userObj = User::newFromIdentity( $user );
foreach ( $autopromoteOnce[$event] as $group => $cond ) {
// Do not check if the user's already a member
if ( in_array( $group, $currentGroups ) ) {
continue;
}
// Do not autopromote if the user has belonged to the group
if ( in_array( $group, $formerGroups ) ) {
continue;
}
// Finally - check the conditions
if ( $this->recCheckCondition( $cond, $userObj ) ) {
$promote[] = $group;
}
}
}
return $promote;
}
/**
* Recursively check a condition. Conditions are in the form
* [ '&' or '|' or '^' or '!', cond1, cond2, ... ]
* where cond1, cond2, ... are themselves conditions; *OR*
* APCOND_EMAILCONFIRMED, *OR*
* [ APCOND_EMAILCONFIRMED ], *OR*
* [ APCOND_EDITCOUNT, number of edits ], *OR*
* [ APCOND_AGE, seconds since registration ], *OR*
* similar constructs defined by extensions.
* This function evaluates the former type recursively, and passes off to
* checkCondition for evaluation of the latter type.
*
* @param mixed $cond A condition, possibly containing other conditions
* @param User $user The user to check the conditions against
* @return bool Whether the condition is true
*/
private function recCheckCondition( $cond, User $user ) : bool {
$validOps = [ '&', '|', '^', '!' ];
if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], $validOps ) ) {
// Recursive condition
if ( $cond[0] == '&' ) { // AND (all conds pass)
foreach ( array_slice( $cond, 1 ) as $subcond ) {
if ( !$this->recCheckCondition( $subcond, $user ) ) {
return false;
}
}
return true;
} elseif ( $cond[0] == '|' ) { // OR (at least one cond passes)
foreach ( array_slice( $cond, 1 ) as $subcond ) {
if ( $this->recCheckCondition( $subcond, $user ) ) {
return true;
}
}
return false;
} elseif ( $cond[0] == '^' ) { // XOR (exactly one cond passes)
if ( count( $cond ) > 3 ) {
$this->logger->warning(
'recCheckCondition() given XOR ("^") condition on three or more conditions.' .
' Check your $wgAutopromote and $wgAutopromoteOnce settings.'
);
}
return $this->recCheckCondition( $cond[1], $user )
xor $this->recCheckCondition( $cond[2], $user );
} elseif ( $cond[0] == '!' ) { // NOT (no conds pass)
foreach ( array_slice( $cond, 1 ) as $subcond ) {
if ( $this->recCheckCondition( $subcond, $user ) ) {
return false;
}
}
return true;
}
}
// If we got here, the array presumably does not contain other conditions;
// it's not recursive. Pass it off to checkCondition.
if ( !is_array( $cond ) ) {
$cond = [ $cond ];
}
return $this->checkCondition( $cond, $user );
}
/**
* As recCheckCondition, but *not* recursive. The only valid conditions
* are those whose first element is one of APCOND_* defined in Defines.php.
* Other types will throw an exception if no extension evaluates them.
*
* @param array $cond A condition, which must not contain other conditions
* @param User $user The user to check the condition against
* @return bool Whether the condition is true for the user
* @throws InvalidArgumentException if autopromote condition was not recognized.
*/
private function checkCondition( array $cond, User $user ) : bool {
if ( count( $cond ) < 1 ) {
return false;
}
switch ( $cond[0] ) {
case APCOND_EMAILCONFIRMED:
if ( Sanitizer::validateEmail( $user->getEmail() ) ) {
if ( $this->options->get( 'EmailAuthentication' ) ) {
return (bool)$user->getEmailAuthenticationTimestamp();
} else {
return true;
}
}
return false;
case APCOND_EDITCOUNT:
$reqEditCount = $cond[1];
// T157718: Avoid edit count lookup if specified edit count is 0 or invalid
if ( $reqEditCount <= 0 ) {
return true;
}
return $user->isRegistered() && $this->userEditTracker->getUserEditCount( $user ) >= $reqEditCount;
case APCOND_AGE:
$age = time() - (int)wfTimestampOrNull( TS_UNIX, $user->getRegistration() );
return $age >= $cond[1];
case APCOND_AGE_FROM_EDIT:
$age = time() - (int)wfTimestampOrNull(
TS_UNIX, $this->userEditTracker->getFirstEditTimestamp( $user ) );
return $age >= $cond[1];
case APCOND_INGROUPS:
$groups = array_slice( $cond, 1 );
return count( array_intersect( $groups, $this->getUserGroups( $user ) ) ) == count( $groups );
case APCOND_ISIP:
return $cond[1] == $user->getRequest()->getIP();
case APCOND_IPINRANGE:
return IPUtils::isInRange( $user->getRequest()->getIP(), $cond[1] );
case APCOND_BLOCKED:
// Because checking for ipblock-exempt leads back to here (thus infinite recursion),
// we stop checking for ipblock-exempt via here. We do this by setting the second
// param to true.
// See T270145.
$block = $user->getBlock( Authority::READ_LATEST, true );
return $block && $block->isSitewide();
case APCOND_ISBOT:
return in_array( 'bot', $this->groupPermissionsLookup
->getGroupPermissions( $this->getUserGroups( $user ) ) );
default:
$result = null;
$this->hookRunner->onAutopromoteCondition( $cond[0],
array_slice( $cond, 1 ), $user, $result );
if ( $result === null ) {
throw new InvalidArgumentException(
"Unrecognized condition {$cond[0]} for autopromotion!"
);
}
return (bool)$result;
}
}
/**
* Add the user to the group if he/she meets given criteria.
*
* Contrary to autopromotion by $wgAutopromote, the group will be
* possible to remove manually via Special:UserRights. In such case it
* will not be re-added automatically. The user will also not lose the
* group if they no longer meet the criteria.
*
* @param UserIdentity $user User to add to the groups
* @param string $event Key in $wgAutopromoteOnce (each event has groups/criteria)
*
* @return string[] Array of groups the user has been promoted to.
*
* @see $wgAutopromoteOnce
*/
public function addUserToAutopromoteOnceGroups(
UserIdentity $user,
string $event
) : array {
Assert::precondition(
!$this->dbDomain || WikiMap::isCurrentWikiDbDomain( $this->dbDomain ),
__METHOD__ . " is not supported for foreign domains: {$this->dbDomain} used"
);
if ( $this->readOnlyMode->isReadOnly() || !$user->getId() ) {
return [];
}
$toPromote = $this->getUserAutopromoteOnceGroups( $user, $event );
if ( $toPromote === [] ) {
return [];
}
$userObj = User::newFromIdentity( $user );
if ( !$userObj->checkAndSetTouched() ) {
return []; // raced out (bug T48834)
}
$oldGroups = $this->getUserGroups( $user ); // previous groups
$oldUGMs = $this->getUserGroupMemberships( $user );
foreach ( $toPromote as $group ) {
$this->addUserToGroup( $user, $group );
}
$newGroups = array_merge( $oldGroups, $toPromote ); // all groups
$newUGMs = $this->getUserGroupMemberships( $user );
// update groups in external authentication database
// TODO: deprecate passing full User object to hook
$this->hookRunner->onUserGroupsChanged(
$userObj,
$toPromote, [],
false,
false,
$oldUGMs,
$newUGMs
);
$logEntry = new ManualLogEntry( 'rights', 'autopromote' );
$logEntry->setPerformer( $user );
$logEntry->setTarget( $userObj->getUserPage() );
$logEntry->setParameters( [
'4::oldgroups' => $oldGroups,
'5::newgroups' => $newGroups,
] );
$logid = $logEntry->insert();
if ( $this->options->get( 'AutopromoteOnceLogInRC' ) ) {
$logEntry->publish( $logid );
}
return $toPromote;
}
/**
* Get the list of explicit group memberships this user has.
* The implicit * and user groups are not included.
*
* @param UserIdentity $user
* @param int $queryFlags
* @return string[]
*/
public function getUserGroups(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL
) : array {
return array_keys( $this->getUserGroupMemberships( $user, $queryFlags ) );
}
/**
* Loads and returns UserGroupMembership objects for all the groups a user currently
* belongs to.
*
* @param UserIdentity $user the user to search for
* @param int $queryFlags
* @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object)
*/
public function getUserGroupMemberships(
UserIdentity $user,
int $queryFlags = self::READ_NORMAL
) : array {
$userKey = $this->getCacheKey( $user );
if ( $this->canUseCachedValues( $user, self::CACHE_MEMBERSHIP, $queryFlags ) &&
isset( $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP] )
) {
/** @suppress PhanTypeMismatchReturn */
return $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP];
}
if ( !$user->isRegistered() ) {
// Anon users don't have groups stored in the database
return [];
}
$db = $this->getDBConnectionRefForQueryFlags( $queryFlags );
$queryInfo = $this->getQueryInfo();
$res = $db->select(
$queryInfo['tables'],
$queryInfo['fields'],
[ 'ug_user' => $user->getId() ],
__METHOD__,
[],
$queryInfo['joins']
);
$ugms = [];
foreach ( $res as $row ) {
$ugm = $this->newGroupMembershipFromRow( $row );
if ( !$ugm->isExpired() ) {
$ugms[$ugm->getGroup()] = $ugm;
}
}
ksort( $ugms );
$this->setCache( $userKey, self::CACHE_MEMBERSHIP, $ugms, $queryFlags );
return $ugms;
}
/**
* Add the user to the given group. This takes immediate effect.
* If the user is already in the group, the expiry time will be updated to the new
* expiry time. (If $expiry is omitted or null, the membership will be altered to
* never expire.)
*
* @param UserIdentity $user
* @param string $group Name of the group to add
* @param string|null $expiry Optional expiry timestamp in any format acceptable to
* wfTimestamp(), or null if the group assignment should not expire
* @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
*
* @throws InvalidArgumentException
* @return bool
*/
public function addUserToGroup(
UserIdentity $user,
string $group,
string $expiry = null,
bool $allowUpdate = false
) : bool {
if ( $this->readOnlyMode->isReadOnly() ) {
return false;
}
if ( !$user->isRegistered() ) {
throw new InvalidArgumentException(
'UserGroupManager::addUserToGroup() needs a positive user ID. ' .
'Perhaps addGroup() was called before the user was added to the database.'
);
}
if ( $expiry ) {
$expiry = wfTimestamp( TS_MW, $expiry );
}
// TODO: Deprecate passing out user object in the hook by introducing
// an alternative hook
if ( $this->hookContainer->isRegistered( 'UserAddGroup' ) ) {
$userObj = User::newFromIdentity( $user );
$userObj->load();
if ( !$this->hookRunner->onUserAddGroup( $userObj, $group, $expiry ) ) {
return false;
}
}
$oldUgms = $this->getUserGroupMemberships( $user, self::READ_LATEST );
$dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY, [], $this->dbDomain );
$dbw->startAtomic( __METHOD__ );
$dbw->insert(
'user_groups',
[
'ug_user' => $user->getId(),
'ug_group' => $group,
'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null,
],
__METHOD__,
[ 'IGNORE' ]
);
$affected = $dbw->affectedRows();
if ( !$affected ) {
// Conflicting row already exists; it should be overridden if it is either expired
// or if $allowUpdate is true and the current row is different than the loaded row.
$conds = [
'ug_user' => $user->getId(),
'ug_group' => $group
];
if ( $allowUpdate ) {
// Update the current row if its expiry does not match that of the loaded row
$conds[] = $expiry
? "ug_expiry IS NULL OR ug_expiry != {$dbw->addQuotes( $dbw->timestamp( $expiry ) )}"
: 'ug_expiry IS NOT NULL';
} else {
// Update the current row if it is expired
$conds[] = "ug_expiry < {$dbw->addQuotes( $dbw->timestamp() )}";
}
$dbw->update(
'user_groups',
[ 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null ],
$conds,
__METHOD__
);
$affected = $dbw->affectedRows();
}
$dbw->endAtomic( __METHOD__ );
// Purge old, expired memberships from the DB
$fname = __METHOD__;
DeferredUpdates::addCallableUpdate( function () use ( $fname ) {
$dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
$hasExpiredRow = $dbr->selectField(
'user_groups',
'1',
[ "ug_expiry < {$dbr->addQuotes( $dbr->timestamp() )}" ],
$fname
);
if ( $hasExpiredRow ) {
JobQueueGroup::singleton( $this->dbDomain )->push( new UserGroupExpiryJob( [] ) );
}
} );
if ( $affected > 0 ) {
$oldUgms[$group] = new UserGroupMembership( $user->getId(), $group, $expiry );
if ( !$oldUgms[$group]->isExpired() ) {
$this->setCache(
$this->getCacheKey( $user ),
self::CACHE_MEMBERSHIP,
$oldUgms,
self::READ_LATEST
);
$this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
}
foreach ( $this->clearCacheCallbacks as $callback ) {
$callback( $user );
}
return true;
}
return false;
}
/**
* Remove the user from the given group. This takes immediate effect.
*
* @param UserIdentity $user
* @param string $group Name of the group to remove
* @throws InvalidArgumentException
* @return bool
*/
public function removeUserFromGroup( UserIdentity $user, string $group ) : bool {
// TODO: Deprecate passing out user object in the hook by introducing
// an alternative hook
if ( $this->hookContainer->isRegistered( 'UserRemoveGroup' ) ) {
$userObj = User::newFromIdentity( $user );
$userObj->load();
if ( !$this->hookRunner->onUserRemoveGroup( $userObj, $group ) ) {
return false;
}
}
if ( $this->readOnlyMode->isReadOnly() ) {
return false;
}
if ( !$user->isRegistered() ) {
throw new InvalidArgumentException(
'UserGroupManager::removeUserFromGroup() needs a positive user ID. ' .
'Perhaps removeUserFromGroup() was called before the user was added to the database.'
);
}
$oldUgms = $this->getUserGroupMemberships( $user, self::READ_LATEST );
$oldFormerGroups = $this->getUserFormerGroups( $user, self::READ_LATEST );
$dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY, [], $this->dbDomain );
$dbw->delete(
'user_groups',
[ 'ug_user' => $user->getId(), 'ug_group' => $group ],
__METHOD__
);
if ( !$dbw->affectedRows() ) {
return false;
}
// Remember that the user was in this group
$dbw->insert(
'user_former_groups',
[ 'ufg_user' => $user->getId(), 'ufg_group' => $group ],
__METHOD__,
[ 'IGNORE' ]
);
unset( $oldUgms[$group] );
$userKey = $this->getCacheKey( $user );
$this->setCache( $userKey, self::CACHE_MEMBERSHIP, $oldUgms, self::READ_LATEST );
$oldFormerGroups[] = $group;
$this->setCache( $userKey, self::CACHE_FORMER, $oldFormerGroups, self::READ_LATEST );
$this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
foreach ( $this->clearCacheCallbacks as $callback ) {
$callback( $user );
}
return true;
}
/**
* Return the tables and fields to be selected to construct new UserGroupMembership object
* using newGroupMembershipFromRow method.
*
* @return array[] With three keys:
* - tables: (string[]) to include in the `$table` to `IDatabase->select()`
* - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
* - joins: (string[]) to include in the `$joins` to `IDatabase->select()`
* @internal
* @phan-return array{tables:string[],fields:string[],joins:string[]}
*/
public function getQueryInfo() : array {
return [
'tables' => [ 'user_groups' ],
'fields' => [
'ug_user',
'ug_group',
'ug_expiry',
],
'joins' => []
];
}
/**
* Purge expired memberships from the user_groups table
* @internal
* @note this could be slow and is intended for use in a background job
* @return int|bool false if purging wasn't attempted (e.g. because of
* readonly), the number of rows purged (might be 0) otherwise
*/
public function purgeExpired() {
if ( $this->readOnlyMode->isReadOnly() ) {
return false;
}
$ticket = $this->loadBalancerFactory->getEmptyTransactionTicket( __METHOD__ );
$dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
$lockKey = "{$dbw->getDomainID()}:UserGroupManager:purge"; // per-wiki
$scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 );
if ( !$scopedLock ) {
return false; // already running
}
$now = time();
$purgedRows = 0;
$queryInfo = $this->getQueryInfo();
do {
$dbw->startAtomic( __METHOD__ );
$res = $dbw->select(
$queryInfo['tables'],
$queryInfo['fields'],
[ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp( $now ) ) ],
__METHOD__,
[ 'FOR UPDATE', 'LIMIT' => 100 ],
$queryInfo['joins']
);
if ( $res->numRows() > 0 ) {
$insertData = []; // array of users/groups to insert to user_former_groups
$deleteCond = []; // array for deleting the rows that are to be moved around
foreach ( $res as $row ) {
$insertData[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
$deleteCond[] = $dbw->makeList(
[ 'ug_user' => $row->ug_user, 'ug_group' => $row->ug_group ],
$dbw::LIST_AND
);
}
// Delete the rows we're about to move
$dbw->delete(
'user_groups',
$dbw->makeList( $deleteCond, $dbw::LIST_OR ),
__METHOD__
);
// Push the groups to user_former_groups
$dbw->insert(
'user_former_groups',
$insertData,
__METHOD__,
[ 'IGNORE' ]
);
// Count how many rows were purged
$purgedRows += $res->numRows();
}
$dbw->endAtomic( __METHOD__ );
$this->loadBalancerFactory->commitAndWaitForReplication( __METHOD__, $ticket );
} while ( $res->numRows() > 0 );
return $purgedRows;
}
/**
* @param array $config
* @param string $group
* @return string[]
*/
private function expandChangeableGroupConfig( array $config, string $group ): array {
if ( empty( $config[$group] ) ) {
return [];
} elseif ( $config[$group] === true ) {
// You get everything
return $this->listAllGroups();
} elseif ( is_array( $config[$group] ) ) {
return $config[$group];
}
return [];
}
/**
* Returns an array of the groups that a particular group can add/remove.
*
* @since 1.37
* @param string $group The group to check for whether it can add/remove
* @return array [
* 'add' => [ addablegroups ],
* 'remove' => [ removablegroups ],
* 'add-self' => [ addablegroups to self ],
* 'remove-self' => [ removable groups from self ] ]
*/
public function getGroupsChangeableByGroup( string $group ): array {
return [
'add' => $this->expandChangeableGroupConfig(
$this->options->get( 'AddGroups' ), $group
),
'remove' => $this->expandChangeableGroupConfig(
$this->options->get( 'RemoveGroups' ), $group
),
'add-self' => $this->expandChangeableGroupConfig(
$this->options->get( 'GroupsAddToSelf' ), $group
),
'remove-self' => $this->expandChangeableGroupConfig(
$this->options->get( 'GroupsRemoveFromSelf' ), $group
),
];
}
/**
* Returns an array of groups that this $actor can add and remove.
*
* @since 1.37
* @param Authority $authority
* @return array [
* 'add' => [ addablegroups ],
* 'remove' => [ removablegroups ],
* 'add-self' => [ addablegroups to self ],
* 'remove-self' => [ removable groups from self ]
* ]
*/
public function getGroupsChangeableBy( Authority $authority ): array {
if ( $authority->isAllowed( 'userrights' ) ) {
// This group gives the right to modify everything (reverse-
// compatibility with old "userrights lets you change
// everything")
// Using array_merge to make the groups reindexed
$all = array_merge( $this->listAllGroups() );
return [
'add' => $all,
'remove' => $all,
'add-self' => [],
'remove-self' => []
];
}
// Okay, it's not so simple, we will have to go through the arrays
$groups = [
'add' => [],
'remove' => [],
'add-self' => [],
'remove-self' => []
];
$actorGroups = $this->getUserEffectiveGroups( $authority->getUser() );
foreach ( $actorGroups as $actorGroup ) {
$groups = array_merge_recursive(
$groups, $this->getGroupsChangeableByGroup( $actorGroup )
);
$groups['add'] = array_unique( $groups['add'] );
$groups['remove'] = array_unique( $groups['remove'] );
$groups['add-self'] = array_unique( $groups['add-self'] );
$groups['remove-self'] = array_unique( $groups['remove-self'] );
}
return $groups;
}
/**
* Cleans cached group memberships for a given user
*
* @param UserIdentity $user
*/
public function clearCache( UserIdentity $user ) {
$userKey = $this->getCacheKey( $user );
unset( $this->userGroupCache[$userKey] );
unset( $this->queryFlagsUsedForCaching[$userKey] );
}
/**
* Sets cached group memberships and query flags for a given user
*
* @param string $userKey
* @param string $cacheKind one of self::CACHE_KIND_* constants
* @param array $groupValue
* @param int $queryFlags
*/
private function setCache(
string $userKey,
string $cacheKind,
array $groupValue,
int $queryFlags
) {
$this->userGroupCache[$userKey][$cacheKind] = $groupValue;
$this->queryFlagsUsedForCaching[$userKey][$cacheKind] = $queryFlags;
}
/**
* Clears a cached group membership and query key for a given user
*
* @param UserIdentity $user
* @param string $cacheKind one of self::CACHE_* constants
*/
private function clearUserCacheForKind( UserIdentity $user, string $cacheKind ) {
$userKey = $this->getCacheKey( $user );
unset( $this->userGroupCache[$userKey][$cacheKind] );
unset( $this->queryFlagsUsedForCaching[$userKey][$cacheKind] );
}
/**
* @param int $queryFlags a bit field composed of READ_XXX flags
* @return DBConnRef
*/
private function getDBConnectionRefForQueryFlags( int $queryFlags ) : DBConnRef {
list( $mode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
return $this->loadBalancer->getConnectionRef( $mode, [], $this->dbDomain );
}
/**
* Gets a unique key for various caches.
* @param UserIdentity $user
* @return string
*/
private function getCacheKey( UserIdentity $user ) : string {
return $user->isRegistered() ? "u:{$user->getId()}" : "anon:{$user->getName()}";
}
/**
* Determines if it's ok to use cached options values for a given user and query flags
* @param UserIdentity $user
* @param string $cacheKind one of self::CACHE_* constants
* @param int $queryFlags
* @return bool
*/
private function canUseCachedValues(
UserIdentity $user,
string $cacheKind,
int $queryFlags
) : bool {
if ( !$user->isRegistered() ) {
// Anon users don't have groups stored in the database,
// so $queryFlags are ignored.
return true;
}
if ( $queryFlags >= self::READ_LOCKING ) {
return false;
}
$userKey = $this->getCacheKey( $user );
$queryFlagsUsed = $this->queryFlagsUsedForCaching[$userKey][$cacheKind] ?? self::READ_NONE;
return $queryFlagsUsed >= $queryFlags;
}
}