UserGroupMembership.php 14 KB

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