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