wiki.techinc.nl/includes/user/UserGroupMembership.php
This, that and the other 73224f4f8b User group memberships that expire
This patch adds an ug_expiry column to the user_groups table, a timestamp
giving a date when the user group expires. A new UserGroupMembership class,
based on the Block class, manages entries in this table.

When the expiry date passes, the row in user_groups is ignored, and will
eventually be purged from the DB when UserGroupMembership::insert is next
called. Old, expired user group memberships are not kept; instead, the log
entries are available to find the history of these memberships, similar
to the way it has always worked for blocks and protections.

Anyone getting user group info through the User object will get correct
information. However, code that reads the user_groups table directly will
now need to skip over rows with ug_expiry < wfTimestampNow(). See
UsersPager for an example of how to do this.

NULL is used to represent infinite (no) expiry, rather than a string
'infinity' or similar (except in the API). This allows existing user group
assignments and log entries, which are all infinite in duration, to be
treated the same as new, infinite-length memberships, without special
casing everything.

The whole thing is behind the temporary feature flag
$wgDisableUserGroupExpiry, in accordance with the WMF schema change policy.

The opportunity has been taken to refactor some static user-group-related
functions out of User into UserGroupMembership, and also to add a primary
key (ug_user, ug_group) to the user_groups table.

There are a few breaking changes:
- UserRightsProxy-like objects are now required to have a
  getGroupMemberships() function.
- $user->mGroups (on a User object) is no longer present.
- Some protected functions in UsersPager are altered or removed.
- The UsersPagerDoBatchLookups hook (unused in any Wikimedia Git-hosted
  extension) has a change of parameter.

Bug: T12493
Depends-On: Ia9616e1e35184fed9058d2d39afbe1038f56d7fa
Depends-On: I86eb1d5619347ce54a5f33a591417742ebe5d6f8
Change-Id: I93c955dc7a970f78e32aa503c01c67da30971d1a
2017-01-27 09:24:20 +00:00

475 lines
13 KiB
PHP

