2019-10-24 03:14:31 +00:00
|
|
|
<?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 InvalidArgumentException;
|
|
|
|
|
use JobQueueGroup;
|
2023-10-29 20:39:20 +00:00
|
|
|
use LogicException;
|
2020-06-05 16:33:08 +00:00
|
|
|
use ManualLogEntry;
|
2019-10-24 03:14:31 +00:00
|
|
|
use MediaWiki\Config\ServiceOptions;
|
2023-11-21 21:08:14 +00:00
|
|
|
use MediaWiki\Deferred\DeferredUpdates;
|
2019-10-24 03:14:31 +00:00
|
|
|
use MediaWiki\HookContainer\HookContainer;
|
|
|
|
|
use MediaWiki\HookContainer\HookRunner;
|
2022-04-26 15:48:03 +00:00
|
|
|
use MediaWiki\MainConfigNames;
|
2023-09-19 16:59:47 +00:00
|
|
|
use MediaWiki\Parser\Sanitizer;
|
2021-05-25 13:48:49 +00:00
|
|
|
use MediaWiki\Permissions\Authority;
|
2021-01-05 23:08:09 +00:00
|
|
|
use MediaWiki\Permissions\GroupPermissionsLookup;
|
2022-04-11 01:26:51 +00:00
|
|
|
use MediaWiki\User\TempUser\TempUserConfig;
|
2023-02-23 20:44:38 +00:00
|
|
|
use MediaWiki\WikiMap\WikiMap;
|
2020-06-04 16:41:12 +00:00
|
|
|
use Psr\Log\LoggerInterface;
|
2019-10-24 03:14:31 +00:00
|
|
|
use UserGroupExpiryJob;
|
2020-06-05 16:33:08 +00:00
|
|
|
use Wikimedia\Assert\Assert;
|
2020-06-04 16:41:12 +00:00
|
|
|
use Wikimedia\IPUtils;
|
2023-08-12 21:14:32 +00:00
|
|
|
use Wikimedia\Rdbms\IConnectionProvider;
|
2024-09-27 16:12:27 +00:00
|
|
|
use Wikimedia\Rdbms\IDBAccessObject;
|
2019-10-24 03:14:31 +00:00
|
|
|
use Wikimedia\Rdbms\ILBFactory;
|
2023-06-08 12:16:21 +00:00
|
|
|
use Wikimedia\Rdbms\IReadableDatabase;
|
2023-05-04 21:41:21 +00:00
|
|
|
use Wikimedia\Rdbms\ReadOnlyMode;
|
2022-10-28 11:32:54 +00:00
|
|
|
use Wikimedia\Rdbms\SelectQueryBuilder;
|
2019-10-24 03:14:31 +00:00
|
|
|
|
|
|
|
|
/**
|
2023-08-27 10:05:11 +00:00
|
|
|
* Manages user groups.
|
2019-10-24 03:14:31 +00:00
|
|
|
* @since 1.35
|
|
|
|
|
*/
|
2024-02-19 08:58:53 +00:00
|
|
|
class UserGroupManager {
|
2019-10-24 03:14:31 +00:00
|
|
|
|
2019-10-25 08:07:22 +00:00
|
|
|
/**
|
|
|
|
|
* @internal For use by ServiceWiring
|
|
|
|
|
*/
|
2019-10-24 03:14:31 +00:00
|
|
|
public const CONSTRUCTOR_OPTIONS = [
|
2022-04-26 15:48:03 +00:00
|
|
|
MainConfigNames::AddGroups,
|
|
|
|
|
MainConfigNames::AutoConfirmAge,
|
|
|
|
|
MainConfigNames::AutoConfirmCount,
|
|
|
|
|
MainConfigNames::Autopromote,
|
|
|
|
|
MainConfigNames::AutopromoteOnce,
|
|
|
|
|
MainConfigNames::AutopromoteOnceLogInRC,
|
|
|
|
|
MainConfigNames::EmailAuthentication,
|
|
|
|
|
MainConfigNames::ImplicitGroups,
|
|
|
|
|
MainConfigNames::GroupInheritsPermissions,
|
|
|
|
|
MainConfigNames::GroupPermissions,
|
|
|
|
|
MainConfigNames::GroupsAddToSelf,
|
|
|
|
|
MainConfigNames::GroupsRemoveFromSelf,
|
|
|
|
|
MainConfigNames::RevokePermissions,
|
|
|
|
|
MainConfigNames::RemoveGroups,
|
2019-01-07 08:01:00 +00:00
|
|
|
MainConfigNames::PrivilegedGroups,
|
2019-10-24 03:14:31 +00:00
|
|
|
];
|
|
|
|
|
|
2018-07-07 08:12:07 +00:00
|
|
|
/**
|
|
|
|
|
* Logical operators recognized in $wgAutopromote.
|
|
|
|
|
*
|
|
|
|
|
* @since 1.42
|
|
|
|
|
*/
|
|
|
|
|
public const VALID_OPS = [ '&', '|', '^', '!' ];
|
|
|
|
|
|
2023-09-28 15:25:12 +00:00
|
|
|
private ServiceOptions $options;
|
|
|
|
|
private IConnectionProvider $dbProvider;
|
|
|
|
|
private HookContainer $hookContainer;
|
|
|
|
|
private HookRunner $hookRunner;
|
|
|
|
|
private ReadOnlyMode $readOnlyMode;
|
|
|
|
|
private UserEditTracker $userEditTracker;
|
|
|
|
|
private GroupPermissionsLookup $groupPermissionsLookup;
|
|
|
|
|
private JobQueueGroup $jobQueueGroup;
|
|
|
|
|
private LoggerInterface $logger;
|
|
|
|
|
private TempUserConfig $tempUserConfig;
|
2022-04-11 01:26:51 +00:00
|
|
|
|
2019-10-24 03:14:31 +00:00
|
|
|
/** @var callable[] */
|
|
|
|
|
private $clearCacheCallbacks;
|
|
|
|
|
|
|
|
|
|
/** @var string|false */
|
2023-05-25 18:46:36 +00:00
|
|
|
private $wikiId;
|
2019-10-24 03:14:31 +00:00
|
|
|
|
2020-06-13 14:32:59 +00:00
|
|
|
/** 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';
|
|
|
|
|
|
2019-01-07 08:01:00 +00:00
|
|
|
/** string key for former groups cache */
|
|
|
|
|
private const CACHE_PRIVILEGED = 'privileged';
|
|
|
|
|
|
2019-10-24 03:14:31 +00:00
|
|
|
/**
|
|
|
|
|
* @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 => [
|
2020-06-13 14:32:59 +00:00
|
|
|
* 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
|
2019-01-07 08:01:00 +00:00
|
|
|
* self::CACHE_PRIVILEGED => privileged groups cache
|
2019-10-24 03:14:31 +00:00
|
|
|
* ]
|
|
|
|
|
*/
|
|
|
|
|
private $userGroupCache = [];
|
|
|
|
|
|
2020-06-09 19:21:10 +00:00
|
|
|
/**
|
|
|
|
|
* @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 => [
|
2020-06-13 14:32:59 +00:00
|
|
|
* self::CACHE_IMPLICIT => implicit groups query flag
|
|
|
|
|
* self::CACHE_EFFECTIVE => effective groups query flag
|
2019-01-07 08:01:00 +00:00
|
|
|
* self::CACHE_MEMBERSHIP => membership groups query flag
|
2020-06-13 14:32:59 +00:00
|
|
|
* self::CACHE_FORMER => former groups query flag
|
2019-01-07 08:01:00 +00:00
|
|
|
* self::CACHE_PRIVILEGED => privileged groups query flag
|
2020-06-09 19:21:10 +00:00
|
|
|
* ]
|
|
|
|
|
*/
|
|
|
|
|
private $queryFlagsUsedForCaching = [];
|
|
|
|
|
|
2023-10-29 20:39:20 +00:00
|
|
|
/**
|
|
|
|
|
* @internal For use preventing an infinite loop when checking APCOND_BLOCKED
|
|
|
|
|
* @var array An assoc. array mapping the getCacheKey userKey to a bool indicating
|
|
|
|
|
* an ongoing condition check.
|
|
|
|
|
*/
|
|
|
|
|
private $recursionMap = [];
|
|
|
|
|
|
2019-10-24 03:14:31 +00:00
|
|
|
/**
|
|
|
|
|
* @param ServiceOptions $options
|
2023-09-08 11:58:27 +00:00
|
|
|
* @param ReadOnlyMode $readOnlyMode
|
2023-08-12 21:14:32 +00:00
|
|
|
* @param ILBFactory $lbFactory
|
2019-10-24 03:14:31 +00:00
|
|
|
* @param HookContainer $hookContainer
|
2020-06-04 16:41:12 +00:00
|
|
|
* @param UserEditTracker $userEditTracker
|
2021-01-05 23:08:09 +00:00
|
|
|
* @param GroupPermissionsLookup $groupPermissionsLookup
|
2023-05-25 18:46:36 +00:00
|
|
|
* @param JobQueueGroup $jobQueueGroup
|
2020-06-04 16:41:12 +00:00
|
|
|
* @param LoggerInterface $logger
|
2022-04-11 01:26:51 +00:00
|
|
|
* @param TempUserConfig $tempUserConfig
|
2019-10-24 03:14:31 +00:00
|
|
|
* @param callable[] $clearCacheCallbacks
|
2023-05-25 18:46:36 +00:00
|
|
|
* @param string|false $wikiId
|
2019-10-24 03:14:31 +00:00
|
|
|
*/
|
|
|
|
|
public function __construct(
|
|
|
|
|
ServiceOptions $options,
|
2023-09-08 11:58:27 +00:00
|
|
|
ReadOnlyMode $readOnlyMode,
|
2023-08-12 21:14:32 +00:00
|
|
|
ILBFactory $lbFactory,
|
2019-10-24 03:14:31 +00:00
|
|
|
HookContainer $hookContainer,
|
2020-06-04 16:41:12 +00:00
|
|
|
UserEditTracker $userEditTracker,
|
2021-01-05 23:08:09 +00:00
|
|
|
GroupPermissionsLookup $groupPermissionsLookup,
|
2021-07-31 06:31:11 +00:00
|
|
|
JobQueueGroup $jobQueueGroup,
|
2020-06-04 16:41:12 +00:00
|
|
|
LoggerInterface $logger,
|
2022-04-11 01:26:51 +00:00
|
|
|
TempUserConfig $tempUserConfig,
|
2019-10-24 03:14:31 +00:00
|
|
|
array $clearCacheCallbacks = [],
|
2023-05-25 18:46:36 +00:00
|
|
|
$wikiId = UserIdentity::LOCAL
|
2019-10-24 03:14:31 +00:00
|
|
|
) {
|
|
|
|
|
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
|
|
|
|
|
$this->options = $options;
|
2023-08-12 21:14:32 +00:00
|
|
|
$this->dbProvider = $lbFactory;
|
2019-10-24 03:14:31 +00:00
|
|
|
$this->hookContainer = $hookContainer;
|
|
|
|
|
$this->hookRunner = new HookRunner( $hookContainer );
|
2020-06-04 16:41:12 +00:00
|
|
|
$this->userEditTracker = $userEditTracker;
|
2021-01-05 23:08:09 +00:00
|
|
|
$this->groupPermissionsLookup = $groupPermissionsLookup;
|
2021-07-31 06:31:11 +00:00
|
|
|
$this->jobQueueGroup = $jobQueueGroup;
|
2020-06-04 16:41:12 +00:00
|
|
|
$this->logger = $logger;
|
2022-04-11 01:26:51 +00:00
|
|
|
$this->tempUserConfig = $tempUserConfig;
|
2023-09-08 11:58:27 +00:00
|
|
|
$this->readOnlyMode = $readOnlyMode;
|
2019-10-24 03:14:31 +00:00
|
|
|
$this->clearCacheCallbacks = $clearCacheCallbacks;
|
2023-05-25 18:46:36 +00:00
|
|
|
$this->wikiId = $wikiId;
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
2020-10-28 10:01:33 +00:00
|
|
|
* @return string[] internal group names
|
2019-10-24 03:14:31 +00:00
|
|
|
*/
|
2021-07-22 03:11:47 +00:00
|
|
|
public function listAllGroups(): array {
|
2022-02-24 14:35:49 +00:00
|
|
|
return array_values( array_unique(
|
|
|
|
|
array_diff(
|
|
|
|
|
array_merge(
|
2022-04-26 15:48:03 +00:00
|
|
|
array_keys( $this->options->get( MainConfigNames::GroupPermissions ) ),
|
|
|
|
|
array_keys( $this->options->get( MainConfigNames::RevokePermissions ) ),
|
|
|
|
|
array_keys( $this->options->get( MainConfigNames::GroupInheritsPermissions ) )
|
2022-02-24 14:35:49 +00:00
|
|
|
),
|
|
|
|
|
$this->listAllImplicitGroups()
|
|
|
|
|
)
|
2019-10-24 03:14:31 +00:00
|
|
|
) );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a list of all configured implicit groups
|
|
|
|
|
* @return string[]
|
|
|
|
|
*/
|
2021-07-22 03:11:47 +00:00
|
|
|
public function listAllImplicitGroups(): array {
|
2022-04-26 15:48:03 +00:00
|
|
|
return $this->options->get( MainConfigNames::ImplicitGroups );
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2021-07-22 03:11:47 +00:00
|
|
|
public function newGroupMembershipFromRow( \stdClass $row ): UserGroupMembership {
|
2019-10-24 03:14:31 +00:00
|
|
|
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
|
2020-06-09 19:21:10 +00:00
|
|
|
* @param int $queryFlags
|
2019-10-24 03:14:31 +00:00
|
|
|
*/
|
|
|
|
|
public function loadGroupMembershipsFromArray(
|
|
|
|
|
UserIdentity $user,
|
2020-06-09 19:21:10 +00:00
|
|
|
array $userGroups,
|
2024-01-10 21:15:28 +00:00
|
|
|
int $queryFlags = IDBAccessObject::READ_NORMAL
|
2019-10-24 03:14:31 +00:00
|
|
|
) {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2020-06-09 19:21:10 +00:00
|
|
|
$membershipGroups = [];
|
2019-10-24 03:14:31 +00:00
|
|
|
reset( $userGroups );
|
|
|
|
|
foreach ( $userGroups as $row ) {
|
|
|
|
|
$ugm = $this->newGroupMembershipFromRow( $row );
|
2020-06-09 19:21:10 +00:00
|
|
|
$membershipGroups[ $ugm->getGroup() ] = $ugm;
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
2021-06-11 13:50:14 +00:00
|
|
|
$this->setCache(
|
|
|
|
|
$this->getCacheKey( $user ),
|
|
|
|
|
self::CACHE_MEMBERSHIP,
|
|
|
|
|
$membershipGroups,
|
|
|
|
|
$queryFlags
|
|
|
|
|
);
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2020-06-09 19:21:10 +00:00
|
|
|
* @param int $queryFlags
|
2019-10-24 03:14:31 +00:00
|
|
|
* @param bool $recache Whether to avoid the cache
|
|
|
|
|
* @return string[] internal group names
|
|
|
|
|
*/
|
2020-06-09 19:21:10 +00:00
|
|
|
public function getUserImplicitGroups(
|
|
|
|
|
UserIdentity $user,
|
2024-01-10 21:15:28 +00:00
|
|
|
int $queryFlags = IDBAccessObject::READ_NORMAL,
|
2020-06-09 19:21:10 +00:00
|
|
|
bool $recache = false
|
2021-07-22 03:11:47 +00:00
|
|
|
): array {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2019-10-24 03:14:31 +00:00
|
|
|
$userKey = $this->getCacheKey( $user );
|
2020-06-09 19:21:10 +00:00
|
|
|
if ( $recache ||
|
2020-06-13 14:32:59 +00:00
|
|
|
!isset( $this->userGroupCache[$userKey][self::CACHE_IMPLICIT] ) ||
|
|
|
|
|
!$this->canUseCachedValues( $user, self::CACHE_IMPLICIT, $queryFlags )
|
2020-06-09 19:21:10 +00:00
|
|
|
) {
|
2019-10-24 03:14:31 +00:00
|
|
|
$groups = [ '*' ];
|
2023-03-14 05:11:17 +00:00
|
|
|
if ( $this->tempUserConfig->isTempName( $user->getName() ) ) {
|
2023-06-22 14:47:47 +00:00
|
|
|
$groups[] = 'temp';
|
2022-04-11 01:26:51 +00:00
|
|
|
} elseif ( $user->isRegistered() ) {
|
|
|
|
|
$groups[] = 'user';
|
2019-10-24 03:14:31 +00:00
|
|
|
$groups = array_unique( array_merge(
|
|
|
|
|
$groups,
|
2020-06-04 16:41:12 +00:00
|
|
|
$this->getUserAutopromoteGroups( $user )
|
2019-10-24 03:14:31 +00:00
|
|
|
) );
|
|
|
|
|
}
|
2021-06-11 13:50:14 +00:00
|
|
|
$this->setCache( $userKey, self::CACHE_IMPLICIT, $groups, $queryFlags );
|
2019-10-24 03:14:31 +00:00
|
|
|
if ( $recache ) {
|
|
|
|
|
// Assure data consistency with rights/groups,
|
2021-07-01 10:32:24 +00:00
|
|
|
// as getUserEffectiveGroups() depends on this function
|
2020-06-13 14:32:59 +00:00
|
|
|
$this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
}
|
2020-06-13 14:32:59 +00:00
|
|
|
return $this->userGroupCache[$userKey][self::CACHE_IMPLICIT];
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2020-10-28 10:01:33 +00:00
|
|
|
* @return string[] internal group names
|
2019-10-24 03:14:31 +00:00
|
|
|
*/
|
|
|
|
|
public function getUserEffectiveGroups(
|
|
|
|
|
UserIdentity $user,
|
2024-01-10 21:15:28 +00:00
|
|
|
int $queryFlags = IDBAccessObject::READ_NORMAL,
|
2019-10-24 03:14:31 +00:00
|
|
|
bool $recache = false
|
2021-07-22 03:11:47 +00:00
|
|
|
): array {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2019-10-24 03:14:31 +00:00
|
|
|
$userKey = $this->getCacheKey( $user );
|
2020-06-09 19:21:10 +00:00
|
|
|
// Ignore cache if the $recache flag is set, cached values can not be used
|
2019-10-24 03:14:31 +00:00
|
|
|
// or the cache value is missing
|
2020-06-09 19:21:10 +00:00
|
|
|
if ( $recache ||
|
2020-06-13 14:32:59 +00:00
|
|
|
!$this->canUseCachedValues( $user, self::CACHE_EFFECTIVE, $queryFlags ) ||
|
|
|
|
|
!isset( $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE] )
|
2019-10-24 03:14:31 +00:00
|
|
|
) {
|
|
|
|
|
$groups = array_unique( array_merge(
|
|
|
|
|
$this->getUserGroups( $user, $queryFlags ), // explicit groups
|
2020-06-09 19:21:10 +00:00
|
|
|
$this->getUserImplicitGroups( $user, $queryFlags, $recache ) // implicit groups
|
2019-10-24 03:14:31 +00:00
|
|
|
) );
|
|
|
|
|
// 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
|
2020-06-09 19:21:10 +00:00
|
|
|
$effectiveGroups = array_values( array_unique( $groups ) );
|
2021-06-11 13:50:14 +00:00
|
|
|
$this->setCache( $userKey, self::CACHE_EFFECTIVE, $effectiveGroups, $queryFlags );
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
2020-06-13 14:32:59 +00:00
|
|
|
return $this->userGroupCache[$userKey][self::CACHE_EFFECTIVE];
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the groups the user has belonged to.
|
|
|
|
|
*
|
2021-07-01 10:32:24 +00:00
|
|
|
* The user may still belong to the returned groups. Compare with
|
|
|
|
|
* getUserGroups().
|
2019-10-24 03:14:31 +00:00
|
|
|
*
|
|
|
|
|
* The function will not return groups the user had belonged to before MW 1.17
|
|
|
|
|
*
|
|
|
|
|
* @param UserIdentity $user
|
|
|
|
|
* @param int $queryFlags
|
2020-10-28 10:01:33 +00:00
|
|
|
* @return string[] Names of the groups the user has belonged to.
|
2019-10-24 03:14:31 +00:00
|
|
|
*/
|
|
|
|
|
public function getUserFormerGroups(
|
|
|
|
|
UserIdentity $user,
|
2024-01-10 21:15:28 +00:00
|
|
|
int $queryFlags = IDBAccessObject::READ_NORMAL
|
2021-07-22 03:11:47 +00:00
|
|
|
): array {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2019-10-24 03:14:31 +00:00
|
|
|
$userKey = $this->getCacheKey( $user );
|
|
|
|
|
|
2020-06-13 14:32:59 +00:00
|
|
|
if ( $this->canUseCachedValues( $user, self::CACHE_FORMER, $queryFlags ) &&
|
|
|
|
|
isset( $this->userGroupCache[$userKey][self::CACHE_FORMER] )
|
2020-06-09 19:21:10 +00:00
|
|
|
) {
|
2020-06-13 14:32:59 +00:00
|
|
|
return $this->userGroupCache[$userKey][self::CACHE_FORMER];
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
2020-06-09 19:21:10 +00:00
|
|
|
if ( !$user->isRegistered() ) {
|
|
|
|
|
// Anon users don't have groups stored in the database
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-24 19:27:15 +00:00
|
|
|
$res = $this->getDBConnectionRefForQueryFlags( $queryFlags )->newSelectQueryBuilder()
|
2022-10-28 11:32:54 +00:00
|
|
|
->select( 'ufg_group' )
|
|
|
|
|
->from( 'user_former_groups' )
|
2023-05-27 18:13:33 +00:00
|
|
|
->where( [ 'ufg_user' => $user->getId( $this->wikiId ) ] )
|
2022-10-28 11:32:54 +00:00
|
|
|
->caller( __METHOD__ )
|
|
|
|
|
->fetchResultSet();
|
2020-06-09 19:21:10 +00:00
|
|
|
$formerGroups = [];
|
2019-10-24 03:14:31 +00:00
|
|
|
foreach ( $res as $row ) {
|
2020-06-09 19:21:10 +00:00
|
|
|
$formerGroups[] = $row->ufg_group;
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
2021-06-11 13:50:14 +00:00
|
|
|
$this->setCache( $userKey, self::CACHE_FORMER, $formerGroups, $queryFlags );
|
2019-10-24 03:14:31 +00:00
|
|
|
|
2020-06-13 14:32:59 +00:00
|
|
|
return $this->userGroupCache[$userKey][self::CACHE_FORMER];
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
2020-06-04 16:41:12 +00:00
|
|
|
/**
|
|
|
|
|
* Get the groups for the given user based on $wgAutopromote.
|
|
|
|
|
*
|
|
|
|
|
* @param UserIdentity $user The user to get the groups for
|
2020-10-28 10:01:33 +00:00
|
|
|
* @return string[] Array of groups to promote to.
|
2020-06-04 16:41:12 +00:00
|
|
|
*
|
|
|
|
|
* @see $wgAutopromote
|
|
|
|
|
*/
|
2021-07-22 03:11:47 +00:00
|
|
|
public function getUserAutopromoteGroups( UserIdentity $user ): array {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2020-06-04 16:41:12 +00:00
|
|
|
$promote = [];
|
|
|
|
|
// TODO: remove the need for the full user object
|
|
|
|
|
$userObj = User::newFromIdentity( $user );
|
2023-06-27 15:24:03 +00:00
|
|
|
if ( $userObj->isTemp() ) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2022-04-26 15:48:03 +00:00
|
|
|
foreach ( $this->options->get( MainConfigNames::Autopromote ) as $group => $cond ) {
|
2020-06-04 16:41:12 +00:00
|
|
|
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)
|
|
|
|
|
*
|
2020-10-28 10:01:33 +00:00
|
|
|
* @return string[] Groups the user should be promoted to.
|
2020-06-04 16:41:12 +00:00
|
|
|
*
|
|
|
|
|
* @see $wgAutopromoteOnce
|
|
|
|
|
*/
|
|
|
|
|
public function getUserAutopromoteOnceGroups(
|
|
|
|
|
UserIdentity $user,
|
|
|
|
|
string $event
|
2021-07-22 03:11:47 +00:00
|
|
|
): array {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2022-04-26 15:48:03 +00:00
|
|
|
$autopromoteOnce = $this->options->get( MainConfigNames::AutopromoteOnce );
|
2020-06-04 16:41:12 +00:00
|
|
|
$promote = [];
|
|
|
|
|
|
|
|
|
|
if ( isset( $autopromoteOnce[$event] ) && count( $autopromoteOnce[$event] ) ) {
|
|
|
|
|
// TODO: remove the need for the full user object
|
|
|
|
|
$userObj = User::newFromIdentity( $user );
|
2023-06-27 16:01:56 +00:00
|
|
|
if ( $userObj->isTemp() ) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
$currentGroups = $this->getUserGroups( $user );
|
|
|
|
|
$formerGroups = $this->getUserFormerGroups( $user );
|
2020-06-04 16:41:12 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2019-01-07 08:01:00 +00:00
|
|
|
/**
|
2023-09-04 17:01:28 +00:00
|
|
|
* Returns the list of privileged groups that $user belongs to.
|
|
|
|
|
* Privileged groups are ones that can be abused in a dangerous way.
|
2019-01-07 08:01:00 +00:00
|
|
|
*
|
|
|
|
|
* Depending on how extensions extend this method, it might return values
|
2023-09-04 17:01:28 +00:00
|
|
|
* that are not strictly user groups (ACL list names, etc.).
|
|
|
|
|
* It is meant for logging/auditing, not for passing to methods that expect group names.
|
2019-01-07 08:01:00 +00:00
|
|
|
*
|
|
|
|
|
* @param UserIdentity $user
|
|
|
|
|
* @param int $queryFlags
|
|
|
|
|
* @param bool $recache Whether to avoid the cache
|
|
|
|
|
* @return string[]
|
2023-09-04 17:01:28 +00:00
|
|
|
* @since 1.41 (also backported to 1.39.5 and 1.40.1)
|
2019-01-07 08:01:00 +00:00
|
|
|
* @see $wgPrivilegedGroups
|
|
|
|
|
* @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGetPrivilegedGroups
|
|
|
|
|
*/
|
|
|
|
|
public function getUserPrivilegedGroups(
|
|
|
|
|
UserIdentity $user,
|
2024-01-10 21:15:28 +00:00
|
|
|
int $queryFlags = IDBAccessObject::READ_NORMAL,
|
2019-01-07 08:01:00 +00:00
|
|
|
bool $recache = false
|
|
|
|
|
): array {
|
|
|
|
|
$userKey = $this->getCacheKey( $user );
|
|
|
|
|
|
|
|
|
|
if ( !$recache &&
|
|
|
|
|
$this->canUseCachedValues( $user, self::CACHE_PRIVILEGED, $queryFlags ) &&
|
|
|
|
|
isset( $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED] )
|
|
|
|
|
) {
|
|
|
|
|
return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ( !$user->isRegistered() ) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$groups = array_intersect(
|
|
|
|
|
$this->getUserEffectiveGroups( $user, $queryFlags, $recache ),
|
|
|
|
|
$this->options->get( 'PrivilegedGroups' )
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$this->hookRunner->onUserPrivilegedGroups( $user, $groups );
|
|
|
|
|
|
|
|
|
|
$this->setCache(
|
|
|
|
|
$this->getCacheKey( $user ),
|
|
|
|
|
self::CACHE_PRIVILEGED,
|
|
|
|
|
array_values( array_unique( $groups ) ),
|
|
|
|
|
$queryFlags
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return $this->userGroupCache[$userKey][self::CACHE_PRIVILEGED];
|
|
|
|
|
}
|
|
|
|
|
|
2020-06-04 16:41:12 +00:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2018-06-01 10:41:15 +00:00
|
|
|
* If you change the logic of this method, please update
|
|
|
|
|
* ApiQuerySiteinfo::appendAutoPromote(), as it depends on this method.
|
|
|
|
|
*
|
2020-06-04 16:41:12 +00:00
|
|
|
* @param mixed $cond A condition, possibly containing other conditions
|
|
|
|
|
* @param User $user The user to check the conditions against
|
2018-06-01 10:41:15 +00:00
|
|
|
*
|
2020-06-04 16:41:12 +00:00
|
|
|
* @return bool Whether the condition is true
|
|
|
|
|
*/
|
2021-07-22 03:11:47 +00:00
|
|
|
private function recCheckCondition( $cond, User $user ): bool {
|
2018-07-07 08:12:07 +00:00
|
|
|
if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], self::VALID_OPS ) ) {
|
2020-06-04 16:41:12 +00:00
|
|
|
// 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.
|
2023-10-29 20:39:20 +00:00
|
|
|
* @throws LogicException if APCOND_BLOCKED is checked again before returning a result.
|
2020-06-04 16:41:12 +00:00
|
|
|
*/
|
2021-07-22 03:11:47 +00:00
|
|
|
private function checkCondition( array $cond, User $user ): bool {
|
2020-06-04 16:41:12 +00:00
|
|
|
if ( count( $cond ) < 1 ) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch ( $cond[0] ) {
|
|
|
|
|
case APCOND_EMAILCONFIRMED:
|
|
|
|
|
if ( Sanitizer::validateEmail( $user->getEmail() ) ) {
|
2022-04-26 15:48:03 +00:00
|
|
|
if ( $this->options->get( MainConfigNames::EmailAuthentication ) ) {
|
2020-06-04 16:41:12 +00:00
|
|
|
return (bool)$user->getEmailAuthenticationTimestamp();
|
|
|
|
|
} else {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
case APCOND_EDITCOUNT:
|
2022-04-26 15:48:03 +00:00
|
|
|
$reqEditCount = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmCount );
|
2020-06-04 16:41:12 +00:00
|
|
|
|
|
|
|
|
// T157718: Avoid edit count lookup if specified edit count is 0 or invalid
|
|
|
|
|
if ( $reqEditCount <= 0 ) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2023-07-23 13:48:48 +00:00
|
|
|
return (int)$this->userEditTracker->getUserEditCount( $user ) >= $reqEditCount;
|
2020-06-04 16:41:12 +00:00
|
|
|
case APCOND_AGE:
|
2022-04-26 15:48:03 +00:00
|
|
|
$reqAge = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmAge );
|
2020-06-04 16:41:12 +00:00
|
|
|
$age = time() - (int)wfTimestampOrNull( TS_UNIX, $user->getRegistration() );
|
2022-03-11 09:48:57 +00:00
|
|
|
return $age >= $reqAge;
|
2020-06-04 16:41:12 +00:00
|
|
|
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:
|
2021-01-08 21:21:38 +00:00
|
|
|
// Because checking for ipblock-exempt leads back to here (thus infinite recursion),
|
2023-10-29 20:39:20 +00:00
|
|
|
// we if we've been here before for this user without having returned a value.
|
|
|
|
|
// See T270145 and T349608
|
|
|
|
|
$userKey = $this->getCacheKey( $user );
|
|
|
|
|
if ( $this->recursionMap[$userKey] ?? false ) {
|
|
|
|
|
throw new LogicException(
|
|
|
|
|
"Unexpected recursion! APCOND_BLOCKED is being checked during" .
|
|
|
|
|
" an existing APCOND_BLOCKED check for \"{$user->getName()}\"!"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
$this->recursionMap[$userKey] = true;
|
|
|
|
|
// Setting the second parameter here to true prevents us from getting back here
|
|
|
|
|
// during standard MediaWiki core behavior
|
2024-01-23 14:01:06 +00:00
|
|
|
$block = $user->getBlock( IDBAccessObject::READ_LATEST, true );
|
2023-10-29 20:39:20 +00:00
|
|
|
$this->recursionMap[$userKey] = false;
|
|
|
|
|
|
2021-01-28 14:55:27 +00:00
|
|
|
return $block && $block->isSitewide();
|
2020-06-04 16:41:12 +00:00
|
|
|
case APCOND_ISBOT:
|
2021-01-05 23:08:09 +00:00
|
|
|
return in_array( 'bot', $this->groupPermissionsLookup
|
2021-01-12 04:48:49 +00:00
|
|
|
->getGroupPermissions( $this->getUserGroups( $user ) ) );
|
2020-06-04 16:41:12 +00:00
|
|
|
default:
|
|
|
|
|
$result = null;
|
|
|
|
|
$this->hookRunner->onAutopromoteCondition( $cond[0],
|
2021-10-25 19:15:52 +00:00
|
|
|
// @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
|
2020-06-04 16:41:12 +00:00
|
|
|
array_slice( $cond, 1 ), $user, $result );
|
|
|
|
|
if ( $result === null ) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
"Unrecognized condition {$cond[0]} for autopromotion!"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (bool)$result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-06-05 16:33:08 +00:00
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
*
|
2020-10-28 10:01:33 +00:00
|
|
|
* @return string[] Array of groups the user has been promoted to.
|
2020-06-05 16:33:08 +00:00
|
|
|
*
|
|
|
|
|
* @see $wgAutopromoteOnce
|
|
|
|
|
*/
|
|
|
|
|
public function addUserToAutopromoteOnceGroups(
|
|
|
|
|
UserIdentity $user,
|
|
|
|
|
string $event
|
2021-07-22 03:11:47 +00:00
|
|
|
): array {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2020-06-05 16:33:08 +00:00
|
|
|
Assert::precondition(
|
2023-05-25 18:46:36 +00:00
|
|
|
!$this->wikiId || WikiMap::isCurrentWikiDbDomain( $this->wikiId ),
|
|
|
|
|
__METHOD__ . " is not supported for foreign wikis: {$this->wikiId} used"
|
2020-06-05 16:33:08 +00:00
|
|
|
);
|
|
|
|
|
|
2023-06-27 16:01:56 +00:00
|
|
|
if (
|
2023-09-08 11:58:27 +00:00
|
|
|
$this->readOnlyMode->isReadOnly( $this->wikiId ) ||
|
2023-06-27 16:01:56 +00:00
|
|
|
!$user->isRegistered() ||
|
|
|
|
|
$this->tempUserConfig->isTempName( $user->getName() )
|
|
|
|
|
) {
|
2020-06-05 16:33:08 +00:00
|
|
|
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 );
|
2021-08-27 18:20:22 +00:00
|
|
|
$this->addUserToMultipleGroups( $user, $toPromote );
|
2020-06-05 16:33:08 +00:00
|
|
|
$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();
|
2022-04-26 15:48:03 +00:00
|
|
|
if ( $this->options->get( MainConfigNames::AutopromoteOnceLogInRC ) ) {
|
2020-06-05 16:33:08 +00:00
|
|
|
$logEntry->publish( $logid );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $toPromote;
|
|
|
|
|
}
|
|
|
|
|
|
2019-10-24 03:14:31 +00:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2024-01-10 21:15:28 +00:00
|
|
|
int $queryFlags = IDBAccessObject::READ_NORMAL
|
2021-07-22 03:11:47 +00:00
|
|
|
): array {
|
2019-10-24 03:14:31 +00:00
|
|
|
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,
|
2024-01-10 21:15:28 +00:00
|
|
|
int $queryFlags = IDBAccessObject::READ_NORMAL
|
2021-07-22 03:11:47 +00:00
|
|
|
): array {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2019-10-24 03:14:31 +00:00
|
|
|
$userKey = $this->getCacheKey( $user );
|
|
|
|
|
|
2020-06-13 14:32:59 +00:00
|
|
|
if ( $this->canUseCachedValues( $user, self::CACHE_MEMBERSHIP, $queryFlags ) &&
|
|
|
|
|
isset( $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP] )
|
2019-10-24 03:14:31 +00:00
|
|
|
) {
|
|
|
|
|
/** @suppress PhanTypeMismatchReturn */
|
2020-06-13 14:32:59 +00:00
|
|
|
return $this->userGroupCache[$userKey][self::CACHE_MEMBERSHIP];
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
2020-06-09 19:21:10 +00:00
|
|
|
if ( !$user->isRegistered() ) {
|
|
|
|
|
// Anon users don't have groups stored in the database
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-24 19:27:15 +00:00
|
|
|
$queryBuilder = $this->newQueryBuilder( $this->getDBConnectionRefForQueryFlags( $queryFlags ) );
|
2022-10-28 11:32:54 +00:00
|
|
|
$res = $queryBuilder
|
2023-05-27 18:13:33 +00:00
|
|
|
->where( [ 'ug_user' => $user->getId( $this->wikiId ) ] )
|
2022-10-28 11:32:54 +00:00
|
|
|
->caller( __METHOD__ )
|
|
|
|
|
->fetchResultSet();
|
2019-10-24 03:14:31 +00:00
|
|
|
|
|
|
|
|
$ugms = [];
|
|
|
|
|
foreach ( $res as $row ) {
|
|
|
|
|
$ugm = $this->newGroupMembershipFromRow( $row );
|
|
|
|
|
if ( !$ugm->isExpired() ) {
|
|
|
|
|
$ugms[$ugm->getGroup()] = $ugm;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ksort( $ugms );
|
|
|
|
|
|
2021-06-11 13:50:14 +00:00
|
|
|
$this->setCache( $userKey, self::CACHE_MEMBERSHIP, $ugms, $queryFlags );
|
2020-06-09 19:21:10 +00:00
|
|
|
|
2019-10-24 03:14:31 +00:00
|
|
|
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,
|
2024-10-16 18:58:33 +00:00
|
|
|
?string $expiry = null,
|
2019-10-24 03:14:31 +00:00
|
|
|
bool $allowUpdate = false
|
2021-07-22 03:11:47 +00:00
|
|
|
): bool {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2023-09-08 11:58:27 +00:00
|
|
|
if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
|
2019-10-24 03:14:31 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2023-07-05 14:34:43 +00:00
|
|
|
$isTemp = $this->tempUserConfig->isTempName( $user->getName() );
|
2023-07-03 14:55:49 +00:00
|
|
|
if ( !$user->isRegistered() ) {
|
2019-10-24 03:14:31 +00:00
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
'UserGroupManager::addUserToGroup() needs a positive user ID. ' .
|
2021-07-01 10:32:24 +00:00
|
|
|
'Perhaps addUserToGroup() was called before the user was added to the database.'
|
2019-10-24 03:14:31 +00:00
|
|
|
);
|
|
|
|
|
}
|
2023-07-05 14:34:43 +00:00
|
|
|
if ( $isTemp ) {
|
|
|
|
|
throw new InvalidArgumentException(
|
|
|
|
|
'UserGroupManager::addUserToGroup() cannot be called on a temporary user.'
|
|
|
|
|
);
|
|
|
|
|
}
|
2019-10-24 03:14:31 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-10 21:15:28 +00:00
|
|
|
$oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST );
|
2023-08-12 21:14:32 +00:00
|
|
|
$dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
|
2019-10-24 03:14:31 +00:00
|
|
|
|
|
|
|
|
$dbw->startAtomic( __METHOD__ );
|
2023-08-03 20:05:22 +00:00
|
|
|
$dbw->newInsertQueryBuilder()
|
In query builders, use insertInto() and deleteFrom() instead of insert() and delete()
The design principle for SelectQueryBuilder was to make the chained
builder calls look as much like SQL as possible, so that developers
could leverage their knowledge of SQL to understand what the query
builder is doing.
That's why SelectQueryBuilder::select() takes a list of fields, and by
the same principle, it makes sense for UpdateQueryBuilder::update() to
take a table. However with "insert" and "delete", the SQL designers
chose to add prepositions "into" and "from", and I think it makes sense
to follow that here.
In terms of natural language, we update a table, but we don't delete a
table, or insert a table. We delete rows from a table, or insert rows
into a table. The table is not the object of the verb.
So, add insertInto() as an alias for insert(), and add deleteFrom() as
an alias for delete(). Use the new methods in MW core callers where
PHPStorm knows the type.
Change-Id: Idb327a54a57a0fb2288ea067472c1e9727016000
2023-09-08 00:06:59 +00:00
|
|
|
->insertInto( 'user_groups' )
|
2023-08-03 20:05:22 +00:00
|
|
|
->ignore()
|
|
|
|
|
->row( [
|
2023-05-27 18:13:33 +00:00
|
|
|
'ug_user' => $user->getId( $this->wikiId ),
|
2019-10-24 03:14:31 +00:00
|
|
|
'ug_group' => $group,
|
|
|
|
|
'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null,
|
2023-08-03 20:05:22 +00:00
|
|
|
] )
|
|
|
|
|
->caller( __METHOD__ )->execute();
|
2019-10-24 03:14:31 +00:00
|
|
|
|
|
|
|
|
$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 = [
|
2023-05-27 18:13:33 +00:00
|
|
|
'ug_user' => $user->getId( $this->wikiId ),
|
2019-10-24 03:14:31 +00:00
|
|
|
'ug_group' => $group
|
|
|
|
|
];
|
|
|
|
|
if ( $allowUpdate ) {
|
|
|
|
|
// Update the current row if its expiry does not match that of the loaded row
|
|
|
|
|
$conds[] = $expiry
|
2023-10-30 19:10:26 +00:00
|
|
|
? $dbw->expr( 'ug_expiry', '=', null )
|
2024-04-19 22:25:15 +00:00
|
|
|
->or( 'ug_expiry', '!=', $dbw->timestamp( $expiry ) )
|
2023-10-30 19:10:26 +00:00
|
|
|
: $dbw->expr( 'ug_expiry', '!=', null );
|
2019-10-24 03:14:31 +00:00
|
|
|
} else {
|
|
|
|
|
// Update the current row if it is expired
|
2023-10-30 19:10:26 +00:00
|
|
|
$conds[] = $dbw->expr( 'ug_expiry', '<', $dbw->timestamp() );
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
2023-06-07 22:07:31 +00:00
|
|
|
$dbw->newUpdateQueryBuilder()
|
|
|
|
|
->update( 'user_groups' )
|
|
|
|
|
->set( [ 'ug_expiry' => $expiry ? $dbw->timestamp( $expiry ) : null ] )
|
|
|
|
|
->where( $conds )
|
|
|
|
|
->caller( __METHOD__ )->execute();
|
2019-10-24 03:14:31 +00:00
|
|
|
$affected = $dbw->affectedRows();
|
|
|
|
|
}
|
|
|
|
|
$dbw->endAtomic( __METHOD__ );
|
|
|
|
|
|
|
|
|
|
// Purge old, expired memberships from the DB
|
2024-08-07 17:24:36 +00:00
|
|
|
DeferredUpdates::addCallableUpdate( function ( $fname ) {
|
2023-08-12 21:14:32 +00:00
|
|
|
$dbr = $this->dbProvider->getReplicaDatabase( $this->wikiId );
|
2023-07-25 13:37:41 +00:00
|
|
|
$hasExpiredRow = (bool)$dbr->newSelectQueryBuilder()
|
|
|
|
|
->select( '1' )
|
|
|
|
|
->from( 'user_groups' )
|
2023-10-21 11:32:26 +00:00
|
|
|
->where( [ $dbr->expr( 'ug_expiry', '<', $dbr->timestamp() ) ] )
|
2024-08-07 17:24:36 +00:00
|
|
|
->caller( $fname )
|
|
|
|
|
->fetchField();
|
2019-10-24 03:14:31 +00:00
|
|
|
if ( $hasExpiredRow ) {
|
2021-07-31 06:31:11 +00:00
|
|
|
$this->jobQueueGroup->push( new UserGroupExpiryJob( [] ) );
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
if ( $affected > 0 ) {
|
2023-05-27 18:13:33 +00:00
|
|
|
$oldUgms[$group] = new UserGroupMembership( $user->getId( $this->wikiId ), $group, $expiry );
|
2020-06-13 14:32:59 +00:00
|
|
|
if ( !$oldUgms[$group]->isExpired() ) {
|
2021-06-11 13:50:14 +00:00
|
|
|
$this->setCache(
|
|
|
|
|
$this->getCacheKey( $user ),
|
|
|
|
|
self::CACHE_MEMBERSHIP,
|
|
|
|
|
$oldUgms,
|
2024-01-10 21:15:28 +00:00
|
|
|
IDBAccessObject::READ_LATEST
|
2021-06-11 13:50:14 +00:00
|
|
|
);
|
2020-06-13 14:32:59 +00:00
|
|
|
$this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
|
|
|
|
|
}
|
2019-10-24 03:14:31 +00:00
|
|
|
foreach ( $this->clearCacheCallbacks as $callback ) {
|
|
|
|
|
$callback( $user );
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2021-08-27 18:20:22 +00:00
|
|
|
/**
|
|
|
|
|
* Add the user to the given list of groups.
|
|
|
|
|
*
|
|
|
|
|
* @since 1.37
|
|
|
|
|
*
|
|
|
|
|
* @param UserIdentity $user
|
|
|
|
|
* @param string[] $groups Names of the groups 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
|
|
|
|
|
*/
|
|
|
|
|
public function addUserToMultipleGroups(
|
|
|
|
|
UserIdentity $user,
|
|
|
|
|
array $groups,
|
2024-10-16 18:58:33 +00:00
|
|
|
?string $expiry = null,
|
2021-08-27 18:20:22 +00:00
|
|
|
bool $allowUpdate = false
|
|
|
|
|
) {
|
|
|
|
|
foreach ( $groups as $group ) {
|
|
|
|
|
$this->addUserToGroup( $user, $group, $expiry, $allowUpdate );
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-10-24 03:14:31 +00:00
|
|
|
/**
|
|
|
|
|
* Remove the user from the given group. This takes immediate effect.
|
|
|
|
|
*
|
|
|
|
|
* @param UserIdentity $user
|
|
|
|
|
* @param string $group Name of the group to remove
|
2020-06-09 19:21:10 +00:00
|
|
|
* @throws InvalidArgumentException
|
2019-10-24 03:14:31 +00:00
|
|
|
* @return bool
|
|
|
|
|
*/
|
2021-07-22 03:11:47 +00:00
|
|
|
public function removeUserFromGroup( UserIdentity $user, string $group ): bool {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2019-10-24 03:14:31 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-08 11:58:27 +00:00
|
|
|
if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
|
2019-10-24 03:14:31 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2020-06-09 19:21:10 +00:00
|
|
|
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.'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-10 21:15:28 +00:00
|
|
|
$oldUgms = $this->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST );
|
|
|
|
|
$oldFormerGroups = $this->getUserFormerGroups( $user, IDBAccessObject::READ_LATEST );
|
2023-08-12 21:14:32 +00:00
|
|
|
$dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
|
2023-04-29 22:03:51 +00:00
|
|
|
$dbw->newDeleteQueryBuilder()
|
In query builders, use insertInto() and deleteFrom() instead of insert() and delete()
The design principle for SelectQueryBuilder was to make the chained
builder calls look as much like SQL as possible, so that developers
could leverage their knowledge of SQL to understand what the query
builder is doing.
That's why SelectQueryBuilder::select() takes a list of fields, and by
the same principle, it makes sense for UpdateQueryBuilder::update() to
take a table. However with "insert" and "delete", the SQL designers
chose to add prepositions "into" and "from", and I think it makes sense
to follow that here.
In terms of natural language, we update a table, but we don't delete a
table, or insert a table. We delete rows from a table, or insert rows
into a table. The table is not the object of the verb.
So, add insertInto() as an alias for insert(), and add deleteFrom() as
an alias for delete(). Use the new methods in MW core callers where
PHPStorm knows the type.
Change-Id: Idb327a54a57a0fb2288ea067472c1e9727016000
2023-09-08 00:06:59 +00:00
|
|
|
->deleteFrom( 'user_groups' )
|
2023-05-27 18:13:33 +00:00
|
|
|
->where( [ 'ug_user' => $user->getId( $this->wikiId ), 'ug_group' => $group ] )
|
2023-04-29 22:03:51 +00:00
|
|
|
->caller( __METHOD__ )->execute();
|
2019-10-24 03:14:31 +00:00
|
|
|
|
|
|
|
|
if ( !$dbw->affectedRows() ) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// Remember that the user was in this group
|
2023-08-03 20:05:22 +00:00
|
|
|
$dbw->newInsertQueryBuilder()
|
In query builders, use insertInto() and deleteFrom() instead of insert() and delete()
The design principle for SelectQueryBuilder was to make the chained
builder calls look as much like SQL as possible, so that developers
could leverage their knowledge of SQL to understand what the query
builder is doing.
That's why SelectQueryBuilder::select() takes a list of fields, and by
the same principle, it makes sense for UpdateQueryBuilder::update() to
take a table. However with "insert" and "delete", the SQL designers
chose to add prepositions "into" and "from", and I think it makes sense
to follow that here.
In terms of natural language, we update a table, but we don't delete a
table, or insert a table. We delete rows from a table, or insert rows
into a table. The table is not the object of the verb.
So, add insertInto() as an alias for insert(), and add deleteFrom() as
an alias for delete(). Use the new methods in MW core callers where
PHPStorm knows the type.
Change-Id: Idb327a54a57a0fb2288ea067472c1e9727016000
2023-09-08 00:06:59 +00:00
|
|
|
->insertInto( 'user_former_groups' )
|
2023-08-03 20:05:22 +00:00
|
|
|
->ignore()
|
|
|
|
|
->row( [ 'ufg_user' => $user->getId( $this->wikiId ), 'ufg_group' => $group ] )
|
|
|
|
|
->caller( __METHOD__ )->execute();
|
2019-10-24 03:14:31 +00:00
|
|
|
|
2020-06-13 14:32:59 +00:00
|
|
|
unset( $oldUgms[$group] );
|
2021-06-11 13:50:14 +00:00
|
|
|
$userKey = $this->getCacheKey( $user );
|
2024-01-10 21:15:28 +00:00
|
|
|
$this->setCache( $userKey, self::CACHE_MEMBERSHIP, $oldUgms, IDBAccessObject::READ_LATEST );
|
2020-06-13 14:32:59 +00:00
|
|
|
$oldFormerGroups[] = $group;
|
2024-01-10 21:15:28 +00:00
|
|
|
$this->setCache( $userKey, self::CACHE_FORMER, $oldFormerGroups, IDBAccessObject::READ_LATEST );
|
2020-06-13 14:32:59 +00:00
|
|
|
$this->clearUserCacheForKind( $user, self::CACHE_EFFECTIVE );
|
2019-10-24 03:14:31 +00:00
|
|
|
foreach ( $this->clearCacheCallbacks as $callback ) {
|
|
|
|
|
$callback( $user );
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2022-10-28 11:32:54 +00:00
|
|
|
* Return the query builder to build upon and query
|
2019-10-24 03:14:31 +00:00
|
|
|
*
|
2023-06-08 12:16:21 +00:00
|
|
|
* @param IReadableDatabase $db
|
2022-10-28 11:32:54 +00:00
|
|
|
* @return SelectQueryBuilder
|
2019-10-24 03:14:31 +00:00
|
|
|
* @internal
|
|
|
|
|
*/
|
2023-06-08 12:16:21 +00:00
|
|
|
public function newQueryBuilder( IReadableDatabase $db ): SelectQueryBuilder {
|
2024-04-19 22:25:15 +00:00
|
|
|
return $db->newSelectQueryBuilder()
|
2022-10-28 11:32:54 +00:00
|
|
|
->select( [
|
2019-10-24 03:14:31 +00:00
|
|
|
'ug_user',
|
|
|
|
|
'ug_group',
|
|
|
|
|
'ug_expiry',
|
2022-10-28 11:32:54 +00:00
|
|
|
] )
|
|
|
|
|
->from( 'user_groups' );
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Purge expired memberships from the user_groups table
|
|
|
|
|
* @internal
|
|
|
|
|
* @note this could be slow and is intended for use in a background job
|
2022-07-31 00:02:18 +00:00
|
|
|
* @return int|false false if purging wasn't attempted (e.g. because of
|
2019-10-24 03:14:31 +00:00
|
|
|
* readonly), the number of rows purged (might be 0) otherwise
|
|
|
|
|
*/
|
|
|
|
|
public function purgeExpired() {
|
2023-09-08 11:58:27 +00:00
|
|
|
if ( $this->readOnlyMode->isReadOnly( $this->wikiId ) ) {
|
2019-10-24 03:14:31 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2023-08-12 21:14:32 +00:00
|
|
|
$ticket = $this->dbProvider->getEmptyTransactionTicket( __METHOD__ );
|
|
|
|
|
$dbw = $this->dbProvider->getPrimaryDatabase( $this->wikiId );
|
2019-10-24 03:14:31 +00:00
|
|
|
|
|
|
|
|
$lockKey = "{$dbw->getDomainID()}:UserGroupManager:purge"; // per-wiki
|
|
|
|
|
$scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 );
|
|
|
|
|
if ( !$scopedLock ) {
|
|
|
|
|
return false; // already running
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$now = time();
|
|
|
|
|
$purgedRows = 0;
|
|
|
|
|
do {
|
|
|
|
|
$dbw->startAtomic( __METHOD__ );
|
2022-10-28 11:32:54 +00:00
|
|
|
$res = $this->newQueryBuilder( $dbw )
|
2023-10-21 11:32:26 +00:00
|
|
|
->where( [ $dbw->expr( 'ug_expiry', '<', $dbw->timestamp( $now ) ) ] )
|
2022-10-28 11:32:54 +00:00
|
|
|
->forUpdate()
|
|
|
|
|
->limit( 100 )
|
|
|
|
|
->caller( __METHOD__ )
|
|
|
|
|
->fetchResultSet();
|
2019-10-24 03:14:31 +00:00
|
|
|
|
|
|
|
|
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 ];
|
2023-11-01 14:14:51 +00:00
|
|
|
$deleteCond[] = $dbw
|
|
|
|
|
->expr( 'ug_user', '=', $row->ug_user )
|
|
|
|
|
->and( 'ug_group', '=', $row->ug_group );
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
// Delete the rows we're about to move
|
2023-04-29 22:03:51 +00:00
|
|
|
$dbw->newDeleteQueryBuilder()
|
In query builders, use insertInto() and deleteFrom() instead of insert() and delete()
The design principle for SelectQueryBuilder was to make the chained
builder calls look as much like SQL as possible, so that developers
could leverage their knowledge of SQL to understand what the query
builder is doing.
That's why SelectQueryBuilder::select() takes a list of fields, and by
the same principle, it makes sense for UpdateQueryBuilder::update() to
take a table. However with "insert" and "delete", the SQL designers
chose to add prepositions "into" and "from", and I think it makes sense
to follow that here.
In terms of natural language, we update a table, but we don't delete a
table, or insert a table. We delete rows from a table, or insert rows
into a table. The table is not the object of the verb.
So, add insertInto() as an alias for insert(), and add deleteFrom() as
an alias for delete(). Use the new methods in MW core callers where
PHPStorm knows the type.
Change-Id: Idb327a54a57a0fb2288ea067472c1e9727016000
2023-09-08 00:06:59 +00:00
|
|
|
->deleteFrom( 'user_groups' )
|
2024-06-29 20:31:02 +00:00
|
|
|
->where( $dbw->orExpr( $deleteCond ) )
|
2023-04-29 22:03:51 +00:00
|
|
|
->caller( __METHOD__ )->execute();
|
2019-10-24 03:14:31 +00:00
|
|
|
// Push the groups to user_former_groups
|
2023-08-03 20:05:22 +00:00
|
|
|
$dbw->newInsertQueryBuilder()
|
In query builders, use insertInto() and deleteFrom() instead of insert() and delete()
The design principle for SelectQueryBuilder was to make the chained
builder calls look as much like SQL as possible, so that developers
could leverage their knowledge of SQL to understand what the query
builder is doing.
That's why SelectQueryBuilder::select() takes a list of fields, and by
the same principle, it makes sense for UpdateQueryBuilder::update() to
take a table. However with "insert" and "delete", the SQL designers
chose to add prepositions "into" and "from", and I think it makes sense
to follow that here.
In terms of natural language, we update a table, but we don't delete a
table, or insert a table. We delete rows from a table, or insert rows
into a table. The table is not the object of the verb.
So, add insertInto() as an alias for insert(), and add deleteFrom() as
an alias for delete(). Use the new methods in MW core callers where
PHPStorm knows the type.
Change-Id: Idb327a54a57a0fb2288ea067472c1e9727016000
2023-09-08 00:06:59 +00:00
|
|
|
->insertInto( 'user_former_groups' )
|
2023-08-03 20:05:22 +00:00
|
|
|
->ignore()
|
|
|
|
|
->rows( $insertData )
|
|
|
|
|
->caller( __METHOD__ )->execute();
|
2019-10-24 03:14:31 +00:00
|
|
|
// Count how many rows were purged
|
|
|
|
|
$purgedRows += $res->numRows();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$dbw->endAtomic( __METHOD__ );
|
|
|
|
|
|
2023-08-12 21:14:32 +00:00
|
|
|
$this->dbProvider->commitAndWaitForReplication( __METHOD__, $ticket );
|
2019-10-24 03:14:31 +00:00
|
|
|
} while ( $res->numRows() > 0 );
|
|
|
|
|
return $purgedRows;
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-25 13:48:49 +00:00
|
|
|
/**
|
|
|
|
|
* @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(
|
2022-04-26 15:48:03 +00:00
|
|
|
$this->options->get( MainConfigNames::AddGroups ), $group
|
2021-05-25 13:48:49 +00:00
|
|
|
),
|
|
|
|
|
'remove' => $this->expandChangeableGroupConfig(
|
2022-04-26 15:48:03 +00:00
|
|
|
$this->options->get( MainConfigNames::RemoveGroups ), $group
|
2021-05-25 13:48:49 +00:00
|
|
|
),
|
|
|
|
|
'add-self' => $this->expandChangeableGroupConfig(
|
2022-04-26 15:48:03 +00:00
|
|
|
$this->options->get( MainConfigNames::GroupsAddToSelf ), $group
|
2021-05-25 13:48:49 +00:00
|
|
|
),
|
|
|
|
|
'remove-self' => $this->expandChangeableGroupConfig(
|
2022-04-26 15:48:03 +00:00
|
|
|
$this->options->get( MainConfigNames::GroupsRemoveFromSelf ), $group
|
2021-05-25 13:48:49 +00:00
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 ]
|
|
|
|
|
* ]
|
2024-02-13 23:45:03 +00:00
|
|
|
* @phan-return array{add:list<string>,remove:list<string>,add-self:list<string>,remove-self:list<string>}
|
2021-05-25 13:48:49 +00:00
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2019-10-24 03:14:31 +00:00
|
|
|
/**
|
|
|
|
|
* Cleans cached group memberships for a given user
|
|
|
|
|
*
|
|
|
|
|
* @param UserIdentity $user
|
|
|
|
|
*/
|
|
|
|
|
public function clearCache( UserIdentity $user ) {
|
2023-05-27 18:13:33 +00:00
|
|
|
$user->assertWiki( $this->wikiId );
|
2019-10-24 03:14:31 +00:00
|
|
|
$userKey = $this->getCacheKey( $user );
|
|
|
|
|
unset( $this->userGroupCache[$userKey] );
|
2020-06-09 19:21:10 +00:00
|
|
|
unset( $this->queryFlagsUsedForCaching[$userKey] );
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sets cached group memberships and query flags for a given user
|
|
|
|
|
*
|
2021-06-11 13:50:14 +00:00
|
|
|
* @param string $userKey
|
2020-06-13 14:32:59 +00:00
|
|
|
* @param string $cacheKind one of self::CACHE_KIND_* constants
|
2020-06-09 19:21:10 +00:00
|
|
|
* @param array $groupValue
|
|
|
|
|
* @param int $queryFlags
|
|
|
|
|
*/
|
2020-06-13 14:32:59 +00:00
|
|
|
private function setCache(
|
2021-06-11 13:50:14 +00:00
|
|
|
string $userKey,
|
2020-06-13 14:32:59 +00:00
|
|
|
string $cacheKind,
|
|
|
|
|
array $groupValue,
|
|
|
|
|
int $queryFlags
|
|
|
|
|
) {
|
|
|
|
|
$this->userGroupCache[$userKey][$cacheKind] = $groupValue;
|
|
|
|
|
$this->queryFlagsUsedForCaching[$userKey][$cacheKind] = $queryFlags;
|
2020-06-09 19:21:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clears a cached group membership and query key for a given user
|
|
|
|
|
*
|
|
|
|
|
* @param UserIdentity $user
|
2020-06-13 14:32:59 +00:00
|
|
|
* @param string $cacheKind one of self::CACHE_* constants
|
2020-06-09 19:21:10 +00:00
|
|
|
*/
|
2020-06-13 14:32:59 +00:00
|
|
|
private function clearUserCacheForKind( UserIdentity $user, string $cacheKind ) {
|
2020-06-09 19:21:10 +00:00
|
|
|
$userKey = $this->getCacheKey( $user );
|
2020-06-13 14:32:59 +00:00
|
|
|
unset( $this->userGroupCache[$userKey][$cacheKind] );
|
|
|
|
|
unset( $this->queryFlagsUsedForCaching[$userKey][$cacheKind] );
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2024-01-25 15:44:20 +00:00
|
|
|
* @param int $recency a bit field composed of IDBAccessObject::READ_XXX flags
|
2024-01-24 19:27:15 +00:00
|
|
|
* @return IReadableDatabase
|
|
|
|
|
*/
|
2024-01-25 15:44:20 +00:00
|
|
|
private function getDBConnectionRefForQueryFlags( int $recency ): IReadableDatabase {
|
|
|
|
|
if ( ( IDBAccessObject::READ_LATEST & $recency ) == IDBAccessObject::READ_LATEST ) {
|
|
|
|
|
return $this->dbProvider->getPrimaryDatabase( $this->wikiId );
|
|
|
|
|
}
|
|
|
|
|
return $this->dbProvider->getReplicaDatabase( $this->wikiId );
|
2024-01-24 19:27:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2019-10-24 03:14:31 +00:00
|
|
|
* Gets a unique key for various caches.
|
|
|
|
|
* @param UserIdentity $user
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
2021-07-22 03:11:47 +00:00
|
|
|
private function getCacheKey( UserIdentity $user ): string {
|
2023-05-27 18:13:33 +00:00
|
|
|
return $user->isRegistered() ? "u:{$user->getId( $this->wikiId )}" : "anon:{$user->getName()}";
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|
2020-06-09 19:21:10 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Determines if it's ok to use cached options values for a given user and query flags
|
|
|
|
|
* @param UserIdentity $user
|
2020-06-13 14:32:59 +00:00
|
|
|
* @param string $cacheKind one of self::CACHE_* constants
|
2020-06-09 19:21:10 +00:00
|
|
|
* @param int $queryFlags
|
|
|
|
|
* @return bool
|
|
|
|
|
*/
|
2020-06-13 14:32:59 +00:00
|
|
|
private function canUseCachedValues(
|
|
|
|
|
UserIdentity $user,
|
|
|
|
|
string $cacheKind,
|
|
|
|
|
int $queryFlags
|
2021-07-22 03:11:47 +00:00
|
|
|
): bool {
|
2020-06-09 19:21:10 +00:00
|
|
|
if ( !$user->isRegistered() ) {
|
|
|
|
|
// Anon users don't have groups stored in the database,
|
|
|
|
|
// so $queryFlags are ignored.
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2024-01-10 21:15:28 +00:00
|
|
|
if ( $queryFlags >= IDBAccessObject::READ_LOCKING ) {
|
2020-06-09 19:21:10 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$userKey = $this->getCacheKey( $user );
|
2024-01-10 21:15:28 +00:00
|
|
|
$queryFlagsUsed = $this->queryFlagsUsedForCaching[$userKey][$cacheKind] ?? IDBAccessObject::READ_NONE;
|
2020-06-09 19:21:10 +00:00
|
|
|
return $queryFlagsUsed >= $queryFlags;
|
|
|
|
|
}
|
2019-10-24 03:14:31 +00:00
|
|
|
}
|