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