MediaWiki  master
SpecialUserRights.php
Go to the documentation of this file.
1 <?php
24 namespace MediaWiki\Specials;
25 
26 use LogEventsList;
27 use LogPage;
28 use ManualLogEntry;
50 use Xml;
51 use XmlSelect;
52 
65  protected $mTarget;
69  protected $mFetchedUser = null;
70  protected $isself = false;
71 
72  private UserGroupManagerFactory $userGroupManagerFactory;
73 
75  private $userGroupManager = null;
76 
77  private UserNameUtils $userNameUtils;
78  private UserNamePrefixSearch $userNamePrefixSearch;
79  private UserFactory $userFactory;
80  private ActorStoreFactory $actorStoreFactory;
81  private WatchlistManager $watchlistManager;
82 
91  public function __construct(
92  UserGroupManagerFactory $userGroupManagerFactory = null,
93  UserNameUtils $userNameUtils = null,
94  UserNamePrefixSearch $userNamePrefixSearch = null,
95  UserFactory $userFactory = null,
96  ActorStoreFactory $actorStoreFactory = null,
97  WatchlistManager $watchlistManager = null
98  ) {
99  parent::__construct( 'Userrights' );
100  $services = MediaWikiServices::getInstance();
101  // This class is extended and therefore falls back to global state - T263207
102  $this->userNameUtils = $userNameUtils ?? $services->getUserNameUtils();
103  $this->userNamePrefixSearch = $userNamePrefixSearch ?? $services->getUserNamePrefixSearch();
104  $this->userFactory = $userFactory ?? $services->getUserFactory();
105  $this->userGroupManagerFactory = $userGroupManagerFactory ?? $services->getUserGroupManagerFactory();
106  $this->actorStoreFactory = $actorStoreFactory ?? $services->getActorStoreFactory();
107  $this->watchlistManager = $watchlistManager ?? $services->getWatchlistManager();
108  }
109 
110  public function doesWrites() {
111  return true;
112  }
113 
125  public function userCanChangeRights( UserIdentity $targetUser, $checkIfSelf = true ) {
126  $isself = $this->getUser()->equals( $targetUser );
127 
128  $userGroupManager = $this->userGroupManagerFactory
129  ->getUserGroupManager( $targetUser->getWikiId() );
130  $available = $userGroupManager->getGroupsChangeableBy( $this->getAuthority() );
131  if ( !$targetUser->isRegistered() ) {
132  return false;
133  }
134 
135  if ( $available['add'] || $available['remove'] ) {
136  // can change some rights for any user
137  return true;
138  }
139 
140  if ( ( $available['add-self'] || $available['remove-self'] )
141  && ( $isself || !$checkIfSelf )
142  ) {
143  // can change some rights for self
144  return true;
145  }
146 
147  return false;
148  }
149 
157  public function execute( $par ) {
158  $user = $this->getUser();
159  $request = $this->getRequest();
160  $session = $request->getSession();
161  $out = $this->getOutput();
162 
163  $out->addModules( [ 'mediawiki.special.userrights' ] );
164 
165  $this->mTarget = $par ?? $request->getVal( 'user' );
166 
167  if ( is_string( $this->mTarget ) ) {
168  $this->mTarget = trim( $this->mTarget );
169  }
170 
171  if ( $this->mTarget !== null && $this->userNameUtils->getCanonical( $this->mTarget ) === $user->getName() ) {
172  $this->isself = true;
173  }
174 
175  $fetchedStatus = $this->mTarget === null ? Status::newFatal( 'nouserspecified' ) :
176  $this->fetchUser( $this->mTarget, true );
177  if ( $fetchedStatus->isOK() ) {
178  $this->mFetchedUser = $fetchedUser = $fetchedStatus->value;
179  // Phan false positive on Status object - T323205
180  '@phan-var UserIdentity $fetchedUser';
181  $wikiId = $fetchedUser->getWikiId();
182  if ( $wikiId === UserIdentity::LOCAL ) {
183  // Set the 'relevant user' in the skin, so it displays links like Contributions,
184  // User logs, UserRights, etc.
185  $this->getSkin()->setRelevantUser( $this->mFetchedUser );
186  }
187  $this->userGroupManager = $this->userGroupManagerFactory
188  ->getUserGroupManager( $wikiId );
189  }
190 
191  // show a successbox, if the user rights was saved successfully
192  if (
193  $session->get( 'specialUserrightsSaveSuccess' ) &&
194  $this->mFetchedUser !== null
195  ) {
196  // Remove session data for the success message
197  $session->remove( 'specialUserrightsSaveSuccess' );
198 
199  $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
200  $out->addHTML(
203  'p',
204  [],
205  $this->msg( 'savedrights', $this->getDisplayUsername( $this->mFetchedUser ) )->text()
206  ),
207  'mw-notify-success'
208  )
209  );
210  }
211 
212  $this->setHeaders();
213  $this->outputHeader();
214 
215  $out->addModuleStyles( 'mediawiki.special' );
216  $this->addHelpLink( 'Help:Assigning permissions' );
217 
218  $this->switchForm();
219 
220  if (
221  $request->wasPosted() &&
222  $request->getCheck( 'saveusergroups' ) &&
223  $this->mTarget !== null &&
224  $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget )
225  ) {
226  /*
227  * If the user is blocked and they only have "partial" access
228  * (e.g. they don't have the userrights permission), then don't
229  * allow them to change any user rights.
230  */
231  if ( !$this->getAuthority()->isAllowed( 'userrights' ) ) {
232  $block = $user->getBlock();
233  if ( $block && $block->isSitewide() ) {
234  throw new UserBlockedError(
235  $block,
236  $user,
237  $this->getLanguage(),
238  $request->getIP()
239  );
240  }
241  }
242 
243  $this->checkReadOnly();
244 
245  // save settings
246  if ( !$fetchedStatus->isOK() ) {
247  $this->getOutput()->addWikiTextAsInterface(
248  $fetchedStatus->getWikiText( false, false, $this->getLanguage() )
249  );
250 
251  return;
252  }
253 
254  $targetUser = $this->mFetchedUser;
255  $conflictCheck = $request->getVal( 'conflictcheck-originalgroups' );
256  $conflictCheck = ( $conflictCheck === '' ) ? [] : explode( ',', $conflictCheck );
257  $userGroups = $this->userGroupManager->getUserGroups( $targetUser, UserGroupManager::READ_LATEST );
258 
259  if ( $userGroups !== $conflictCheck ) {
260  $out->addHTML( Html::errorBox(
261  $this->msg( 'userrights-conflict' )->parse()
262  ) );
263  } else {
264  $status = $this->saveUserGroups(
265  $request->getVal( 'user-reason' ),
266  $targetUser
267  );
268 
269  if ( $status->isOK() ) {
270  // Set session data for the success message
271  $session->set( 'specialUserrightsSaveSuccess', 1 );
272 
273  $out->redirect( $this->getSuccessURL() );
274  return;
275  } else {
276  // Print an error message and redisplay the form
277  $out->wrapWikiTextAsInterface(
278  'error', $status->getWikiText( false, false, $this->getLanguage() )
279  );
280  }
281  }
282  }
283 
284  // show some more forms
285  if ( $this->mTarget !== null ) {
286  $this->editUserGroupsForm( $this->mTarget );
287  }
288  }
289 
290  private function getSuccessURL() {
291  return $this->getPageTitle( $this->mTarget )->getFullURL();
292  }
293 
300  public function canProcessExpiries() {
301  return true;
302  }
303 
313  public static function expiryToTimestamp( $expiry ) {
314  if ( wfIsInfinity( $expiry ) ) {
315  return null;
316  }
317 
318  $unix = strtotime( $expiry );
319 
320  if ( !$unix || $unix === -1 ) {
321  return false;
322  }
323 
324  // @todo FIXME: Non-qualified absolute times are not in users specified timezone
325  // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
326  return wfTimestamp( TS_MW, $unix );
327  }
328 
337  protected function saveUserGroups( $reason, $user ) {
338  if ( $this->userNameUtils->isTemp( $user->getName() ) ) {
339  return Status::newFatal( 'userrights-no-tempuser' );
340  }
341  $allgroups = $this->userGroupManager->listAllGroups();
342  $addgroup = [];
343  $groupExpiries = []; // associative array of (group name => expiry)
344  $removegroup = [];
345  $existingUGMs = $this->userGroupManager->getUserGroupMemberships( $user );
346 
347  // This could possibly create a highly unlikely race condition if permissions are changed between
348  // when the form is loaded and when the form is saved. Ignoring it for the moment.
349  foreach ( $allgroups as $group ) {
350  // We'll tell it to remove all unchecked groups, and add all checked groups.
351  // Later on, this gets filtered for what can actually be removed
352  if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
353  $addgroup[] = $group;
354 
355  if ( $this->canProcessExpiries() ) {
356  // read the expiry information from the request
357  $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
358  if ( $expiryDropdown === 'existing' ) {
359  continue;
360  }
361 
362  if ( $expiryDropdown === 'other' ) {
363  $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
364  } else {
365  $expiryValue = $expiryDropdown;
366  }
367 
368  // validate the expiry
369  $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue );
370 
371  if ( $groupExpiries[$group] === false ) {
372  return Status::newFatal( 'userrights-invalid-expiry', $group );
373  }
374 
375  // not allowed to have things expiring in the past
376  if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
377  return Status::newFatal( 'userrights-expiry-in-past', $group );
378  }
379 
380  // if the user can only add this group (not remove it), the expiry time
381  // cannot be brought forward (T156784)
382  if ( !$this->canRemove( $group ) &&
383  isset( $existingUGMs[$group] ) &&
384  ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) >
385  ( $groupExpiries[$group] ?: 'infinity' )
386  ) {
387  return Status::newFatal( 'userrights-cannot-shorten-expiry', $group );
388  }
389  }
390  } else {
391  $removegroup[] = $group;
392  }
393  }
394 
395  $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
396 
397  if ( $user->getWikiId() === UserIdentity::LOCAL && $this->getRequest()->getCheck( 'wpWatch' ) ) {
398  $this->watchlistManager->addWatchIgnoringRights(
399  $this->getUser(),
400  Title::makeTitle( NS_USER, $user->getName() )
401  );
402  }
403 
404  return Status::newGood();
405  }
406 
422  public function doSaveUserGroups( $user, array $add, array $remove, $reason = '',
423  array $tags = [], array $groupExpiries = []
424  ) {
425  // Validate input set...
426  $isself = $user->getName() == $this->getUser()->getName();
427  if ( $this->userGroupManager !== null ) {
428  // Used after form submit
429  $userGroupManager = $this->userGroupManager;
430  } else {
431  // Used as backend-function
432  $userGroupManager = $this->userGroupManagerFactory
433  ->getUserGroupManager( $user->getWikiId() );
434  }
435  $groups = $userGroupManager->getUserGroups( $user );
436  $ugms = $userGroupManager->getUserGroupMemberships( $user );
437  $changeable = $userGroupManager->getGroupsChangeableBy( $this->getAuthority() );
438  $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
439  $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );
440 
441  $remove = array_unique( array_intersect( $remove, $removable, $groups ) );
442  $add = array_intersect( $add, $addable );
443 
444  // add only groups that are not already present or that need their expiry updated,
445  // UNLESS the user can only add this group (not remove it) and the expiry time
446  // is being brought forward (T156784)
447  $add = array_filter( $add,
448  static function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
449  if ( isset( $groupExpiries[$group] ) &&
450  !in_array( $group, $removable ) &&
451  isset( $ugms[$group] ) &&
452  ( $ugms[$group]->getExpiry() ?: 'infinity' ) >
453  ( $groupExpiries[$group] ?: 'infinity' )
454  ) {
455  return false;
456  }
457  return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
458  } );
459 
460  if ( $user->getWikiId() === UserIdentity::LOCAL ) {
461  // For compatibility local changes are provided as User object to the hook
462  $hookUser = $this->userFactory->newFromUserIdentity( $user );
463  } else {
464  // Interwiki changes are provided as UserIdentity since 1.41, was UserRightsProxy before
465  $hookUser = $user;
466  }
467  $this->getHookRunner()->onChangeUserGroups( $this->getUser(), $hookUser, $add, $remove );
468 
469  $oldGroups = $groups;
470  $oldUGMs = $userGroupManager->getUserGroupMemberships( $user );
471  $newGroups = $oldGroups;
472 
473  // Remove groups, then add new ones/update expiries of existing ones
474  if ( $remove ) {
475  foreach ( $remove as $index => $group ) {
476  if ( !$userGroupManager->removeUserFromGroup( $user, $group ) ) {
477  unset( $remove[$index] );
478  }
479  }
480  $newGroups = array_diff( $newGroups, $remove );
481  }
482  if ( $add ) {
483  foreach ( $add as $index => $group ) {
484  $expiry = $groupExpiries[$group] ?? null;
485  if ( !$userGroupManager->addUserToGroup( $user, $group, $expiry, true ) ) {
486  unset( $add[$index] );
487  }
488  }
489  $newGroups = array_merge( $newGroups, $add );
490  }
491  $newGroups = array_unique( $newGroups );
492  $newUGMs = $userGroupManager->getUserGroupMemberships( $user );
493 
494  // Ensure that caches are cleared
495  $this->userFactory->invalidateCache( $user );
496 
497  // update groups in external authentication database
498  $this->getHookRunner()->onUserGroupsChanged( $hookUser, $add, $remove,
499  $this->getUser(), $reason, $oldUGMs, $newUGMs );
500 
501  wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) );
502  wfDebug( 'newGroups: ' . print_r( $newGroups, true ) );
503  wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) );
504  wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) );
505 
506  // Only add a log entry if something actually changed
507  if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
508  $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
509  }
510 
511  return [ $add, $remove ];
512  }
513 
521  protected static function serialiseUgmForLog( $ugm ) {
522  if ( !$ugm instanceof UserGroupMembership ) {
523  return null;
524  }
525  return [ 'expiry' => $ugm->getExpiry() ];
526  }
527 
538  protected function addLogEntry( $user, array $oldGroups, array $newGroups, $reason,
539  array $tags, array $oldUGMs, array $newUGMs
540  ) {
541  // make sure $oldUGMs and $newUGMs are in the same order, and serialise
542  // each UGM object to a simplified array
543  $oldUGMs = array_map( static function ( $group ) use ( $oldUGMs ) {
544  return isset( $oldUGMs[$group] ) ?
545  self::serialiseUgmForLog( $oldUGMs[$group] ) :
546  null;
547  }, $oldGroups );
548  $newUGMs = array_map( static function ( $group ) use ( $newUGMs ) {
549  return isset( $newUGMs[$group] ) ?
550  self::serialiseUgmForLog( $newUGMs[$group] ) :
551  null;
552  }, $newGroups );
553 
554  $logEntry = new ManualLogEntry( 'rights', 'rights' );
555  $logEntry->setPerformer( $this->getUser() );
556  $logEntry->setTarget( Title::makeTitle( NS_USER, $this->getDisplayUsername( $user ) ) );
557  $logEntry->setComment( is_string( $reason ) ? $reason : "" );
558  $logEntry->setParameters( [
559  '4::oldgroups' => $oldGroups,
560  '5::newgroups' => $newGroups,
561  'oldmetadata' => $oldUGMs,
562  'newmetadata' => $newUGMs,
563  ] );
564  $logid = $logEntry->insert();
565  if ( count( $tags ) ) {
566  $logEntry->addTags( $tags );
567  }
568  $logEntry->publish( $logid );
569  }
570 
575  private function editUserGroupsForm( $username ) {
576  $status = $this->fetchUser( $username, true );
577  if ( !$status->isOK() ) {
578  $this->getOutput()->addWikiTextAsInterface(
579  $status->getWikiText( false, false, $this->getLanguage() )
580  );
581 
582  return;
583  }
584 
586  $user = $status->value;
587  '@phan-var UserIdentity $user';
588 
589  $groups = $this->userGroupManager->getUserGroups( $user );
590  $groupMemberships = $this->userGroupManager->getUserGroupMemberships( $user );
591  $this->showEditUserGroupsForm( $user, $groups, $groupMemberships );
592 
593  // This isn't really ideal logging behavior, but let's not hide the
594  // interwiki logs if we're using them as is.
595  $this->showLogFragment( $user, $this->getOutput() );
596  }
597 
607  public function fetchUser( $username, $writing = true ) {
608  $parts = explode( $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter ),
609  $username );
610  if ( count( $parts ) < 2 ) {
611  $name = trim( $username );
612  $wikiId = UserIdentity::LOCAL;
613  } else {
614  [ $name, $wikiId ] = array_map( 'trim', $parts );
615 
616  if ( WikiMap::isCurrentWikiId( $wikiId ) ) {
617  $wikiId = UserIdentity::LOCAL;
618  } else {
619  if ( $writing &&
620  !$this->getAuthority()->isAllowed( 'userrights-interwiki' )
621  ) {
622  return Status::newFatal( 'userrights-no-interwiki' );
623  }
624  $localDatabases = $this->getConfig()->get( MainConfigNames::LocalDatabases );
625  if ( !in_array( $wikiId, $localDatabases ) ) {
626  return Status::newFatal( 'userrights-nodatabase', $wikiId );
627  }
628  }
629  }
630 
631  if ( $name === '' ) {
632  return Status::newFatal( 'nouserspecified' );
633  }
634 
635  $userIdentityLookup = $this->actorStoreFactory->getUserIdentityLookup( $wikiId );
636  if ( $name[0] == '#' ) {
637  // Numeric ID can be specified...
638  $id = intval( substr( $name, 1 ) );
639 
640  $user = $userIdentityLookup->getUserIdentityByUserId( $id );
641  if ( !$user ) {
642  // Different error message for compatibility
643  return Status::newFatal( 'noname' );
644  }
645  $name = $user->getName();
646  } else {
647  $name = $this->userNameUtils->getCanonical( $name );
648  if ( $name === false ) {
649  // invalid name
650  return Status::newFatal( 'nosuchusershort', $username );
651  }
652  $user = $userIdentityLookup->getUserIdentityByName( $name );
653  }
654 
655  if ( $this->userNameUtils->isTemp( $name ) ) {
656  return Status::newFatal( 'userrights-no-group' );
657  }
658 
659  if ( !$user || !$user->isRegistered() ) {
660  return Status::newFatal( 'nosuchusershort', $username );
661  }
662 
663  if ( $user->getWikiId() === UserIdentity::LOCAL &&
664  $this->userFactory->newFromUserIdentity( $user )->isHidden() &&
665  !$this->getAuthority()->isAllowed( 'hideuser' )
666  ) {
667  // Cannot see hidden users, pretend they don't exist
668  return Status::newFatal( 'nosuchusershort', $username );
669  }
670 
671  return Status::newGood( $user );
672  }
673 
681  public function makeGroupNameList( $ids ) {
682  if ( !$ids ) {
683  return $this->msg( 'rightsnone' )->inContentLanguage()->text();
684  } else {
685  return implode( ', ', $ids );
686  }
687  }
688 
692  protected function switchForm() {
693  $this->getOutput()->addModules( 'mediawiki.userSuggest' );
694 
695  $this->getOutput()->addHTML(
697  'form',
698  [
699  'method' => 'get',
700  'action' => wfScript(),
701  'name' => 'uluser',
702  'id' => 'mw-userrights-form1'
703  ]
704  ) .
705  Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
706  Xml::fieldset( $this->msg( 'userrights-lookup-user' )->text() ) .
708  $this->msg( 'userrights-user-editname' )->text(),
709  'user',
710  'username',
711  30,
712  $this->mTarget ? str_replace( '_', ' ', $this->mTarget ) : '',
713  [
714  'class' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
715  ] + (
716  // Set autofocus on blank input and error input
717  $this->mFetchedUser === null ? [ 'autofocus' => '' ] : []
718  )
719  ) . ' ' .
721  $this->msg( 'editusergroup' )->text()
722  ) .
723  Html::closeElement( 'fieldset' ) .
724  Html::closeElement( 'form' ) . "\n"
725  );
726  }
727 
737  protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) {
738  $list = $membersList = $tempList = $tempMembersList = [];
739  foreach ( $groupMemberships as $ugm ) {
740  $linkG = UserGroupMembership::getLinkHTML( $ugm, $this->getContext() );
741  $linkM = UserGroupMembership::getLinkHTML( $ugm, $this->getContext(), $user->getName() );
742  if ( $ugm->getExpiry() ) {
743  $tempList[] = $linkG;
744  $tempMembersList[] = $linkM;
745  } else {
746  $list[] = $linkG;
747  $membersList[] = $linkM;
748 
749  }
750  }
751 
752  $autoList = [];
753  $autoMembersList = [];
754 
755  if ( $user->getWikiId() === UserIdentity::LOCAL ) {
756  // Listing autopromote groups works only on the local wiki
757  foreach ( $this->userGroupManager->getUserAutopromoteGroups( $user ) as $group ) {
758  $autoList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
759  $autoMembersList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext(), $user->getName() );
760  }
761  }
762 
763  $language = $this->getLanguage();
764  $displayedList = $this->msg( 'userrights-groupsmember-type' )
765  ->rawParams(
766  $language->commaList( array_merge( $tempList, $list ) ),
767  $language->commaList( array_merge( $tempMembersList, $membersList ) )
768  )->escaped();
769  $displayedAutolist = $this->msg( 'userrights-groupsmember-type' )
770  ->rawParams(
771  $language->commaList( $autoList ),
772  $language->commaList( $autoMembersList )
773  )->escaped();
774 
775  $grouplist = '';
776  $count = count( $list ) + count( $tempList );
777  if ( $count > 0 ) {
778  $grouplist = $this->msg( 'userrights-groupsmember' )
779  ->numParams( $count )
780  ->params( $user->getName() )
781  ->parse();
782  $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n";
783  }
784 
785  $count = count( $autoList );
786  if ( $count > 0 ) {
787  $autogrouplistintro = $this->msg( 'userrights-groupsmember-auto' )
788  ->numParams( $count )
789  ->params( $user->getName() )
790  ->parse();
791  $grouplist .= '<p>' . $autogrouplistintro . ' ' . $displayedAutolist . "</p>\n";
792  }
793 
794  $systemUser = $user->getWikiId() === UserIdentity::LOCAL
795  && $this->userFactory->newFromUserIdentity( $user )->isSystemUser();
796  if ( $systemUser ) {
797  $systemusernote = $this->msg( 'userrights-systemuser' )
798  ->params( $user->getName() )
799  ->parse();
800  $grouplist .= '<p>' . $systemusernote . "</p>\n";
801  }
802 
803  // Only add an email link if the user is not a system user
804  $flags = $systemUser ? 0 : Linker::TOOL_LINKS_EMAIL;
805  $userToolLinks = Linker::userToolLinks(
806  $user->getId( $user->getWikiId() ),
807  $this->getDisplayUsername( $user ),
808  false, /* default for redContribsWhenNoEdits */
809  $flags
810  );
811 
812  [ $groupCheckboxes, $canChangeAny ] =
813  $this->groupCheckboxes( $groupMemberships, $user );
814  $this->getOutput()->addHTML(
816  'form',
817  [
818  'method' => 'post',
819  'action' => $this->getPageTitle()->getLocalURL(),
820  'name' => 'editGroup',
821  'id' => 'mw-userrights-form2'
822  ]
823  ) .
824  Html::hidden( 'user', $this->mTarget ) .
825  Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) .
826  Html::hidden(
827  'conflictcheck-originalgroups',
828  implode( ',', $this->userGroupManager->getUserGroups( $user ) )
829  ) . // Conflict detection
830  Xml::openElement( 'fieldset' ) .
831  Xml::element(
832  'legend',
833  [],
834  $this->msg(
835  $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup',
836  $user->getName()
837  )->text()
838  ) .
839  $this->msg(
840  $canChangeAny ? 'editinguser' : 'viewinguserrights'
841  )->params( wfEscapeWikiText( $this->getDisplayUsername( $user ) ) )
842  ->rawParams( $userToolLinks )->parse()
843  );
844  if ( $canChangeAny ) {
845  $this->getOutput()->addHTML(
846  $this->msg( 'userrights-groups-help', $user->getName() )->parse() .
847  $grouplist .
848  $groupCheckboxes .
849  Xml::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
850  "<tr>
851  <td class='mw-label'>" .
852  Xml::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) .
853  "</td>
854  <td class='mw-input'>" .
855  Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason' ) ?? false, [
856  'id' => 'wpReason',
857  // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
858  // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
859  // Unicode codepoints.
861  ] ) .
862  "</td>
863  </tr>
864  <tr>
865  <td></td>
866  <td class='mw-submit'>" .
867  Xml::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(),
868  [ 'name' => 'saveusergroups' ] +
869  Linker::tooltipAndAccesskeyAttribs( 'userrights-set' )
870  ) .
871  "</td>
872  </tr>
873  <tr>
874  <td></td>
875  <td class='mw-input'>" .
876  Xml::checkLabel( $this->msg( 'userrights-watchuser' )->text(), 'wpWatch', 'wpWatch' ) .
877  "</td>
878  </tr>" .
879  Xml::closeElement( 'table' ) . "\n"
880  );
881  } else {
882  $this->getOutput()->addHTML( $grouplist );
883  }
884  $this->getOutput()->addHTML(
885  Xml::closeElement( 'fieldset' ) .
886  Xml::closeElement( 'form' ) . "\n"
887  );
888  }
889 
899  private function groupCheckboxes( $usergroups, $user ) {
900  $allgroups = $this->userGroupManager->listAllGroups();
901  $ret = '';
902 
903  // Get the list of preset expiry times from the system message
904  $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
905  $expiryOptions = $expiryOptionsMsg->isDisabled()
906  ? []
907  : XmlSelect::parseOptionsMessage( $expiryOptionsMsg->text() );
908 
909  // Put all column info into an associative array so that extensions can
910  // more easily manage it.
911  $columns = [ 'unchangeable' => [], 'changeable' => [] ];
912 
913  foreach ( $allgroups as $group ) {
914  $set = isset( $usergroups[$group] );
915  // Users who can add the group, but not remove it, can only lengthen
916  // expiries, not shorten them. So they should only see the expiry
917  // dropdown if the group currently has a finite expiry
918  $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
919  !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
920  // Should the checkbox be disabled?
921  $disabledCheckbox = !(
922  ( $set && $this->canRemove( $group ) ) ||
923  ( !$set && $this->canAdd( $group ) ) );
924  // Should the expiry elements be disabled?
925  $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
926  // Do we need to point out that this action is irreversible?
927  $irreversible = !$disabledCheckbox && (
928  ( $set && !$this->canAdd( $group ) ) ||
929  ( !$set && !$this->canRemove( $group ) ) );
930 
931  $checkbox = [
932  'set' => $set,
933  'disabled' => $disabledCheckbox,
934  'disabled-expiry' => $disabledExpiry,
935  'irreversible' => $irreversible
936  ];
937 
938  if ( $disabledCheckbox && $disabledExpiry ) {
939  $columns['unchangeable'][$group] = $checkbox;
940  } else {
941  $columns['changeable'][$group] = $checkbox;
942  }
943  }
944 
945  // Build the HTML table
946  $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
947  "<tr>\n";
948  foreach ( $columns as $name => $column ) {
949  if ( $column === [] ) {
950  continue;
951  }
952  // Messages: userrights-changeable-col, userrights-unchangeable-col
953  $ret .= Xml::element(
954  'th',
955  null,
956  $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text()
957  );
958  }
959 
960  $ret .= "</tr>\n<tr>\n";
961  $uiLanguage = $this->getLanguage();
962  $userName = $user->getName();
963  foreach ( $columns as $column ) {
964  if ( $column === [] ) {
965  continue;
966  }
967  $ret .= "\t<td style='vertical-align:top;'>\n";
968  foreach ( $column as $group => $checkbox ) {
969  $attr = [ 'class' => 'mw-userrights-groupcheckbox' ];
970  if ( $checkbox['disabled'] ) {
971  $attr['disabled'] = 'disabled';
972  }
973 
974  $member = $uiLanguage->getGroupMemberName( $group, $userName );
975  if ( $checkbox['irreversible'] ) {
976  $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
977  } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
978  $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
979  } else {
980  $text = $member;
981  }
982  $checkboxHtml = Xml::checkLabel( $text, "wpGroup-" . $group,
983  "wpGroup-" . $group, $checkbox['set'], $attr );
984 
985  if ( $this->canProcessExpiries() ) {
986  $uiUser = $this->getUser();
987 
988  $currentExpiry = isset( $usergroups[$group] ) ?
989  $usergroups[$group]->getExpiry() :
990  null;
991 
992  // If the user can't modify the expiry, print the current expiry below
993  // it in plain text. Otherwise provide UI to set/change the expiry
994  if ( $checkbox['set'] &&
995  ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] )
996  ) {
997  if ( $currentExpiry ) {
998  $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
999  $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
1000  $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser );
1001  $expiryHtml = Xml::element( 'span', null,
1002  $this->msg( 'userrights-expiry-current' )->params(
1003  $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text() );
1004  } else {
1005  $expiryHtml = Xml::element( 'span', null,
1006  $this->msg( 'userrights-expiry-none' )->text() );
1007  }
1008  // T171345: Add a hidden form element so that other groups can still be manipulated,
1009  // otherwise saving errors out with an invalid expiry time for this group.
1010  $expiryHtml .= Html::hidden( "wpExpiry-$group",
1011  $currentExpiry ? 'existing' : 'infinite' );
1012  $expiryHtml .= "<br />\n";
1013  } else {
1014  $expiryHtml = Xml::element( 'span', null,
1015  $this->msg( 'userrights-expiry' )->text() );
1016  $expiryHtml .= Xml::openElement( 'span' );
1017 
1018  // add a form element to set the expiry date
1019  $expiryFormOptions = new XmlSelect(
1020  "wpExpiry-$group",
1021  "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
1022  $currentExpiry ? 'existing' : 'infinite'
1023  );
1024  if ( $checkbox['disabled-expiry'] ) {
1025  $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
1026  }
1027 
1028  if ( $currentExpiry ) {
1029  $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
1030  $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
1031  $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
1032  $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
1033  $timestamp, $d, $t );
1034  $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
1035  }
1036 
1037  $expiryFormOptions->addOption(
1038  $this->msg( 'userrights-expiry-none' )->text(),
1039  'infinite'
1040  );
1041  $expiryFormOptions->addOption(
1042  $this->msg( 'userrights-expiry-othertime' )->text(),
1043  'other'
1044  );
1045 
1046  $expiryFormOptions->addOptions( $expiryOptions );
1047 
1048  // Add expiry dropdown
1049  $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
1050 
1051  // Add custom expiry field
1052  $attribs = [
1053  'id' => "mw-input-wpExpiry-$group-other",
1054  'class' => 'mw-userrights-expiryfield',
1055  ];
1056  if ( $checkbox['disabled-expiry'] ) {
1057  $attribs['disabled'] = 'disabled';
1058  }
1059  $expiryHtml .= Xml::input( "wpExpiry-$group-other", 30, '', $attribs );
1060 
1061  // If the user group is set but the checkbox is disabled, mimic a
1062  // checked checkbox in the form submission
1063  if ( $checkbox['set'] && $checkbox['disabled'] ) {
1064  $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
1065  }
1066 
1067  $expiryHtml .= Xml::closeElement( 'span' );
1068  }
1069 
1070  $divAttribs = [
1071  'id' => "mw-userrights-nested-wpGroup-$group",
1072  'class' => 'mw-userrights-nested',
1073  ];
1074  $checkboxHtml .= "\t\t\t" . Xml::tags( 'div', $divAttribs, $expiryHtml ) . "\n";
1075  }
1076  $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
1077  ? Xml::tags( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
1078  : Xml::tags( 'div', [], $checkboxHtml )
1079  ) . "\n";
1080  }
1081  $ret .= "\t</td>\n";
1082  }
1083  $ret .= Xml::closeElement( 'tr' ) . Xml::closeElement( 'table' );
1084 
1085  return [ $ret, (bool)$columns['changeable'] ];
1086  }
1087 
1092  private function canRemove( $group ) {
1093  $groups = $this->changeableGroups();
1094 
1095  return in_array(
1096  $group,
1097  $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] )
1098  );
1099  }
1100 
1105  private function canAdd( $group ) {
1106  $groups = $this->changeableGroups();
1107 
1108  return in_array(
1109  $group,
1110  $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] )
1111  );
1112  }
1113 
1122  protected function changeableGroups() {
1123  return $this->userGroupManager->getGroupsChangeableBy( $this->getContext()->getAuthority() );
1124  }
1125 
1134  private function getDisplayUsername( UserIdentity $user ) {
1135  $userName = $user->getName();
1136  if ( $user->getWikiId() !== UserIdentity::LOCAL ) {
1137  $userName .= $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter )
1138  . $user->getWikiId();
1139  }
1140  return $userName;
1141  }
1142 
1149  protected function showLogFragment( $user, $output ) {
1150  $rightsLogPage = new LogPage( 'rights' );
1151  $output->addHTML( Xml::element( 'h2', null, $rightsLogPage->getName()->text() ) );
1152  LogEventsList::showLogExtract( $output, 'rights',
1153  Title::makeTitle( NS_USER, $this->getDisplayUsername( $user ) ) );
1154  }
1155 
1164  public function prefixSearchSubpages( $search, $limit, $offset ) {
1165  $search = $this->userNameUtils->getCanonical( $search );
1166  if ( !$search ) {
1167  // No prefix suggestion for invalid user
1168  return [];
1169  }
1170  // Autocomplete subpage as user list - public to allow caching
1171  return $this->userNamePrefixSearch
1172  ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
1173  }
1174 
1175  protected function getGroupName() {
1176  return 'users';
1177  }
1178 }
1179 
1184 class_alias( SpecialUserRights::class, 'UserrightsPage' );
const NS_USER
Definition: Defines.php:66
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
wfScript( $script='index')
Get the URL path to a MediaWiki entry point.
wfIsInfinity( $str)
Determine input string is represents as infinity.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfEscapeWikiText( $text)
Escapes the given text so that it may be output using addWikiText() without any linking,...
static showLogExtract(&$out, $types=[], $page='', $user='', $param=[])
Show log extract.
Class to simplify the use of log pages.
Definition: LogPage.php:43
Class for creating new log entries and inserting them into the database.
Handle database storage of comments such as edit summaries and log reasons.
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
This class is a collection of static functions that serve two purposes:
Definition: Html.php:57
static successBox( $html, $className='')
Return a success box.
Definition: Html.php:844
static errorBox( $html, $heading='', $className='')
Return an error box.
Definition: Html.php:830
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing '/'...
Definition: Html.php:288
static hidden( $name, $value, array $attribs=[])
Convenience function to produce an input element with type=hidden.
Definition: Html.php:897
static closeElement( $element)
Returns "</$element>".
Definition: Html.php:352
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:264
Some internal bits split of from Skin.php.
Definition: Linker.php:65
static tooltipAndAccesskeyAttribs( $name, array $msgParams=[], $options=null, $localizer=null)
Returns the attributes for the tooltip and access key.
Definition: Linker.php:2373
static userToolLinks( $userId, $userText, $redContribsWhenNoEdits=false, $flags=0, $edits=null, $useParentheses=true)
Generate standard user tool links (talk, contributions, block link, etc.)
Definition: Linker.php:1248
A class containing constants representing the names of configuration variables.
const LocalDatabases
Name constant for the LocalDatabases setting, for use with Config::get()
const UserrightsInterwikiDelimiter
Name constant for the UserrightsInterwikiDelimiter setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
This is one of the Core classes and should be read at least once by any new developers.
Definition: OutputPage.php:93
Parent class for all special pages.
Definition: SpecialPage.php:65
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getSkin()
Shortcut to get the skin being used for this instance.
getUser()
Shortcut to get the User executing this instance.
getPageTitle( $subpage=false)
Get a self-referential title object.
checkReadOnly()
If the wiki is currently in readonly mode, throws a ReadOnlyError.
getConfig()
Shortcut to get main config object.
getContext()
Gets the context this SpecialPage is executed in.
getRequest()
Get the WebRequest being used for this instance.
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getOutput()
Get the OutputPage being used for this instance.
getAuthority()
Shortcut to get the Authority executing this instance.
getLanguage()
Shortcut to get user's language.
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
Special page to allow managing user group membership.
execute( $par)
Manage forms to be shown according to posted data.
saveUserGroups( $reason, $user)
Save user groups changes in the database.
doesWrites()
Indicates whether this special page may perform database writes.
addLogEntry( $user, array $oldGroups, array $newGroups, $reason, array $tags, array $oldUGMs, array $newUGMs)
Add a rights log entry for an action.
null string $mTarget
The target of the local right-adjuster's interest.
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
showEditUserGroupsForm( $user, $groups, $groupMemberships)
Show the form to edit group memberships.
userCanChangeRights(UserIdentity $targetUser, $checkIfSelf=true)
Check whether the current user (from context) can change the target user's rights.
prefixSearchSubpages( $search, $limit, $offset)
Return an array of subpages beginning with $search that this special page will accept.
switchForm()
Output a form to allow searching for a user.
static expiryToTimestamp( $expiry)
Converts a user group membership expiry string into a timestamp.
__construct(UserGroupManagerFactory $userGroupManagerFactory=null, UserNameUtils $userNameUtils=null, UserNamePrefixSearch $userNamePrefixSearch=null, UserFactory $userFactory=null, ActorStoreFactory $actorStoreFactory=null, WatchlistManager $watchlistManager=null)
null UserIdentity $mFetchedUser
The user object of the target username or null.
doSaveUserGroups( $user, array $add, array $remove, $reason='', array $tags=[], array $groupExpiries=[])
Save user groups changes in the database.
fetchUser( $username, $writing=true)
Normalize the input username, which may be local or remote, and return a user identity object,...
canProcessExpiries()
Returns true if this user rights form can set and change user group expiries.
static serialiseUgmForLog( $ugm)
Serialise a UserGroupMembership object for storage in the log_params section of the logging table.
showLogFragment( $user, $output)
Show a rights log fragment for the specified user.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:58
Represents a title within MediaWiki.
Definition: Title.php:76
static makeTitle( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:624
Creates User objects.
Definition: UserFactory.php:41
Factory service for UserGroupManager instances.
addUserToGroup(UserIdentity $user, string $group, string $expiry=null, bool $allowUpdate=false)
Add the user to the given group.
getUserGroupMemberships(UserIdentity $user, int $queryFlags=self::READ_NORMAL)
Loads and returns UserGroupMembership objects for all the groups a user currently belongs to.
removeUserFromGroup(UserIdentity $user, string $group)
Remove the user from the given group.
getGroupsChangeableBy(Authority $authority)
Returns an array of groups that this $actor can add and remove.
getUserGroups(UserIdentity $user, int $queryFlags=self::READ_NORMAL)
Get the list of explicit group memberships this user has.
Represents a "user group membership" – a specific instance of a user belonging to a group.
static getLinkHTML( $ugm, IContextSource $context, $userName=null)
Gets a link for a user group, possibly including the expiry date if relevant.
Handles searching prefixes of user names.
UserNameUtils service.
Tools for dealing with other locally-hosted wikis.
Definition: WikiMap.php:31
static isCurrentWikiId( $wikiId)
Definition: WikiMap.php:323
Show an error when a user tries to do something they do not have the necessary permissions for.
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:73
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:85
Show an error when the user tries to do something whilst blocked.
Class for generating HTML <select> or <datalist> elements.
Definition: XmlSelect.php:28
static parseOptionsMessage(string $msg)
Parse labels and values out of a comma- and colon-separated list of options, such as is used for expi...
Definition: XmlSelect.php:148
Module of static functions for generating XML.
Definition: Xml.php:33
static closeElement( $element)
Shortcut to close an XML element.
Definition: Xml.php:124
static label( $label, $id, $attribs=[])
Convenience function to build an HTML form label.
Definition: Xml.php:371
static openElement( $element, $attribs=null)
This opens an XML element.
Definition: Xml.php:115
static input( $name, $size=false, $value=false, $attribs=[])
Convenience function to build an HTML text input field.
Definition: Xml.php:287
static submitButton( $value, $attribs=[])
Convenience function to build an HTML submit button When $wgUseMediaWikiUIEverywhere is true it will ...
Definition: Xml.php:473
static checkLabel( $label, $name, $id, $checked=false, $attribs=[])
Convenience function to build an HTML checkbox with a label.
Definition: Xml.php:432
static inputLabel( $label, $name, $id, $size=false, $value=false, $attribs=[])
Convenience function to build an HTML text input field with a label.
Definition: Xml.php:393
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition: Xml.php:141
static element( $element, $attribs=null, $contents='', $allowShortTag=true)
Format an XML element with given attributes and, optionally, text content.
Definition: Xml.php:50
static fieldset( $legend=false, $content=false, $attribs=[])
Shortcut for creating fieldsets.
Definition: Xml.php:632
getWikiId()
Get the ID of the wiki this page belongs to.
Interface for objects representing user identity.
isRegistered()
This must be equivalent to getId() != 0 and is provided for code readability.
getId( $wikiId=self::LOCAL)