<?php
/**
* Represents the membership of a user to a user group.
*
* 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
*/
/**
* Represents a "user group membership" -- a specific instance of a user belonging
* to a group. For example, the fact that user Mary belongs to the sysop group is a
* user group membership.
*
* The class encapsulates rows in the user_groups table. The logic is low-level and
* doesn't run any hooks. Often, you will want to call User::addGroup() or
* User::removeGroup() instead.
*
* @since 1.29
*/
class UserGroupMembership {
/** @var int The ID of the user who belongs to the group */
private $userId;
/** @var string */
private $group;
/** @var string|null Timestamp of expiry in TS_MW format, or null if no expiry */
private $expiry;
/**
* @param int $userId The ID of the user who belongs to the group
* @param string $group The internal group name
* @param string|null $expiry Timestamp of expiry in TS_MW format, or null if no expiry
*/
public function __construct( $userId = 0, $group = null, $expiry = null ) {
global $wgDisableUserGroupExpiry;
if ( $wgDisableUserGroupExpiry ) {
$expiry = null;
}
$this->userId = (int)$userId;
$this->group = $group; // TODO throw on invalid group?
$this->expiry = $expiry ?: null;
}
/**
* @return int
*/
public function getUserId() {
return $this->userId;
}
/**
* @return string
*/
public function getGroup() {
return $this->group;
}
/**
* @return string|null Timestamp of expiry in TS_MW format, or null if no expiry
*/
public function getExpiry() {
global $wgDisableUserGroupExpiry;
if ( $wgDisableUserGroupExpiry ) {
return null;
}
return $this->expiry;
}
protected function initFromRow( $row ) {
global $wgDisableUserGroupExpiry;
$this->userId = (int)$row->ug_user;
$this->group = $row->ug_group;
if ( $wgDisableUserGroupExpiry ) {
$this->expiry = null;
} else {
$this->expiry = $row->ug_expiry === null ?
null :
wfTimestamp( TS_MW, $row->ug_expiry );
}
}
/**
* Creates a new UserGroupMembership object from a database row.
*
* @param stdClass $row The row from the user_groups table
* @return UserGroupMembership
*/
public static function newFromRow( $row ) {
$ugm = new self;
$ugm->initFromRow( $row );
return $ugm;
}
/**
* Returns the list of user_groups fields that should be selected to create
* a new user group membership.
* @return array
*/
public static function selectFields() {
global $wgDisableUserGroupExpiry;
if ( $wgDisableUserGroupExpiry ) {
return [
'ug_user',
'ug_group',
];
} else {
return [
'ug_user',
'ug_group',
'ug_expiry',
];
}
}
/**
* Delete the row from the user_groups table.
*
* @throws MWException
* @param IDatabase|null $dbw Optional master database connection to use
* @return bool Whether or not anything was deleted
*/
public function delete( IDatabase $dbw = null ) {
global $wgDisableUserGroupExpiry;
if ( wfReadOnly() ) {
return false;
}
if ( $dbw === null ) {
$dbw = wfGetDB( DB_MASTER );
}
if ( $wgDisableUserGroupExpiry ) {
$dbw->delete( 'user_groups', $this->getDatabaseArray( $dbw ), __METHOD__ );
} else {
$dbw->delete(
'user_groups',
[ 'ug_user' => $this->userId, 'ug_group' => $this->group ],
__METHOD__ );
}
if ( !$dbw->affectedRows() ) {
return false;
}
// Remember that the user was in this group
$dbw->insert(
'user_former_groups',
[ 'ufg_user' => $this->userId, 'ufg_group' => $this->group ],
__METHOD__,
[ 'IGNORE' ] );
return true;
}
/**
* Insert a user right membership into the database. When $allowUpdate is false,
* the function fails if there is a conflicting membership entry (same user and
* group) already in the table.
*
* @throws MWException
* @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
* @param IDatabase|null $dbw If you have one available
* @return bool Whether or not anything was inserted
*/
public function insert( $allowUpdate = false, IDatabase $dbw = null ) {
global $wgDisableUserGroupExpiry;
if ( $dbw === null ) {
$dbw = wfGetDB( DB_MASTER );
}
// Purge old, expired memberships from the DB
self::purgeExpired( $dbw );
// Check that the values make sense
if ( $this->group === null ) {
throw new UnexpectedValueException(
'Don\'t try inserting an uninitialized UserGroupMembership object' );
} elseif ( $this->userId <= 0 ) {
throw new UnexpectedValueException(
'UserGroupMembership::insert() needs a positive user ID. ' .
'Did you forget to add your User object to the database before calling addGroup()?' );
}
$row = $this->getDatabaseArray( $dbw );
$dbw->insert( 'user_groups', $row, __METHOD__, [ 'IGNORE' ] );
$affected = $dbw->affectedRows();
// Don't collide with expired user group memberships
// Do this after trying to insert, in order to avoid locking
if ( !$wgDisableUserGroupExpiry && !$affected ) {
$conds = [
'ug_user' => $row['ug_user'],
'ug_group' => $row['ug_group'],
];
// if we're unconditionally updating, check that the expiry is not already the
// same as what we are trying to update it to; otherwise, only update if
// the expiry date is in the past
if ( $allowUpdate ) {
if ( $this->expiry ) {
$conds[] = 'ug_expiry IS NULL OR ug_expiry != ' .
$dbw->addQuotes( $dbw->timestamp( $this->expiry ) );
} else {
$conds[] = 'ug_expiry IS NOT NULL';
}
} else {
$conds[] = 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() );
}
$row = $dbw->selectRow( 'user_groups', $this::selectFields(), $conds, __METHOD__ );
if ( $row ) {
$dbw->update(
'user_groups',
[ 'ug_expiry' => $this->expiry ? $dbw->timestamp( $this->expiry ) : null ],
[ 'ug_user' => $row->ug_user, 'ug_group' => $row->ug_group ],
__METHOD__ );
$affected = $dbw->affectedRows();
}
}
return $affected > 0;
}
/**
* Get an array suitable for passing to $dbw->insert() or $dbw->update()
* @param IDatabase $db
* @return array
*/
protected function getDatabaseArray( IDatabase $db ) {
global $wgDisableUserGroupExpiry;
$a = [
'ug_user' => $this->userId,
'ug_group' => $this->group,
];
if ( !$wgDisableUserGroupExpiry ) {
$a['ug_expiry'] = $this->expiry ? $db->timestamp( $this->expiry ) : null;
}
return $a;
}
/**
* Has the membership expired?
* @return bool
*/
public function isExpired() {
global $wgDisableUserGroupExpiry;
if ( $wgDisableUserGroupExpiry || !$this->expiry ) {
return false;
} else {
return wfTimestampNow() > $this->expiry;
}
}
/**
* Purge expired memberships from the user_groups table
*
* @param IDatabase|null $dbw
*/
public static function purgeExpired( IDatabase $dbw = null ) {
global $wgDisableUserGroupExpiry;
if ( $wgDisableUserGroupExpiry || wfReadOnly() ) {
return;
}
if ( $dbw === null ) {
$dbw = wfGetDB( DB_MASTER );
}
DeferredUpdates::addUpdate( new AtomicSectionUpdate(
$dbw,
__METHOD__,
function ( IDatabase $dbw, $fname ) {
$expiryCond = [ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ];
$res = $dbw->select( 'user_groups', self::selectFields(), $expiryCond, $fname );
// save an array of users/groups to insert to user_former_groups
$usersAndGroups = [];
foreach ( $res as $row ) {
$usersAndGroups[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
}
// delete 'em all
$dbw->delete( 'user_groups', $expiryCond, $fname );
// and push the groups to user_former_groups
$dbw->insert( 'user_former_groups', $usersAndGroups, __METHOD__, [ 'IGNORE' ] );
}
) );
}
/**
* Returns UserGroupMembership objects for all the groups a user currently
* belongs to.
*
* @param int $userId ID of the user to search for
* @param IDatabase|null $db Optional database connection
* @return array Associative array of (group name => UserGroupMembership object)
*/
public static function getMembershipsForUser( $userId, $db = null ) {
if ( !$db ) {
$db = wfGetDB( DB_REPLICA );
}
$res = $db->select( 'user_groups',
self::selectFields(),
[ 'ug_user' => $userId ],
__METHOD__ );
$ugms = [];
foreach ( $res as $row ) {
$ugm = self::newFromRow( $row );
if ( !$ugm->isExpired() ) {
$ugms[$ugm->group] = $ugm;
}
}
return $ugms;
}
/**
* Returns a UserGroupMembership object that pertains to the given user and group,
* or false if the user does not belong to that group (or the assignment has
* expired).
*
* @param int $userId ID of the user to search for
* @param string $group User group name
* @param IDatabase|null $db Optional database connection
* @return UserGroupMembership|false
*/
public static function getMembership( $userId, $group, IDatabase $db = null ) {
if ( !$db ) {
$db = wfGetDB( DB_REPLICA );
}
$row = $db->selectRow( 'user_groups',
self::selectFields(),
[ 'ug_user' => $userId, 'ug_group' => $group ],
__METHOD__ );
if ( !$row ) {
return false;
}
$ugm = self::newFromRow( $row );
if ( !$ugm->isExpired() ) {
return $ugm;
} else {
return false;
}
}
/**
* Gets a link for a user group, possibly including the expiry date if relevant.
*
* @param string|UserGroupMembership $ugm Either a group name as a string, or
* a UserGroupMembership object
* @param IContextSource $context
* @param string $format Either 'wiki' or 'html'
* @param string|null $userName If you want to use the group member message
* ("administrator"), pass the name of the user who belongs to the group; it
* is used for GENDER of the group member message. If you instead want the
* group name message ("Administrators"), omit this parameter.
* @return string
*/
public static function getLink( $ugm, IContextSource $context, $format,
$userName = null ) {
if ( $format !== 'wiki' && $format !== 'html' ) {
throw new MWException( 'UserGroupMembership::getLink() $format parameter should be ' .
"'wiki' or 'html'" );
}
if ( $ugm instanceof UserGroupMembership ) {
$expiry = $ugm->getExpiry();
$group = $ugm->getGroup();
} else {
$expiry = null;
$group = $ugm;
}
if ( $userName !== null ) {
$groupName = self::getGroupMemberName( $group, $userName );
} else {
$groupName = self::getGroupName( $group );
}
// link to the group description page, if it exists
$linkTitle = self::getGroupPage( $group );
if ( $linkTitle ) {
if ( $format === 'wiki' ) {
$linkPage = $linkTitle->getFullText();
$groupLink = "[[$linkPage|$groupName]]";
} else {
$groupLink = Linker::link( $linkTitle, htmlspecialchars( $groupName ) );
}
} else {
$groupLink = htmlspecialchars( $groupName );
}
if ( $expiry ) {
// format the expiry to a nice string
$uiLanguage = $context->getLanguage();
$uiUser = $context->getUser();
$expiryDT = $uiLanguage->userTimeAndDate( $expiry, $uiUser );
$expiryD = $uiLanguage->userDate( $expiry, $uiUser );
$expiryT = $uiLanguage->userTime( $expiry, $uiUser );
if ( $format === 'html' ) {
$groupLink = Message::rawParam( $groupLink );
}
return $context->msg( 'group-membership-link-with-expiry' )
->params( $groupLink, $expiryDT, $expiryD, $expiryT )->text();
} else {
return $groupLink;
}
}
/**
* Gets the localized friendly name for a group, if it exists. For example,
* "Administrators" or "Bureaucrats"
*
* @param string $group Internal group name
* @return string Localized friendly group name
*/
public static function getGroupName( $group ) {
$msg = wfMessage( "group-$group" );
return $msg->isBlank() ? $group : $msg->text();
}
/**
* Gets the localized name for a member of a group, if it exists. For example,
* "administrator" or "bureaucrat"
*
* @param string $group Internal group name
* @param string $username Username for gender
* @return string Localized name for group member
*/
public static function getGroupMemberName( $group, $username ) {
$msg = wfMessage( "group-$group-member", $username );
return $msg->isBlank() ? $group : $msg->text();
}
/**
* Gets the title of a page describing a particular user group. When the name
* of the group appears in the UI, it can link to this page.
*
* @param string $group Internal group name
* @return Title|bool Title of the page if it exists, false otherwise
*/
public static function getGroupPage( $group ) {
$msg = wfMessage( "grouppage-$group" )->inContentLanguage();
if ( $msg->exists() ) {
$title = Title::newFromText( $msg->text() );
if ( is_object( $title ) ) {
return $title;
}
}
return false;
}
}