SpecialUserrights.php 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062
  1. <?php
  2. /**
  3. * Implements Special:Userrights
  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. * @ingroup SpecialPage
  22. */
  23. use MediaWiki\MediaWikiServices;
  24. /**
  25. * Special page to allow managing user group membership
  26. *
  27. * @ingroup SpecialPage
  28. */
  29. class UserrightsPage extends SpecialPage {
  30. /**
  31. * The target of the local right-adjuster's interest. Can be gotten from
  32. * either a GET parameter or a subpage-style parameter, so have a member
  33. * variable for it.
  34. * @var null|string $mTarget
  35. */
  36. protected $mTarget;
  37. /*
  38. * @var null|User $mFetchedUser The user object of the target username or null.
  39. */
  40. protected $mFetchedUser = null;
  41. protected $isself = false;
  42. public function __construct() {
  43. parent::__construct( 'Userrights' );
  44. }
  45. public function doesWrites() {
  46. return true;
  47. }
  48. /**
  49. * Check whether the current user (from context) can change the target user's rights.
  50. *
  51. * @param User $targetUser User whose rights are being changed
  52. * @param bool $checkIfSelf If false, assume that the current user can add/remove groups defined
  53. * in $wgGroupsAddToSelf / $wgGroupsRemoveFromSelf, without checking if it's the same as target
  54. * user
  55. * @return bool
  56. */
  57. public function userCanChangeRights( $targetUser, $checkIfSelf = true ) {
  58. $isself = $this->getUser()->equals( $targetUser );
  59. $available = $this->changeableGroups();
  60. if ( $targetUser->getId() === 0 ) {
  61. return false;
  62. }
  63. if ( $available['add'] || $available['remove'] ) {
  64. // can change some rights for any user
  65. return true;
  66. }
  67. if ( ( $available['add-self'] || $available['remove-self'] )
  68. && ( $isself || !$checkIfSelf )
  69. ) {
  70. // can change some rights for self
  71. return true;
  72. }
  73. return false;
  74. }
  75. /**
  76. * Manage forms to be shown according to posted data.
  77. * Depending on the submit button used, call a form or a save function.
  78. *
  79. * @param string|null $par String if any subpage provided, else null
  80. * @throws UserBlockedError|PermissionsError
  81. * @suppress PhanUndeclaredMethod
  82. */
  83. public function execute( $par ) {
  84. $user = $this->getUser();
  85. $request = $this->getRequest();
  86. $session = $request->getSession();
  87. $out = $this->getOutput();
  88. $out->addModules( [ 'mediawiki.special.userrights' ] );
  89. $this->mTarget = $par ?? $request->getVal( 'user' );
  90. if ( is_string( $this->mTarget ) ) {
  91. $this->mTarget = trim( $this->mTarget );
  92. }
  93. if ( $this->mTarget !== null && User::getCanonicalName( $this->mTarget ) === $user->getName() ) {
  94. $this->isself = true;
  95. }
  96. $fetchedStatus = $this->fetchUser( $this->mTarget, true );
  97. if ( $fetchedStatus->isOK() ) {
  98. $this->mFetchedUser = $fetchedStatus->value;
  99. if ( $this->mFetchedUser instanceof User ) {
  100. // Set the 'relevant user' in the skin, so it displays links like Contributions,
  101. // User logs, UserRights, etc.
  102. $this->getSkin()->setRelevantUser( $this->mFetchedUser );
  103. }
  104. }
  105. // show a successbox, if the user rights was saved successfully
  106. if (
  107. $session->get( 'specialUserrightsSaveSuccess' ) &&
  108. $this->mFetchedUser !== null
  109. ) {
  110. // Remove session data for the success message
  111. $session->remove( 'specialUserrightsSaveSuccess' );
  112. $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
  113. $out->addHTML(
  114. Html::rawElement(
  115. 'div',
  116. [
  117. 'class' => 'mw-notify-success successbox',
  118. 'id' => 'mw-preferences-success',
  119. 'data-mw-autohide' => 'false',
  120. ],
  121. Html::element(
  122. 'p',
  123. [],
  124. $this->msg( 'savedrights', $this->mFetchedUser->getName() )->text()
  125. )
  126. )
  127. );
  128. }
  129. $this->setHeaders();
  130. $this->outputHeader();
  131. $out->addModuleStyles( 'mediawiki.special' );
  132. $this->addHelpLink( 'Help:Assigning permissions' );
  133. $this->switchForm();
  134. if (
  135. $request->wasPosted() &&
  136. $request->getCheck( 'saveusergroups' ) &&
  137. $this->mTarget !== null &&
  138. $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget )
  139. ) {
  140. /*
  141. * If the user is blocked and they only have "partial" access
  142. * (e.g. they don't have the userrights permission), then don't
  143. * allow them to change any user rights.
  144. */
  145. if ( !MediaWikiServices::getInstance()
  146. ->getPermissionManager()
  147. ->userHasRight( $user, 'userrights' )
  148. ) {
  149. $block = $user->getBlock();
  150. if ( $block && $block->isSitewide() ) {
  151. throw new UserBlockedError( $block );
  152. }
  153. }
  154. $this->checkReadOnly();
  155. // save settings
  156. if ( !$fetchedStatus->isOK() ) {
  157. $this->getOutput()->addWikiTextAsInterface(
  158. $fetchedStatus->getWikiText( false, false, $this->getLanguage() )
  159. );
  160. return;
  161. }
  162. $targetUser = $this->mFetchedUser;
  163. if ( $targetUser instanceof User ) { // UserRightsProxy doesn't have this method (T63252)
  164. $targetUser->clearInstanceCache(); // T40989
  165. }
  166. $conflictCheck = $request->getVal( 'conflictcheck-originalgroups' );
  167. $conflictCheck = ( $conflictCheck === '' ) ? [] : explode( ',', $conflictCheck );
  168. $userGroups = $targetUser->getGroups();
  169. if ( $userGroups !== $conflictCheck ) {
  170. $out->wrapWikiMsg( '<span class="error">$1</span>', 'userrights-conflict' );
  171. } else {
  172. $status = $this->saveUserGroups(
  173. $this->mTarget,
  174. $request->getVal( 'user-reason' ),
  175. $targetUser
  176. );
  177. if ( $status->isOK() ) {
  178. // Set session data for the success message
  179. $session->set( 'specialUserrightsSaveSuccess', 1 );
  180. $out->redirect( $this->getSuccessURL() );
  181. return;
  182. } else {
  183. // Print an error message and redisplay the form
  184. $out->wrapWikiTextAsInterface(
  185. 'error', $status->getWikiText( false, false, $this->getLanguage() )
  186. );
  187. }
  188. }
  189. }
  190. // show some more forms
  191. if ( $this->mTarget !== null ) {
  192. $this->editUserGroupsForm( $this->mTarget );
  193. }
  194. }
  195. function getSuccessURL() {
  196. return $this->getPageTitle( $this->mTarget )->getFullURL();
  197. }
  198. /**
  199. * Returns true if this user rights form can set and change user group expiries.
  200. * Subclasses may wish to override this to return false.
  201. *
  202. * @return bool
  203. */
  204. public function canProcessExpiries() {
  205. return true;
  206. }
  207. /**
  208. * Converts a user group membership expiry string into a timestamp. Words like
  209. * 'existing' or 'other' should have been filtered out before calling this
  210. * function.
  211. *
  212. * @param string $expiry
  213. * @return string|null|false A string containing a valid timestamp, or null
  214. * if the expiry is infinite, or false if the timestamp is not valid
  215. */
  216. public static function expiryToTimestamp( $expiry ) {
  217. if ( wfIsInfinity( $expiry ) ) {
  218. return null;
  219. }
  220. $unix = strtotime( $expiry );
  221. if ( !$unix || $unix === -1 ) {
  222. return false;
  223. }
  224. // @todo FIXME: Non-qualified absolute times are not in users specified timezone
  225. // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
  226. return wfTimestamp( TS_MW, $unix );
  227. }
  228. /**
  229. * Save user groups changes in the database.
  230. * Data comes from the editUserGroupsForm() form function
  231. *
  232. * @param string $username Username to apply changes to.
  233. * @param string $reason Reason for group change
  234. * @param User|UserRightsProxy $user Target user object.
  235. * @return Status
  236. */
  237. protected function saveUserGroups( $username, $reason, $user ) {
  238. $allgroups = $this->getAllGroups();
  239. $addgroup = [];
  240. $groupExpiries = []; // associative array of (group name => expiry)
  241. $removegroup = [];
  242. $existingUGMs = $user->getGroupMemberships();
  243. // This could possibly create a highly unlikely race condition if permissions are changed between
  244. // when the form is loaded and when the form is saved. Ignoring it for the moment.
  245. foreach ( $allgroups as $group ) {
  246. // We'll tell it to remove all unchecked groups, and add all checked groups.
  247. // Later on, this gets filtered for what can actually be removed
  248. if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
  249. $addgroup[] = $group;
  250. if ( $this->canProcessExpiries() ) {
  251. // read the expiry information from the request
  252. $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
  253. if ( $expiryDropdown === 'existing' ) {
  254. continue;
  255. }
  256. if ( $expiryDropdown === 'other' ) {
  257. $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
  258. } else {
  259. $expiryValue = $expiryDropdown;
  260. }
  261. // validate the expiry
  262. $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue );
  263. if ( $groupExpiries[$group] === false ) {
  264. return Status::newFatal( 'userrights-invalid-expiry', $group );
  265. }
  266. // not allowed to have things expiring in the past
  267. if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
  268. return Status::newFatal( 'userrights-expiry-in-past', $group );
  269. }
  270. // if the user can only add this group (not remove it), the expiry time
  271. // cannot be brought forward (T156784)
  272. if ( !$this->canRemove( $group ) &&
  273. isset( $existingUGMs[$group] ) &&
  274. ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) >
  275. ( $groupExpiries[$group] ?: 'infinity' )
  276. ) {
  277. return Status::newFatal( 'userrights-cannot-shorten-expiry', $group );
  278. }
  279. }
  280. } else {
  281. $removegroup[] = $group;
  282. }
  283. }
  284. $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
  285. return Status::newGood();
  286. }
  287. /**
  288. * Save user groups changes in the database. This function does not throw errors;
  289. * instead, it ignores groups that the performer does not have permission to set.
  290. *
  291. * @param User|UserRightsProxy $user
  292. * @param array $add Array of groups to add
  293. * @param array $remove Array of groups to remove
  294. * @param string $reason Reason for group change
  295. * @param array $tags Array of change tags to add to the log entry
  296. * @param array $groupExpiries Associative array of (group name => expiry),
  297. * containing only those groups that are to have new expiry values set
  298. * @return array Tuple of added, then removed groups
  299. */
  300. function doSaveUserGroups( $user, array $add, array $remove, $reason = '',
  301. array $tags = [], array $groupExpiries = []
  302. ) {
  303. // Validate input set...
  304. $isself = $user->getName() == $this->getUser()->getName();
  305. $groups = $user->getGroups();
  306. $ugms = $user->getGroupMemberships();
  307. $changeable = $this->changeableGroups();
  308. $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
  309. $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );
  310. $remove = array_unique(
  311. array_intersect( (array)$remove, $removable, $groups ) );
  312. $add = array_intersect( (array)$add, $addable );
  313. // add only groups that are not already present or that need their expiry updated,
  314. // UNLESS the user can only add this group (not remove it) and the expiry time
  315. // is being brought forward (T156784)
  316. $add = array_filter( $add,
  317. function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
  318. if ( isset( $groupExpiries[$group] ) &&
  319. !in_array( $group, $removable ) &&
  320. isset( $ugms[$group] ) &&
  321. ( $ugms[$group]->getExpiry() ?: 'infinity' ) >
  322. ( $groupExpiries[$group] ?: 'infinity' )
  323. ) {
  324. return false;
  325. }
  326. return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
  327. } );
  328. Hooks::run( 'ChangeUserGroups', [ $this->getUser(), $user, &$add, &$remove ] );
  329. $oldGroups = $groups;
  330. $oldUGMs = $user->getGroupMemberships();
  331. $newGroups = $oldGroups;
  332. // Remove groups, then add new ones/update expiries of existing ones
  333. if ( $remove ) {
  334. foreach ( $remove as $index => $group ) {
  335. if ( !$user->removeGroup( $group ) ) {
  336. unset( $remove[$index] );
  337. }
  338. }
  339. $newGroups = array_diff( $newGroups, $remove );
  340. }
  341. if ( $add ) {
  342. foreach ( $add as $index => $group ) {
  343. $expiry = $groupExpiries[$group] ?? null;
  344. if ( !$user->addGroup( $group, $expiry ) ) {
  345. unset( $add[$index] );
  346. }
  347. }
  348. $newGroups = array_merge( $newGroups, $add );
  349. }
  350. $newGroups = array_unique( $newGroups );
  351. $newUGMs = $user->getGroupMemberships();
  352. // Ensure that caches are cleared
  353. $user->invalidateCache();
  354. // update groups in external authentication database
  355. Hooks::run( 'UserGroupsChanged', [ $user, $add, $remove, $this->getUser(),
  356. $reason, $oldUGMs, $newUGMs ] );
  357. wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) . "\n" );
  358. wfDebug( 'newGroups: ' . print_r( $newGroups, true ) . "\n" );
  359. wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) . "\n" );
  360. wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) . "\n" );
  361. // Only add a log entry if something actually changed
  362. if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
  363. $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
  364. }
  365. return [ $add, $remove ];
  366. }
  367. /**
  368. * Serialise a UserGroupMembership object for storage in the log_params section
  369. * of the logging table. Only keeps essential data, removing redundant fields.
  370. *
  371. * @param UserGroupMembership|null $ugm May be null if things get borked
  372. * @return array
  373. */
  374. protected static function serialiseUgmForLog( $ugm ) {
  375. if ( !$ugm instanceof UserGroupMembership ) {
  376. return null;
  377. }
  378. return [ 'expiry' => $ugm->getExpiry() ];
  379. }
  380. /**
  381. * Add a rights log entry for an action.
  382. * @param User|UserRightsProxy $user
  383. * @param array $oldGroups
  384. * @param array $newGroups
  385. * @param string $reason
  386. * @param array $tags Change tags for the log entry
  387. * @param array $oldUGMs Associative array of (group name => UserGroupMembership)
  388. * @param array $newUGMs Associative array of (group name => UserGroupMembership)
  389. */
  390. protected function addLogEntry( $user, array $oldGroups, array $newGroups, $reason,
  391. array $tags, array $oldUGMs, array $newUGMs
  392. ) {
  393. // make sure $oldUGMs and $newUGMs are in the same order, and serialise
  394. // each UGM object to a simplified array
  395. $oldUGMs = array_map( function ( $group ) use ( $oldUGMs ) {
  396. return isset( $oldUGMs[$group] ) ?
  397. self::serialiseUgmForLog( $oldUGMs[$group] ) :
  398. null;
  399. }, $oldGroups );
  400. $newUGMs = array_map( function ( $group ) use ( $newUGMs ) {
  401. return isset( $newUGMs[$group] ) ?
  402. self::serialiseUgmForLog( $newUGMs[$group] ) :
  403. null;
  404. }, $newGroups );
  405. $logEntry = new ManualLogEntry( 'rights', 'rights' );
  406. $logEntry->setPerformer( $this->getUser() );
  407. $logEntry->setTarget( $user->getUserPage() );
  408. $logEntry->setComment( $reason );
  409. $logEntry->setParameters( [
  410. '4::oldgroups' => $oldGroups,
  411. '5::newgroups' => $newGroups,
  412. 'oldmetadata' => $oldUGMs,
  413. 'newmetadata' => $newUGMs,
  414. ] );
  415. $logid = $logEntry->insert();
  416. if ( count( $tags ) ) {
  417. $logEntry->addTags( $tags );
  418. }
  419. $logEntry->publish( $logid );
  420. }
  421. /**
  422. * Edit user groups membership
  423. * @param string $username Name of the user.
  424. */
  425. function editUserGroupsForm( $username ) {
  426. $status = $this->fetchUser( $username, true );
  427. if ( !$status->isOK() ) {
  428. $this->getOutput()->addWikiTextAsInterface(
  429. $status->getWikiText( false, false, $this->getLanguage() )
  430. );
  431. return;
  432. }
  433. /** @var User $user */
  434. $user = $status->value;
  435. '@phan-var User $user';
  436. $groups = $user->getGroups();
  437. $groupMemberships = $user->getGroupMemberships();
  438. $this->showEditUserGroupsForm( $user, $groups, $groupMemberships );
  439. // This isn't really ideal logging behavior, but let's not hide the
  440. // interwiki logs if we're using them as is.
  441. $this->showLogFragment( $user, $this->getOutput() );
  442. }
  443. /**
  444. * Normalize the input username, which may be local or remote, and
  445. * return a user (or proxy) object for manipulating it.
  446. *
  447. * Side effects: error output for invalid access
  448. * @param string $username
  449. * @param bool $writing
  450. * @return Status
  451. */
  452. public function fetchUser( $username, $writing = true ) {
  453. $parts = explode( $this->getConfig()->get( 'UserrightsInterwikiDelimiter' ), $username );
  454. if ( count( $parts ) < 2 ) {
  455. $name = trim( $username );
  456. $dbDomain = '';
  457. } else {
  458. list( $name, $dbDomain ) = array_map( 'trim', $parts );
  459. if ( WikiMap::isCurrentWikiId( $dbDomain ) ) {
  460. $dbDomain = '';
  461. } else {
  462. if ( $writing && !MediaWikiServices::getInstance()
  463. ->getPermissionManager()
  464. ->userHasRight( $this->getUser(), 'userrights-interwiki' )
  465. ) {
  466. return Status::newFatal( 'userrights-no-interwiki' );
  467. }
  468. if ( !UserRightsProxy::validDatabase( $dbDomain ) ) {
  469. return Status::newFatal( 'userrights-nodatabase', $dbDomain );
  470. }
  471. }
  472. }
  473. if ( $name === '' ) {
  474. return Status::newFatal( 'nouserspecified' );
  475. }
  476. if ( $name[0] == '#' ) {
  477. // Numeric ID can be specified...
  478. // We'll do a lookup for the name internally.
  479. $id = intval( substr( $name, 1 ) );
  480. if ( $dbDomain == '' ) {
  481. $name = User::whoIs( $id );
  482. } else {
  483. $name = UserRightsProxy::whoIs( $dbDomain, $id );
  484. }
  485. if ( !$name ) {
  486. return Status::newFatal( 'noname' );
  487. }
  488. } else {
  489. $name = User::getCanonicalName( $name );
  490. if ( $name === false ) {
  491. // invalid name
  492. return Status::newFatal( 'nosuchusershort', $username );
  493. }
  494. }
  495. if ( $dbDomain == '' ) {
  496. $user = User::newFromName( $name );
  497. } else {
  498. $user = UserRightsProxy::newFromName( $dbDomain, $name );
  499. }
  500. if ( !$user || $user->isAnon() ) {
  501. return Status::newFatal( 'nosuchusershort', $username );
  502. }
  503. return Status::newGood( $user );
  504. }
  505. /**
  506. * @since 1.15
  507. *
  508. * @param array $ids
  509. *
  510. * @return string
  511. */
  512. public function makeGroupNameList( $ids ) {
  513. if ( empty( $ids ) ) {
  514. return $this->msg( 'rightsnone' )->inContentLanguage()->text();
  515. } else {
  516. return implode( ', ', $ids );
  517. }
  518. }
  519. /**
  520. * Output a form to allow searching for a user
  521. */
  522. function switchForm() {
  523. $this->getOutput()->addModules( 'mediawiki.userSuggest' );
  524. $this->getOutput()->addHTML(
  525. Html::openElement(
  526. 'form',
  527. [
  528. 'method' => 'get',
  529. 'action' => wfScript(),
  530. 'name' => 'uluser',
  531. 'id' => 'mw-userrights-form1'
  532. ]
  533. ) .
  534. Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
  535. Xml::fieldset( $this->msg( 'userrights-lookup-user' )->text() ) .
  536. Xml::inputLabel(
  537. $this->msg( 'userrights-user-editname' )->text(),
  538. 'user',
  539. 'username',
  540. 30,
  541. str_replace( '_', ' ', $this->mTarget ),
  542. [
  543. 'class' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
  544. ] + (
  545. // Set autofocus on blank input and error input
  546. $this->mFetchedUser === null ? [ 'autofocus' => '' ] : []
  547. )
  548. ) . ' ' .
  549. Xml::submitButton(
  550. $this->msg( 'editusergroup' )->text()
  551. ) .
  552. Html::closeElement( 'fieldset' ) .
  553. Html::closeElement( 'form' ) . "\n"
  554. );
  555. }
  556. /**
  557. * Show the form to edit group memberships.
  558. *
  559. * @param User|UserRightsProxy $user User or UserRightsProxy you're editing
  560. * @param array $groups Array of groups the user is in. Not used by this implementation
  561. * anymore, but kept for backward compatibility with subclasses
  562. * @param array $groupMemberships Associative array of (group name => UserGroupMembership
  563. * object) containing the groups the user is in
  564. */
  565. protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) {
  566. $list = $membersList = $tempList = $tempMembersList = [];
  567. foreach ( $groupMemberships as $ugm ) {
  568. $linkG = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html' );
  569. $linkM = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html',
  570. $user->getName() );
  571. if ( $ugm->getExpiry() ) {
  572. $tempList[] = $linkG;
  573. $tempMembersList[] = $linkM;
  574. } else {
  575. $list[] = $linkG;
  576. $membersList[] = $linkM;
  577. }
  578. }
  579. $autoList = [];
  580. $autoMembersList = [];
  581. if ( $user instanceof User ) {
  582. foreach ( Autopromote::getAutopromoteGroups( $user ) as $group ) {
  583. $autoList[] = UserGroupMembership::getLink( $group, $this->getContext(), 'html' );
  584. $autoMembersList[] = UserGroupMembership::getLink( $group, $this->getContext(),
  585. 'html', $user->getName() );
  586. }
  587. }
  588. $language = $this->getLanguage();
  589. $displayedList = $this->msg( 'userrights-groupsmember-type' )
  590. ->rawParams(
  591. $language->commaList( array_merge( $tempList, $list ) ),
  592. $language->commaList( array_merge( $tempMembersList, $membersList ) )
  593. )->escaped();
  594. $displayedAutolist = $this->msg( 'userrights-groupsmember-type' )
  595. ->rawParams(
  596. $language->commaList( $autoList ),
  597. $language->commaList( $autoMembersList )
  598. )->escaped();
  599. $grouplist = '';
  600. $count = count( $list ) + count( $tempList );
  601. if ( $count > 0 ) {
  602. $grouplist = $this->msg( 'userrights-groupsmember' )
  603. ->numParams( $count )
  604. ->params( $user->getName() )
  605. ->parse();
  606. $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n";
  607. }
  608. $count = count( $autoList );
  609. if ( $count > 0 ) {
  610. $autogrouplistintro = $this->msg( 'userrights-groupsmember-auto' )
  611. ->numParams( $count )
  612. ->params( $user->getName() )
  613. ->parse();
  614. $grouplist .= '<p>' . $autogrouplistintro . ' ' . $displayedAutolist . "</p>\n";
  615. }
  616. $userToolLinks = Linker::userToolLinks(
  617. $user->getId(),
  618. $user->getName(),
  619. false, /* default for redContribsWhenNoEdits */
  620. Linker::TOOL_LINKS_EMAIL /* Add "send e-mail" link */
  621. );
  622. list( $groupCheckboxes, $canChangeAny ) =
  623. $this->groupCheckboxes( $groupMemberships, $user );
  624. $this->getOutput()->addHTML(
  625. Xml::openElement(
  626. 'form',
  627. [
  628. 'method' => 'post',
  629. 'action' => $this->getPageTitle()->getLocalURL(),
  630. 'name' => 'editGroup',
  631. 'id' => 'mw-userrights-form2'
  632. ]
  633. ) .
  634. Html::hidden( 'user', $this->mTarget ) .
  635. Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) .
  636. Html::hidden(
  637. 'conflictcheck-originalgroups',
  638. implode( ',', $user->getGroups() )
  639. ) . // Conflict detection
  640. Xml::openElement( 'fieldset' ) .
  641. Xml::element(
  642. 'legend',
  643. [],
  644. $this->msg(
  645. $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup',
  646. $user->getName()
  647. )->text()
  648. ) .
  649. $this->msg(
  650. $canChangeAny ? 'editinguser' : 'viewinguserrights'
  651. )->params( wfEscapeWikiText( $user->getName() ) )
  652. ->rawParams( $userToolLinks )->parse()
  653. );
  654. if ( $canChangeAny ) {
  655. $this->getOutput()->addHTML(
  656. $this->msg( 'userrights-groups-help', $user->getName() )->parse() .
  657. $grouplist .
  658. $groupCheckboxes .
  659. Xml::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
  660. "<tr>
  661. <td class='mw-label'>" .
  662. Xml::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) .
  663. "</td>
  664. <td class='mw-input'>" .
  665. Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason', false ), [
  666. 'id' => 'wpReason',
  667. // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
  668. // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
  669. // Unicode codepoints.
  670. 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
  671. ] ) .
  672. "</td>
  673. </tr>
  674. <tr>
  675. <td></td>
  676. <td class='mw-submit'>" .
  677. Xml::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(),
  678. [ 'name' => 'saveusergroups' ] +
  679. Linker::tooltipAndAccesskeyAttribs( 'userrights-set' )
  680. ) .
  681. "</td>
  682. </tr>" .
  683. Xml::closeElement( 'table' ) . "\n"
  684. );
  685. } else {
  686. $this->getOutput()->addHTML( $grouplist );
  687. }
  688. $this->getOutput()->addHTML(
  689. Xml::closeElement( 'fieldset' ) .
  690. Xml::closeElement( 'form' ) . "\n"
  691. );
  692. }
  693. /**
  694. * Returns an array of all groups that may be edited
  695. * @return array Array of groups that may be edited.
  696. */
  697. protected static function getAllGroups() {
  698. return User::getAllGroups();
  699. }
  700. /**
  701. * Adds a table with checkboxes where you can select what groups to add/remove
  702. *
  703. * @param UserGroupMembership[] $usergroups Associative array of (group name as string =>
  704. * UserGroupMembership object) for groups the user belongs to
  705. * @param User $user
  706. * @return array Array with 2 elements: the XHTML table element with checkxboes, and
  707. * whether any groups are changeable
  708. */
  709. private function groupCheckboxes( $usergroups, $user ) {
  710. $allgroups = $this->getAllGroups();
  711. $ret = '';
  712. // Get the list of preset expiry times from the system message
  713. $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
  714. $expiryOptions = $expiryOptionsMsg->isDisabled() ?
  715. [] :
  716. explode( ',', $expiryOptionsMsg->text() );
  717. // Put all column info into an associative array so that extensions can
  718. // more easily manage it.
  719. $columns = [ 'unchangeable' => [], 'changeable' => [] ];
  720. foreach ( $allgroups as $group ) {
  721. $set = isset( $usergroups[$group] );
  722. // Users who can add the group, but not remove it, can only lengthen
  723. // expiries, not shorten them. So they should only see the expiry
  724. // dropdown if the group currently has a finite expiry
  725. $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
  726. !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
  727. // Should the checkbox be disabled?
  728. $disabledCheckbox = !(
  729. ( $set && $this->canRemove( $group ) ) ||
  730. ( !$set && $this->canAdd( $group ) ) );
  731. // Should the expiry elements be disabled?
  732. $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
  733. // Do we need to point out that this action is irreversible?
  734. $irreversible = !$disabledCheckbox && (
  735. ( $set && !$this->canAdd( $group ) ) ||
  736. ( !$set && !$this->canRemove( $group ) ) );
  737. $checkbox = [
  738. 'set' => $set,
  739. 'disabled' => $disabledCheckbox,
  740. 'disabled-expiry' => $disabledExpiry,
  741. 'irreversible' => $irreversible
  742. ];
  743. if ( $disabledCheckbox && $disabledExpiry ) {
  744. $columns['unchangeable'][$group] = $checkbox;
  745. } else {
  746. $columns['changeable'][$group] = $checkbox;
  747. }
  748. }
  749. // Build the HTML table
  750. $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
  751. "<tr>\n";
  752. foreach ( $columns as $name => $column ) {
  753. if ( $column === [] ) {
  754. continue;
  755. }
  756. // Messages: userrights-changeable-col, userrights-unchangeable-col
  757. $ret .= Xml::element(
  758. 'th',
  759. null,
  760. $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text()
  761. );
  762. }
  763. $ret .= "</tr>\n<tr>\n";
  764. foreach ( $columns as $column ) {
  765. if ( $column === [] ) {
  766. continue;
  767. }
  768. $ret .= "\t<td style='vertical-align:top;'>\n";
  769. foreach ( $column as $group => $checkbox ) {
  770. $attr = [ 'class' => 'mw-userrights-groupcheckbox' ];
  771. if ( $checkbox['disabled'] ) {
  772. $attr['disabled'] = 'disabled';
  773. }
  774. $member = UserGroupMembership::getGroupMemberName( $group, $user->getName() );
  775. if ( $checkbox['irreversible'] ) {
  776. $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
  777. } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
  778. $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
  779. } else {
  780. $text = $member;
  781. }
  782. $checkboxHtml = Xml::checkLabel( $text, "wpGroup-" . $group,
  783. "wpGroup-" . $group, $checkbox['set'], $attr );
  784. if ( $this->canProcessExpiries() ) {
  785. $uiUser = $this->getUser();
  786. $uiLanguage = $this->getLanguage();
  787. $currentExpiry = isset( $usergroups[$group] ) ?
  788. $usergroups[$group]->getExpiry() :
  789. null;
  790. // If the user can't modify the expiry, print the current expiry below
  791. // it in plain text. Otherwise provide UI to set/change the expiry
  792. if ( $checkbox['set'] &&
  793. ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] )
  794. ) {
  795. if ( $currentExpiry ) {
  796. $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
  797. $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
  798. $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser );
  799. $expiryHtml = $this->msg( 'userrights-expiry-current' )->params(
  800. $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text();
  801. } else {
  802. $expiryHtml = $this->msg( 'userrights-expiry-none' )->text();
  803. }
  804. // T171345: Add a hidden form element so that other groups can still be manipulated,
  805. // otherwise saving errors out with an invalid expiry time for this group.
  806. $expiryHtml .= Html::hidden( "wpExpiry-$group",
  807. $currentExpiry ? 'existing' : 'infinite' );
  808. $expiryHtml .= "<br />\n";
  809. } else {
  810. $expiryHtml = Xml::element( 'span', null,
  811. $this->msg( 'userrights-expiry' )->text() );
  812. $expiryHtml .= Xml::openElement( 'span' );
  813. // add a form element to set the expiry date
  814. $expiryFormOptions = new XmlSelect(
  815. "wpExpiry-$group",
  816. "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
  817. $currentExpiry ? 'existing' : 'infinite'
  818. );
  819. if ( $checkbox['disabled-expiry'] ) {
  820. $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
  821. }
  822. if ( $currentExpiry ) {
  823. $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
  824. $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
  825. $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
  826. $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
  827. $timestamp, $d, $t );
  828. $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
  829. }
  830. $expiryFormOptions->addOption(
  831. $this->msg( 'userrights-expiry-none' )->text(),
  832. 'infinite'
  833. );
  834. $expiryFormOptions->addOption(
  835. $this->msg( 'userrights-expiry-othertime' )->text(),
  836. 'other'
  837. );
  838. foreach ( $expiryOptions as $option ) {
  839. if ( strpos( $option, ":" ) === false ) {
  840. $displayText = $value = $option;
  841. } else {
  842. list( $displayText, $value ) = explode( ":", $option );
  843. }
  844. $expiryFormOptions->addOption( $displayText, htmlspecialchars( $value ) );
  845. }
  846. // Add expiry dropdown
  847. $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
  848. // Add custom expiry field
  849. $attribs = [
  850. 'id' => "mw-input-wpExpiry-$group-other",
  851. 'class' => 'mw-userrights-expiryfield',
  852. ];
  853. if ( $checkbox['disabled-expiry'] ) {
  854. $attribs['disabled'] = 'disabled';
  855. }
  856. $expiryHtml .= Xml::input( "wpExpiry-$group-other", 30, '', $attribs );
  857. // If the user group is set but the checkbox is disabled, mimic a
  858. // checked checkbox in the form submission
  859. if ( $checkbox['set'] && $checkbox['disabled'] ) {
  860. $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
  861. }
  862. $expiryHtml .= Xml::closeElement( 'span' );
  863. }
  864. $divAttribs = [
  865. 'id' => "mw-userrights-nested-wpGroup-$group",
  866. 'class' => 'mw-userrights-nested',
  867. ];
  868. $checkboxHtml .= "\t\t\t" . Xml::tags( 'div', $divAttribs, $expiryHtml ) . "\n";
  869. }
  870. $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
  871. ? Xml::tags( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
  872. : Xml::tags( 'div', [], $checkboxHtml )
  873. ) . "\n";
  874. }
  875. $ret .= "\t</td>\n";
  876. }
  877. $ret .= Xml::closeElement( 'tr' ) . Xml::closeElement( 'table' );
  878. return [ $ret, (bool)$columns['changeable'] ];
  879. }
  880. /**
  881. * @param string $group The name of the group to check
  882. * @return bool Can we remove the group?
  883. */
  884. private function canRemove( $group ) {
  885. $groups = $this->changeableGroups();
  886. return in_array(
  887. $group,
  888. $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] )
  889. );
  890. }
  891. /**
  892. * @param string $group The name of the group to check
  893. * @return bool Can we add the group?
  894. */
  895. private function canAdd( $group ) {
  896. $groups = $this->changeableGroups();
  897. return in_array(
  898. $group,
  899. $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] )
  900. );
  901. }
  902. /**
  903. * Returns $this->getUser()->changeableGroups()
  904. *
  905. * @return array [
  906. * 'add' => [ addablegroups ],
  907. * 'remove' => [ removablegroups ],
  908. * 'add-self' => [ addablegroups to self ],
  909. * 'remove-self' => [ removable groups from self ]
  910. * ]
  911. */
  912. function changeableGroups() {
  913. return $this->getUser()->changeableGroups();
  914. }
  915. /**
  916. * Show a rights log fragment for the specified user
  917. *
  918. * @param User $user User to show log for
  919. * @param OutputPage $output OutputPage to use
  920. */
  921. protected function showLogFragment( $user, $output ) {
  922. $rightsLogPage = new LogPage( 'rights' );
  923. $output->addHTML( Xml::element( 'h2', null, $rightsLogPage->getName()->text() ) );
  924. LogEventsList::showLogExtract( $output, 'rights', $user->getUserPage() );
  925. }
  926. /**
  927. * Return an array of subpages beginning with $search that this special page will accept.
  928. *
  929. * @param string $search Prefix to search for
  930. * @param int $limit Maximum number of results to return (usually 10)
  931. * @param int $offset Number of results to skip (usually 0)
  932. * @return string[] Matching subpages
  933. */
  934. public function prefixSearchSubpages( $search, $limit, $offset ) {
  935. $user = User::newFromName( $search );
  936. if ( !$user ) {
  937. // No prefix suggestion for invalid user
  938. return [];
  939. }
  940. // Autocomplete subpage as user list - public to allow caching
  941. return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
  942. }
  943. protected function getGroupName() {
  944. return 'users';
  945. }
  946. }