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