3 * Represents the membership of a user to a user group.
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
23 use Wikimedia\Rdbms\IDatabase
;
24 use MediaWiki\MediaWikiServices
;
27 * Represents a "user group membership" -- a specific instance of a user belonging
28 * to a group. For example, the fact that user Mary belongs to the sysop group is a
29 * user group membership.
31 * The class encapsulates rows in the user_groups table. The logic is low-level and
32 * doesn't run any hooks. Often, you will want to call User::addGroup() or
33 * User::removeGroup() instead.
37 class UserGroupMembership
{
38 /** @var int The ID of the user who belongs to the group */
44 /** @var string|null Timestamp of expiry in TS_MW format, or null if no expiry */
48 * @param int $userId The ID of the user who belongs to the group
49 * @param string|null $group The internal group name
50 * @param string|null $expiry Timestamp of expiry in TS_MW format, or null if no expiry
52 public function __construct( $userId = 0, $group = null, $expiry = null ) {
53 $this->userId
= (int)$userId;
54 $this->group
= $group; // TODO throw on invalid group?
55 $this->expiry
= $expiry ?
: null;
61 public function getUserId() {
68 public function getGroup() {
73 * @return string|null Timestamp of expiry in TS_MW format, or null if no expiry
75 public function getExpiry() {
79 protected function initFromRow( $row ) {
80 $this->userId
= (int)$row->ug_user
;
81 $this->group
= $row->ug_group
;
82 $this->expiry
= $row->ug_expiry
=== null ?
84 wfTimestamp( TS_MW
, $row->ug_expiry
);
88 * Creates a new UserGroupMembership object from a database row.
90 * @param stdClass $row The row from the user_groups table
91 * @return UserGroupMembership
93 public static function newFromRow( $row ) {
95 $ugm->initFromRow( $row );
100 * Returns the list of user_groups fields that should be selected to create
101 * a new user group membership.
104 public static function selectFields() {
113 * Delete the row from the user_groups table.
115 * @throws MWException
116 * @param IDatabase|null $dbw Optional master database connection to use
117 * @return bool Whether or not anything was deleted
119 public function delete( IDatabase
$dbw = null ) {
120 if ( wfReadOnly() ) {
124 if ( $dbw === null ) {
125 $dbw = wfGetDB( DB_MASTER
);
130 [ 'ug_user' => $this->userId
, 'ug_group' => $this->group
],
132 if ( !$dbw->affectedRows() ) {
136 // Remember that the user was in this group
138 'user_former_groups',
139 [ 'ufg_user' => $this->userId
, 'ufg_group' => $this->group
],
147 * Insert a user right membership into the database. When $allowUpdate is false,
148 * the function fails if there is a conflicting membership entry (same user and
149 * group) already in the table.
151 * @throws UnexpectedValueException
152 * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
153 * @param IDatabase|null $dbw If you have one available
154 * @return bool Whether or not anything was inserted
156 public function insert( $allowUpdate = false, IDatabase
$dbw = null ) {
157 if ( $this->group
=== null ) {
158 throw new UnexpectedValueException(
159 'Cannot insert an uninitialized UserGroupMembership instance'
161 } elseif ( $this->userId
<= 0 ) {
162 throw new UnexpectedValueException(
163 'UserGroupMembership::insert() needs a positive user ID. ' .
164 'Perhaps addGroup() was called before the user was added to the database.'
168 $dbw = $dbw ?
: wfGetDB( DB_MASTER
);
169 $row = $this->getDatabaseArray( $dbw );
171 $dbw->startAtomic( __METHOD__
);
172 $dbw->insert( 'user_groups', $row, __METHOD__
, [ 'IGNORE' ] );
173 $affected = $dbw->affectedRows();
175 // Conflicting row already exists; it should be overriden if it is either expired
176 // or if $allowUpdate is true and the current row is different than the loaded row.
177 $conds = [ 'ug_user' => $row['ug_user'], 'ug_group' => $row['ug_group'] ];
178 if ( $allowUpdate ) {
179 // Update the current row if its expiry does not match that of the loaded row
180 $conds[] = $this->expiry
181 ?
'ug_expiry IS NULL OR ug_expiry != ' .
182 $dbw->addQuotes( $dbw->timestamp( $this->expiry
) )
183 : 'ug_expiry IS NOT NULL';
185 // Update the current row if it is expired
186 $conds[] = 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() );
190 [ 'ug_expiry' => $this->expiry ?
$dbw->timestamp( $this->expiry
) : null ],
194 $affected = $dbw->affectedRows();
196 $dbw->endAtomic( __METHOD__
);
198 // Purge old, expired memberships from the DB
200 DeferredUpdates
::addCallableUpdate( function () use ( $dbw, $fname ) {
201 $hasExpiredRow = $dbw->selectField(
204 [ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
207 if ( $hasExpiredRow ) {
208 JobQueueGroup
::singleton()->push( new UserGroupExpiryJob() );
212 return $affected > 0;
216 * Get an array suitable for passing to $dbw->insert() or $dbw->update()
217 * @param IDatabase $db
220 protected function getDatabaseArray( IDatabase
$db ) {
222 'ug_user' => $this->userId
,
223 'ug_group' => $this->group
,
224 'ug_expiry' => $this->expiry ?
$db->timestamp( $this->expiry
) : null,
229 * Has the membership expired?
232 public function isExpired() {
233 if ( !$this->expiry
) {
236 return wfTimestampNow() > $this->expiry
;
240 * Purge expired memberships from the user_groups table
242 * @return int|bool false if purging wasn't attempted (e.g. because of
243 * readonly), the number of rows purged (might be 0) otherwise
245 public static function purgeExpired() {
246 $services = MediaWikiServices
::getInstance();
247 if ( $services->getReadOnlyMode()->isReadOnly() ) {
251 $lbFactory = $services->getDBLoadBalancerFactory();
252 $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__
);
253 $dbw = $services->getDBLoadBalancer()->getConnectionRef( DB_MASTER
);
255 $lockKey = "{$dbw->getDomainID()}:UserGroupMembership:purge"; // per-wiki
256 $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__
, 0 );
257 if ( !$scopedLock ) {
258 return false; // already running
264 $dbw->startAtomic( __METHOD__
);
268 self
::selectFields(),
269 [ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp( $now ) ) ],
271 [ 'FOR UPDATE', 'LIMIT' => 100 ]
274 if ( $res->numRows() > 0 ) {
275 $insertData = []; // array of users/groups to insert to user_former_groups
276 $deleteCond = []; // array for deleting the rows that are to be moved around
277 foreach ( $res as $row ) {
278 $insertData[] = [ 'ufg_user' => $row->ug_user
, 'ufg_group' => $row->ug_group
];
279 $deleteCond[] = $dbw->makeList(
280 [ 'ug_user' => $row->ug_user
, 'ug_group' => $row->ug_group
],
284 // Delete the rows we're about to move
287 $dbw->makeList( $deleteCond, $dbw::LIST_OR
),
290 // Push the groups to user_former_groups
291 $dbw->insert( 'user_former_groups', $insertData, __METHOD__
, [ 'IGNORE' ] );
292 // Count how many rows were purged
293 $purgedRows +
= $res->numRows();
296 $dbw->endAtomic( __METHOD__
);
298 $lbFactory->commitAndWaitForReplication( __METHOD__
, $ticket );
299 } while ( $res->numRows() > 0 );
304 * Returns UserGroupMembership objects for all the groups a user currently
307 * @param int $userId ID of the user to search for
308 * @param IDatabase|null $db Optional database connection
309 * @return UserGroupMembership[] Associative array of (group name => UserGroupMembership object)
311 public static function getMembershipsForUser( $userId, IDatabase
$db = null ) {
313 $db = wfGetDB( DB_REPLICA
);
316 $res = $db->select( 'user_groups',
317 self
::selectFields(),
318 [ 'ug_user' => $userId ],
322 foreach ( $res as $row ) {
323 $ugm = self
::newFromRow( $row );
324 if ( !$ugm->isExpired() ) {
325 $ugms[$ugm->group
] = $ugm;
334 * Returns a UserGroupMembership object that pertains to the given user and group,
335 * or false if the user does not belong to that group (or the assignment has
338 * @param int $userId ID of the user to search for
339 * @param string $group User group name
340 * @param IDatabase|null $db Optional database connection
341 * @return UserGroupMembership|false
343 public static function getMembership( $userId, $group, IDatabase
$db = null ) {
345 $db = wfGetDB( DB_REPLICA
);
348 $row = $db->selectRow( 'user_groups',
349 self
::selectFields(),
350 [ 'ug_user' => $userId, 'ug_group' => $group ],
356 $ugm = self
::newFromRow( $row );
357 if ( !$ugm->isExpired() ) {
364 * Gets a link for a user group, possibly including the expiry date if relevant.
366 * @param string|UserGroupMembership $ugm Either a group name as a string, or
367 * a UserGroupMembership object
368 * @param IContextSource $context
369 * @param string $format Either 'wiki' or 'html'
370 * @param string|null $userName If you want to use the group member message
371 * ("administrator"), pass the name of the user who belongs to the group; it
372 * is used for GENDER of the group member message. If you instead want the
373 * group name message ("Administrators"), omit this parameter.
376 public static function getLink( $ugm, IContextSource
$context, $format,
379 if ( $format !== 'wiki' && $format !== 'html' ) {
380 throw new MWException( 'UserGroupMembership::getLink() $format parameter should be ' .
381 "'wiki' or 'html'" );
384 if ( $ugm instanceof UserGroupMembership
) {
385 $expiry = $ugm->getExpiry();
386 $group = $ugm->getGroup();
392 if ( $userName !== null ) {
393 $groupName = self
::getGroupMemberName( $group, $userName );
395 $groupName = self
::getGroupName( $group );
398 // link to the group description page, if it exists
399 $linkTitle = self
::getGroupPage( $group );
400 $linkRenderer = MediaWikiServices
::getInstance()->getLinkRenderer();
402 if ( $format === 'wiki' ) {
403 $linkPage = $linkTitle->getFullText();
404 $groupLink = "[[$linkPage|$groupName]]";
406 $groupLink = $linkRenderer->makeLink( $linkTitle, $groupName );
409 $groupLink = htmlspecialchars( $groupName );
413 // format the expiry to a nice string
414 $uiLanguage = $context->getLanguage();
415 $uiUser = $context->getUser();
416 $expiryDT = $uiLanguage->userTimeAndDate( $expiry, $uiUser );
417 $expiryD = $uiLanguage->userDate( $expiry, $uiUser );
418 $expiryT = $uiLanguage->userTime( $expiry, $uiUser );
419 if ( $format === 'html' ) {
420 $groupLink = Message
::rawParam( $groupLink );
422 return $context->msg( 'group-membership-link-with-expiry' )
423 ->params( $groupLink, $expiryDT, $expiryD, $expiryT )->text();
429 * Gets the localized friendly name for a group, if it exists. For example,
430 * "Administrators" or "Bureaucrats"
432 * @param string $group Internal group name
433 * @return string Localized friendly group name
435 public static function getGroupName( $group ) {
436 $msg = wfMessage( "group-$group" );
437 return $msg->isBlank() ?
$group : $msg->text();
441 * Gets the localized name for a member of a group, if it exists. For example,
442 * "administrator" or "bureaucrat"
444 * @param string $group Internal group name
445 * @param string $username Username for gender
446 * @return string Localized name for group member
448 public static function getGroupMemberName( $group, $username ) {
449 $msg = wfMessage( "group-$group-member", $username );
450 return $msg->isBlank() ?
$group : $msg->text();
454 * Gets the title of a page describing a particular user group. When the name
455 * of the group appears in the UI, it can link to this page.
457 * @param string $group Internal group name
458 * @return Title|bool Title of the page if it exists, false otherwise
460 public static function getGroupPage( $group ) {
461 $msg = wfMessage( "grouppage-$group" )->inContentLanguage();
462 if ( $msg->exists() ) {
463 $title = Title
::newFromText( $msg->text() );
464 if ( is_object( $title ) ) {