Revised styling of sister-search sidebar.
[lhc/web/wiklou.git] / includes / user / UserGroupMembership.php
1 <?php
2 /**
3 * Represents the membership of a user to a user group.
4 *
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.
9 *
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.
14 *
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
19 *
20 * @file
21 */
22
23 use Wikimedia\Rdbms\IDatabase;
24
25 /**
26 * Represents a "user group membership" -- a specific instance of a user belonging
27 * to a group. For example, the fact that user Mary belongs to the sysop group is a
28 * user group membership.
29 *
30 * The class encapsulates rows in the user_groups table. The logic is low-level and
31 * doesn't run any hooks. Often, you will want to call User::addGroup() or
32 * User::removeGroup() instead.
33 *
34 * @since 1.29
35 */
36 class UserGroupMembership {
37 /** @var int The ID of the user who belongs to the group */
38 private $userId;
39
40 /** @var string */
41 private $group;
42
43 /** @var string|null Timestamp of expiry in TS_MW format, or null if no expiry */
44 private $expiry;
45
46 /**
47 * @param int $userId The ID of the user who belongs to the group
48 * @param string $group The internal group name
49 * @param string|null $expiry Timestamp of expiry in TS_MW format, or null if no expiry
50 */
51 public function __construct( $userId = 0, $group = null, $expiry = null ) {
52 global $wgDisableUserGroupExpiry;
53 if ( $wgDisableUserGroupExpiry ) {
54 $expiry = null;
55 }
56
57 $this->userId = (int)$userId;
58 $this->group = $group; // TODO throw on invalid group?
59 $this->expiry = $expiry ?: null;
60 }
61
62 /**
63 * @return int
64 */
65 public function getUserId() {
66 return $this->userId;
67 }
68
69 /**
70 * @return string
71 */
72 public function getGroup() {
73 return $this->group;
74 }
75
76 /**
77 * @return string|null Timestamp of expiry in TS_MW format, or null if no expiry
78 */
79 public function getExpiry() {
80 global $wgDisableUserGroupExpiry;
81 if ( $wgDisableUserGroupExpiry ) {
82 return null;
83 }
84
85 return $this->expiry;
86 }
87
88 protected function initFromRow( $row ) {
89 global $wgDisableUserGroupExpiry;
90
91 $this->userId = (int)$row->ug_user;
92 $this->group = $row->ug_group;
93 if ( $wgDisableUserGroupExpiry ) {
94 $this->expiry = null;
95 } else {
96 $this->expiry = $row->ug_expiry === null ?
97 null :
98 wfTimestamp( TS_MW, $row->ug_expiry );
99 }
100 }
101
102 /**
103 * Creates a new UserGroupMembership object from a database row.
104 *
105 * @param stdClass $row The row from the user_groups table
106 * @return UserGroupMembership
107 */
108 public static function newFromRow( $row ) {
109 $ugm = new self;
110 $ugm->initFromRow( $row );
111 return $ugm;
112 }
113
114 /**
115 * Returns the list of user_groups fields that should be selected to create
116 * a new user group membership.
117 * @return array
118 */
119 public static function selectFields() {
120 global $wgDisableUserGroupExpiry;
121 if ( $wgDisableUserGroupExpiry ) {
122 return [
123 'ug_user',
124 'ug_group',
125 ];
126 } else {
127 return [
128 'ug_user',
129 'ug_group',
130 'ug_expiry',
131 ];
132 }
133 }
134
135 /**
136 * Delete the row from the user_groups table.
137 *
138 * @throws MWException
139 * @param IDatabase|null $dbw Optional master database connection to use
140 * @return bool Whether or not anything was deleted
141 */
142 public function delete( IDatabase $dbw = null ) {
143 global $wgDisableUserGroupExpiry;
144 if ( wfReadOnly() ) {
145 return false;
146 }
147
148 if ( $dbw === null ) {
149 $dbw = wfGetDB( DB_MASTER );
150 }
151
152 if ( $wgDisableUserGroupExpiry ) {
153 $dbw->delete( 'user_groups', $this->getDatabaseArray( $dbw ), __METHOD__ );
154 } else {
155 $dbw->delete(
156 'user_groups',
157 [ 'ug_user' => $this->userId, 'ug_group' => $this->group ],
158 __METHOD__ );
159 }
160 if ( !$dbw->affectedRows() ) {
161 return false;
162 }
163
164 // Remember that the user was in this group
165 $dbw->insert(
166 'user_former_groups',
167 [ 'ufg_user' => $this->userId, 'ufg_group' => $this->group ],
168 __METHOD__,
169 [ 'IGNORE' ] );
170
171 return true;
172 }
173
174 /**
175 * Insert a user right membership into the database. When $allowUpdate is false,
176 * the function fails if there is a conflicting membership entry (same user and
177 * group) already in the table.
178 *
179 * @throws MWException
180 * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
181 * @param IDatabase|null $dbw If you have one available
182 * @return bool Whether or not anything was inserted
183 */
184 public function insert( $allowUpdate = false, IDatabase $dbw = null ) {
185 global $wgDisableUserGroupExpiry;
186 if ( $dbw === null ) {
187 $dbw = wfGetDB( DB_MASTER );
188 }
189
190 // Purge old, expired memberships from the DB
191 self::purgeExpired( $dbw );
192
193 // Check that the values make sense
194 if ( $this->group === null ) {
195 throw new UnexpectedValueException(
196 'Don\'t try inserting an uninitialized UserGroupMembership object' );
197 } elseif ( $this->userId <= 0 ) {
198 throw new UnexpectedValueException(
199 'UserGroupMembership::insert() needs a positive user ID. ' .
200 'Did you forget to add your User object to the database before calling addGroup()?' );
201 }
202
203 $row = $this->getDatabaseArray( $dbw );
204 $dbw->insert( 'user_groups', $row, __METHOD__, [ 'IGNORE' ] );
205 $affected = $dbw->affectedRows();
206
207 // Don't collide with expired user group memberships
208 // Do this after trying to insert, in order to avoid locking
209 if ( !$wgDisableUserGroupExpiry && !$affected ) {
210 $conds = [
211 'ug_user' => $row['ug_user'],
212 'ug_group' => $row['ug_group'],
213 ];
214 // if we're unconditionally updating, check that the expiry is not already the
215 // same as what we are trying to update it to; otherwise, only update if
216 // the expiry date is in the past
217 if ( $allowUpdate ) {
218 if ( $this->expiry ) {
219 $conds[] = 'ug_expiry IS NULL OR ug_expiry != ' .
220 $dbw->addQuotes( $dbw->timestamp( $this->expiry ) );
221 } else {
222 $conds[] = 'ug_expiry IS NOT NULL';
223 }
224 } else {
225 $conds[] = 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() );
226 }
227
228 $row = $dbw->selectRow( 'user_groups', $this::selectFields(), $conds, __METHOD__ );
229 if ( $row ) {
230 $dbw->update(
231 'user_groups',
232 [ 'ug_expiry' => $this->expiry ? $dbw->timestamp( $this->expiry ) : null ],
233 [ 'ug_user' => $row->ug_user, 'ug_group' => $row->ug_group ],
234 __METHOD__ );
235 $affected = $dbw->affectedRows();
236 }
237 }
238
239 return $affected > 0;
240 }
241
242 /**
243 * Get an array suitable for passing to $dbw->insert() or $dbw->update()
244 * @param IDatabase $db
245 * @return array
246 */
247 protected function getDatabaseArray( IDatabase $db ) {
248 global $wgDisableUserGroupExpiry;
249
250 $a = [
251 'ug_user' => $this->userId,
252 'ug_group' => $this->group,
253 ];
254 if ( !$wgDisableUserGroupExpiry ) {
255 $a['ug_expiry'] = $this->expiry ? $db->timestamp( $this->expiry ) : null;
256 }
257 return $a;
258 }
259
260 /**
261 * Has the membership expired?
262 * @return bool
263 */
264 public function isExpired() {
265 global $wgDisableUserGroupExpiry;
266 if ( $wgDisableUserGroupExpiry || !$this->expiry ) {
267 return false;
268 } else {
269 return wfTimestampNow() > $this->expiry;
270 }
271 }
272
273 /**
274 * Purge expired memberships from the user_groups table
275 *
276 * @param IDatabase|null $dbw
277 */
278 public static function purgeExpired( IDatabase $dbw = null ) {
279 global $wgDisableUserGroupExpiry;
280 if ( $wgDisableUserGroupExpiry || wfReadOnly() ) {
281 return;
282 }
283
284 if ( $dbw === null ) {
285 $dbw = wfGetDB( DB_MASTER );
286 }
287
288 DeferredUpdates::addUpdate( new AtomicSectionUpdate(
289 $dbw,
290 __METHOD__,
291 function ( IDatabase $dbw, $fname ) {
292 $expiryCond = [ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ];
293 $res = $dbw->select( 'user_groups', self::selectFields(), $expiryCond, $fname );
294
295 // save an array of users/groups to insert to user_former_groups
296 $usersAndGroups = [];
297 foreach ( $res as $row ) {
298 $usersAndGroups[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
299 }
300
301 // delete 'em all
302 $dbw->delete( 'user_groups', $expiryCond, $fname );
303
304 // and push the groups to user_former_groups
305 $dbw->insert( 'user_former_groups', $usersAndGroups, __METHOD__, [ 'IGNORE' ] );
306 }
307 ) );
308 }
309
310 /**
311 * Returns UserGroupMembership objects for all the groups a user currently
312 * belongs to.
313 *
314 * @param int $userId ID of the user to search for
315 * @param IDatabase|null $db Optional database connection
316 * @return array Associative array of (group name => UserGroupMembership object)
317 */
318 public static function getMembershipsForUser( $userId, IDatabase $db = null ) {
319 if ( !$db ) {
320 $db = wfGetDB( DB_REPLICA );
321 }
322
323 $res = $db->select( 'user_groups',
324 self::selectFields(),
325 [ 'ug_user' => $userId ],
326 __METHOD__ );
327
328 $ugms = [];
329 foreach ( $res as $row ) {
330 $ugm = self::newFromRow( $row );
331 if ( !$ugm->isExpired() ) {
332 $ugms[$ugm->group] = $ugm;
333 }
334 }
335
336 return $ugms;
337 }
338
339 /**
340 * Returns a UserGroupMembership object that pertains to the given user and group,
341 * or false if the user does not belong to that group (or the assignment has
342 * expired).
343 *
344 * @param int $userId ID of the user to search for
345 * @param string $group User group name
346 * @param IDatabase|null $db Optional database connection
347 * @return UserGroupMembership|false
348 */
349 public static function getMembership( $userId, $group, IDatabase $db = null ) {
350 if ( !$db ) {
351 $db = wfGetDB( DB_REPLICA );
352 }
353
354 $row = $db->selectRow( 'user_groups',
355 self::selectFields(),
356 [ 'ug_user' => $userId, 'ug_group' => $group ],
357 __METHOD__ );
358 if ( !$row ) {
359 return false;
360 }
361
362 $ugm = self::newFromRow( $row );
363 if ( !$ugm->isExpired() ) {
364 return $ugm;
365 } else {
366 return false;
367 }
368 }
369
370 /**
371 * Gets a link for a user group, possibly including the expiry date if relevant.
372 *
373 * @param string|UserGroupMembership $ugm Either a group name as a string, or
374 * a UserGroupMembership object
375 * @param IContextSource $context
376 * @param string $format Either 'wiki' or 'html'
377 * @param string|null $userName If you want to use the group member message
378 * ("administrator"), pass the name of the user who belongs to the group; it
379 * is used for GENDER of the group member message. If you instead want the
380 * group name message ("Administrators"), omit this parameter.
381 * @return string
382 */
383 public static function getLink( $ugm, IContextSource $context, $format,
384 $userName = null ) {
385
386 if ( $format !== 'wiki' && $format !== 'html' ) {
387 throw new MWException( 'UserGroupMembership::getLink() $format parameter should be ' .
388 "'wiki' or 'html'" );
389 }
390
391 if ( $ugm instanceof UserGroupMembership ) {
392 $expiry = $ugm->getExpiry();
393 $group = $ugm->getGroup();
394 } else {
395 $expiry = null;
396 $group = $ugm;
397 }
398
399 if ( $userName !== null ) {
400 $groupName = self::getGroupMemberName( $group, $userName );
401 } else {
402 $groupName = self::getGroupName( $group );
403 }
404
405 // link to the group description page, if it exists
406 $linkTitle = self::getGroupPage( $group );
407 if ( $linkTitle ) {
408 if ( $format === 'wiki' ) {
409 $linkPage = $linkTitle->getFullText();
410 $groupLink = "[[$linkPage|$groupName]]";
411 } else {
412 $groupLink = Linker::link( $linkTitle, htmlspecialchars( $groupName ) );
413 }
414 } else {
415 $groupLink = htmlspecialchars( $groupName );
416 }
417
418 if ( $expiry ) {
419 // format the expiry to a nice string
420 $uiLanguage = $context->getLanguage();
421 $uiUser = $context->getUser();
422 $expiryDT = $uiLanguage->userTimeAndDate( $expiry, $uiUser );
423 $expiryD = $uiLanguage->userDate( $expiry, $uiUser );
424 $expiryT = $uiLanguage->userTime( $expiry, $uiUser );
425 if ( $format === 'html' ) {
426 $groupLink = Message::rawParam( $groupLink );
427 }
428 return $context->msg( 'group-membership-link-with-expiry' )
429 ->params( $groupLink, $expiryDT, $expiryD, $expiryT )->text();
430 } else {
431 return $groupLink;
432 }
433 }
434
435 /**
436 * Gets the localized friendly name for a group, if it exists. For example,
437 * "Administrators" or "Bureaucrats"
438 *
439 * @param string $group Internal group name
440 * @return string Localized friendly group name
441 */
442 public static function getGroupName( $group ) {
443 $msg = wfMessage( "group-$group" );
444 return $msg->isBlank() ? $group : $msg->text();
445 }
446
447 /**
448 * Gets the localized name for a member of a group, if it exists. For example,
449 * "administrator" or "bureaucrat"
450 *
451 * @param string $group Internal group name
452 * @param string $username Username for gender
453 * @return string Localized name for group member
454 */
455 public static function getGroupMemberName( $group, $username ) {
456 $msg = wfMessage( "group-$group-member", $username );
457 return $msg->isBlank() ? $group : $msg->text();
458 }
459
460 /**
461 * Gets the title of a page describing a particular user group. When the name
462 * of the group appears in the UI, it can link to this page.
463 *
464 * @param string $group Internal group name
465 * @return Title|bool Title of the page if it exists, false otherwise
466 */
467 public static function getGroupPage( $group ) {
468 $msg = wfMessage( "grouppage-$group" )->inContentLanguage();
469 if ( $msg->exists() ) {
470 $title = Title::newFromText( $msg->text() );
471 if ( is_object( $title ) ) {
472 return $title;
473 }
474 }
475 return false;
476 }
477 }