MediaWiki  master
SpecialUserrights.php
Go to the documentation of this file.
1 <?php
32 
38 class UserrightsPage extends SpecialPage {
45  protected $mTarget;
49  protected $mFetchedUser = null;
50  protected $isself = false;
51 
53  private $userGroupManager;
54 
56  private $userNameUtils;
57 
59  private $userNamePrefixSearch;
60 
62  private $userFactory;
63 
70  public function __construct(
71  UserGroupManagerFactory $userGroupManagerFactory = null,
72  UserNameUtils $userNameUtils = null,
73  UserNamePrefixSearch $userNamePrefixSearch = null,
74  UserFactory $userFactory = null
75  ) {
76  parent::__construct( 'Userrights' );
77  $services = MediaWikiServices::getInstance();
78  // This class is extended and therefore falls back to global state - T263207
79  $this->userNameUtils = $userNameUtils ?? $services->getUserNameUtils();
80  $this->userNamePrefixSearch = $userNamePrefixSearch ?? $services->getUserNamePrefixSearch();
81  $this->userFactory = $userFactory ?? $services->getUserFactory();
82 
83  // TODO don't hard code false, use interwiki domains. See T14518
84  $this->userGroupManager = ( $userGroupManagerFactory ?? $services->getUserGroupManagerFactory() )
85  ->getUserGroupManager( false );
86  }
87 
88  public function doesWrites() {
89  return true;
90  }
91 
101  public function userCanChangeRights( UserIdentity $targetUser, $checkIfSelf = true ) {
102  $isself = $this->getUser()->equals( $targetUser );
103 
104  $available = $this->changeableGroups();
105  if ( $targetUser->getId() === 0 ) {
106  return false;
107  }
108 
109  if ( $available['add'] || $available['remove'] ) {
110  // can change some rights for any user
111  return true;
112  }
113 
114  if ( ( $available['add-self'] || $available['remove-self'] )
115  && ( $isself || !$checkIfSelf )
116  ) {
117  // can change some rights for self
118  return true;
119  }
120 
121  return false;
122  }
123 
131  public function execute( $par ) {
132  $user = $this->getUser();
133  $request = $this->getRequest();
134  $session = $request->getSession();
135  $out = $this->getOutput();
136 
137  $out->addModules( [ 'mediawiki.special.userrights' ] );
138 
139  $this->mTarget = $par ?? $request->getVal( 'user' );
140 
141  if ( is_string( $this->mTarget ) ) {
142  $this->mTarget = trim( $this->mTarget );
143  }
144 
145  if ( $this->mTarget !== null && $this->userNameUtils->getCanonical( $this->mTarget ) === $user->getName() ) {
146  $this->isself = true;
147  }
148 
149  $fetchedStatus = $this->mTarget === null ? Status::newFatal( 'nouserspecified' ) :
150  $this->fetchUser( $this->mTarget, true );
151  if ( $fetchedStatus->isOK() ) {
152  $this->mFetchedUser = $fetchedStatus->value;
153  if ( $this->mFetchedUser instanceof User ) {
154  // Set the 'relevant user' in the skin, so it displays links like Contributions,
155  // User logs, UserRights, etc.
156  $this->getSkin()->setRelevantUser( $this->mFetchedUser );
157  }
158  }
159 
160  // show a successbox, if the user rights was saved successfully
161  if (
162  $session->get( 'specialUserrightsSaveSuccess' ) &&
163  $this->mFetchedUser !== null
164  ) {
165  // Remove session data for the success message
166  $session->remove( 'specialUserrightsSaveSuccess' );
167 
168  $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
169  $out->addHTML(
172  'p',
173  [],
174  $this->msg( 'savedrights', $this->mFetchedUser->getName() )->text()
175  ),
176  'mw-notify-success'
177  )
178  );
179  }
180 
181  $this->setHeaders();
182  $this->outputHeader();
183 
184  $out->addModuleStyles( 'mediawiki.special' );
185  $this->addHelpLink( 'Help:Assigning permissions' );
186 
187  $this->switchForm();
188 
189  if (
190  $request->wasPosted() &&
191  $request->getCheck( 'saveusergroups' ) &&
192  $this->mTarget !== null &&
193  $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget )
194  ) {
195  /*
196  * If the user is blocked and they only have "partial" access
197  * (e.g. they don't have the userrights permission), then don't
198  * allow them to change any user rights.
199  */
200  if ( !$this->getAuthority()->isAllowed( 'userrights' ) ) {
201  $block = $user->getBlock();
202  if ( $block && $block->isSitewide() ) {
203  throw new UserBlockedError(
204  $block,
205  $user,
206  $this->getLanguage(),
207  $request->getIP()
208  );
209  }
210  }
211 
212  $this->checkReadOnly();
213 
214  // save settings
215  if ( !$fetchedStatus->isOK() ) {
216  $this->getOutput()->addWikiTextAsInterface(
217  $fetchedStatus->getWikiText( false, false, $this->getLanguage() )
218  );
219 
220  return;
221  }
222 
223  $targetUser = $this->mFetchedUser;
224  if ( $targetUser instanceof User ) { // UserRightsProxy doesn't have this method (T63252)
225  $targetUser->clearInstanceCache(); // T40989
226  }
227 
228  $conflictCheck = $request->getVal( 'conflictcheck-originalgroups' );
229  $conflictCheck = ( $conflictCheck === '' ) ? [] : explode( ',', $conflictCheck );
230  $userGroups = $targetUser->getGroups();
231 
232  if ( $userGroups !== $conflictCheck ) {
233  $out->addHTML( Html::errorBox(
234  $this->msg( 'userrights-conflict' )->parse()
235  ) );
236  } else {
237  $status = $this->saveUserGroups(
238  $this->mTarget,
239  $request->getVal( 'user-reason' ),
240  $targetUser
241  );
242 
243  if ( $status->isOK() ) {
244  // Set session data for the success message
245  $session->set( 'specialUserrightsSaveSuccess', 1 );
246 
247  $out->redirect( $this->getSuccessURL() );
248  return;
249  } else {
250  // Print an error message and redisplay the form
251  $out->wrapWikiTextAsInterface(
252  'error', $status->getWikiText( false, false, $this->getLanguage() )
253  );
254  }
255  }
256  }
257 
258  // show some more forms
259  if ( $this->mTarget !== null ) {
260  $this->editUserGroupsForm( $this->mTarget );
261  }
262  }
263 
264  private function getSuccessURL() {
265  return $this->getPageTitle( $this->mTarget )->getFullURL();
266  }
267 
274  public function canProcessExpiries() {
275  return true;
276  }
277 
287  public static function expiryToTimestamp( $expiry ) {
288  if ( wfIsInfinity( $expiry ) ) {
289  return null;
290  }
291 
292  $unix = strtotime( $expiry );
293 
294  if ( !$unix || $unix === -1 ) {
295  return false;
296  }
297 
298  // @todo FIXME: Non-qualified absolute times are not in users specified timezone
299  // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
300  return wfTimestamp( TS_MW, $unix );
301  }
302 
312  protected function saveUserGroups( $username, $reason, $user ) {
313  $allgroups = $this->getAllGroups();
314  $addgroup = [];
315  $groupExpiries = []; // associative array of (group name => expiry)
316  $removegroup = [];
317  $existingUGMs = $user->getGroupMemberships();
318 
319  // This could possibly create a highly unlikely race condition if permissions are changed between
320  // when the form is loaded and when the form is saved. Ignoring it for the moment.
321  foreach ( $allgroups as $group ) {
322  // We'll tell it to remove all unchecked groups, and add all checked groups.
323  // Later on, this gets filtered for what can actually be removed
324  if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
325  $addgroup[] = $group;
326 
327  if ( $this->canProcessExpiries() ) {
328  // read the expiry information from the request
329  $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
330  if ( $expiryDropdown === 'existing' ) {
331  continue;
332  }
333 
334  if ( $expiryDropdown === 'other' ) {
335  $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
336  } else {
337  $expiryValue = $expiryDropdown;
338  }
339 
340  // validate the expiry
341  $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue );
342 
343  if ( $groupExpiries[$group] === false ) {
344  return Status::newFatal( 'userrights-invalid-expiry', $group );
345  }
346 
347  // not allowed to have things expiring in the past
348  if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
349  return Status::newFatal( 'userrights-expiry-in-past', $group );
350  }
351 
352  // if the user can only add this group (not remove it), the expiry time
353  // cannot be brought forward (T156784)
354  if ( !$this->canRemove( $group ) &&
355  isset( $existingUGMs[$group] ) &&
356  ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) >
357  ( $groupExpiries[$group] ?: 'infinity' )
358  ) {
359  return Status::newFatal( 'userrights-cannot-shorten-expiry', $group );
360  }
361  }
362  } else {
363  $removegroup[] = $group;
364  }
365  }
366 
367  $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
368 
369  return Status::newGood();
370  }
371 
385  public function doSaveUserGroups( $user, array $add, array $remove, $reason = '',
386  array $tags = [], array $groupExpiries = []
387  ) {
388  // Validate input set...
389  $isself = $user->getName() == $this->getUser()->getName();
390  $groups = $user->getGroups();
391  $ugms = $user->getGroupMemberships();
392  $changeable = $this->changeableGroups();
393  $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
394  $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );
395 
396  $remove = array_unique( array_intersect( $remove, $removable, $groups ) );
397  $add = array_intersect( $add, $addable );
398 
399  // add only groups that are not already present or that need their expiry updated,
400  // UNLESS the user can only add this group (not remove it) and the expiry time
401  // is being brought forward (T156784)
402  $add = array_filter( $add,
403  static function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
404  if ( isset( $groupExpiries[$group] ) &&
405  !in_array( $group, $removable ) &&
406  isset( $ugms[$group] ) &&
407  ( $ugms[$group]->getExpiry() ?: 'infinity' ) >
408  ( $groupExpiries[$group] ?: 'infinity' )
409  ) {
410  return false;
411  }
412  return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
413  } );
414 
415  $this->getHookRunner()->onChangeUserGroups( $this->getUser(), $user, $add, $remove );
416 
417  $oldGroups = $groups;
418  $oldUGMs = $user->getGroupMemberships();
419  $newGroups = $oldGroups;
420 
421  // Remove groups, then add new ones/update expiries of existing ones
422  if ( $remove ) {
423  foreach ( $remove as $index => $group ) {
424  if ( !$user->removeGroup( $group ) ) {
425  unset( $remove[$index] );
426  }
427  }
428  $newGroups = array_diff( $newGroups, $remove );
429  }
430  if ( $add ) {
431  foreach ( $add as $index => $group ) {
432  $expiry = $groupExpiries[$group] ?? null;
433  if ( !$user->addGroup( $group, $expiry ) ) {
434  unset( $add[$index] );
435  }
436  }
437  $newGroups = array_merge( $newGroups, $add );
438  }
439  $newGroups = array_unique( $newGroups );
440  $newUGMs = $user->getGroupMemberships();
441 
442  // Ensure that caches are cleared
443  $user->invalidateCache();
444 
445  // update groups in external authentication database
446  $this->getHookRunner()->onUserGroupsChanged( $user, $add, $remove,
447  $this->getUser(), $reason, $oldUGMs, $newUGMs );
448 
449  wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) );
450  wfDebug( 'newGroups: ' . print_r( $newGroups, true ) );
451  wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) );
452  wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) );
453 
454  // Only add a log entry if something actually changed
455  if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
456  $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
457  }
458 
459  return [ $add, $remove ];
460  }
461 
469  protected static function serialiseUgmForLog( $ugm ) {
470  if ( !$ugm instanceof UserGroupMembership ) {
471  return null;
472  }
473  return [ 'expiry' => $ugm->getExpiry() ];
474  }
475 
486  protected function addLogEntry( $user, array $oldGroups, array $newGroups, $reason,
487  array $tags, array $oldUGMs, array $newUGMs
488  ) {
489  // make sure $oldUGMs and $newUGMs are in the same order, and serialise
490  // each UGM object to a simplified array
491  $oldUGMs = array_map( function ( $group ) use ( $oldUGMs ) {
492  return isset( $oldUGMs[$group] ) ?
493  self::serialiseUgmForLog( $oldUGMs[$group] ) :
494  null;
495  }, $oldGroups );
496  $newUGMs = array_map( function ( $group ) use ( $newUGMs ) {
497  return isset( $newUGMs[$group] ) ?
498  self::serialiseUgmForLog( $newUGMs[$group] ) :
499  null;
500  }, $newGroups );
501 
502  $logEntry = new ManualLogEntry( 'rights', 'rights' );
503  $logEntry->setPerformer( $this->getUser() );
504  $logEntry->setTarget( $user->getUserPage() );
505  $logEntry->setComment( is_string( $reason ) ? $reason : "" );
506  $logEntry->setParameters( [
507  '4::oldgroups' => $oldGroups,
508  '5::newgroups' => $newGroups,
509  'oldmetadata' => $oldUGMs,
510  'newmetadata' => $newUGMs,
511  ] );
512  $logid = $logEntry->insert();
513  if ( count( $tags ) ) {
514  $logEntry->addTags( $tags );
515  }
516  $logEntry->publish( $logid );
517  }
518 
523  private function editUserGroupsForm( $username ) {
524  $status = $this->fetchUser( $username, true );
525  if ( !$status->isOK() ) {
526  $this->getOutput()->addWikiTextAsInterface(
527  $status->getWikiText( false, false, $this->getLanguage() )
528  );
529 
530  return;
531  }
532 
534  $user = $status->value;
535  '@phan-var User $user';
536 
537  $groups = $user->getGroups();
538  $groupMemberships = $user->getGroupMemberships();
539  $this->showEditUserGroupsForm( $user, $groups, $groupMemberships );
540 
541  // This isn't really ideal logging behavior, but let's not hide the
542  // interwiki logs if we're using them as is.
543  $this->showLogFragment( $user, $this->getOutput() );
544  }
545 
555  public function fetchUser( $username, $writing = true ) {
556  $parts = explode( $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter ),
557  $username );
558  if ( count( $parts ) < 2 ) {
559  $name = trim( $username );
560  $dbDomain = '';
561  } else {
562  [ $name, $dbDomain ] = array_map( 'trim', $parts );
563 
564  if ( WikiMap::isCurrentWikiId( $dbDomain ) ) {
565  $dbDomain = '';
566  } else {
567  if ( $writing &&
568  !$this->getAuthority()->isAllowed( 'userrights-interwiki' )
569  ) {
570  return Status::newFatal( 'userrights-no-interwiki' );
571  }
572  if ( !UserRightsProxy::validDatabase( $dbDomain ) ) {
573  return Status::newFatal( 'userrights-nodatabase', $dbDomain );
574  }
575  }
576  }
577 
578  if ( $name === '' ) {
579  return Status::newFatal( 'nouserspecified' );
580  }
581 
582  if ( $name[0] == '#' ) {
583  // Numeric ID can be specified...
584  // We'll do a lookup for the name internally.
585  $id = intval( substr( $name, 1 ) );
586 
587  if ( $dbDomain == '' ) {
588  $name = User::whoIs( $id );
589  } else {
590  $name = UserRightsProxy::whoIs( $dbDomain, $id );
591  }
592 
593  if ( !$name ) {
594  return Status::newFatal( 'noname' );
595  }
596  } else {
597  $name = $this->userNameUtils->getCanonical( $name );
598  if ( $name === false ) {
599  // invalid name
600  return Status::newFatal( 'nosuchusershort', $username );
601  }
602  }
603 
604  if ( $dbDomain == '' ) {
605  $user = $this->userFactory->newFromName( $name );
606  } else {
607  $user = UserRightsProxy::newFromName( $dbDomain, $name );
608  }
609 
610  if ( !$user || $user->isAnon() ) {
611  return Status::newFatal( 'nosuchusershort', $username );
612  }
613 
614  if ( $user instanceof User &&
615  $user->isHidden() &&
616  !$this->getAuthority()->isAllowed( 'hideuser' )
617  ) {
618  // Cannot see hidden users, pretend they don't exist
619  return Status::newFatal( 'nosuchusershort', $username );
620  }
621 
622  return Status::newGood( $user );
623  }
624 
632  public function makeGroupNameList( $ids ) {
633  if ( empty( $ids ) ) {
634  return $this->msg( 'rightsnone' )->inContentLanguage()->text();
635  } else {
636  return implode( ', ', $ids );
637  }
638  }
639 
643  protected function switchForm() {
644  $this->getOutput()->addModules( 'mediawiki.userSuggest' );
645 
646  $this->getOutput()->addHTML(
648  'form',
649  [
650  'method' => 'get',
651  'action' => wfScript(),
652  'name' => 'uluser',
653  'id' => 'mw-userrights-form1'
654  ]
655  ) .
656  Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
657  Xml::fieldset( $this->msg( 'userrights-lookup-user' )->text() ) .
659  $this->msg( 'userrights-user-editname' )->text(),
660  'user',
661  'username',
662  30,
663  $this->mTarget ? str_replace( '_', ' ', $this->mTarget ) : '',
664  [
665  'class' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
666  ] + (
667  // Set autofocus on blank input and error input
668  $this->mFetchedUser === null ? [ 'autofocus' => '' ] : []
669  )
670  ) . ' ' .
672  $this->msg( 'editusergroup' )->text()
673  ) .
674  Html::closeElement( 'fieldset' ) .
675  Html::closeElement( 'form' ) . "\n"
676  );
677  }
678 
688  protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) {
689  $list = $membersList = $tempList = $tempMembersList = [];
690  foreach ( $groupMemberships as $ugm ) {
691  $linkG = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html' );
692  $linkM = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html',
693  $user->getName() );
694  if ( $ugm->getExpiry() ) {
695  $tempList[] = $linkG;
696  $tempMembersList[] = $linkM;
697  } else {
698  $list[] = $linkG;
699  $membersList[] = $linkM;
700 
701  }
702  }
703 
704  $autoList = [];
705  $autoMembersList = [];
706 
707  $isUserInstance = $user instanceof User;
708 
709  if ( $isUserInstance ) {
710  foreach ( $this->userGroupManager->getUserAutopromoteGroups( $user ) as $group ) {
711  $autoList[] = UserGroupMembership::getLink( $group, $this->getContext(), 'html' );
712  $autoMembersList[] = UserGroupMembership::getLink( $group, $this->getContext(),
713  'html', $user->getName() );
714  }
715  }
716 
717  $language = $this->getLanguage();
718  $displayedList = $this->msg( 'userrights-groupsmember-type' )
719  ->rawParams(
720  $language->commaList( array_merge( $tempList, $list ) ),
721  $language->commaList( array_merge( $tempMembersList, $membersList ) )
722  )->escaped();
723  $displayedAutolist = $this->msg( 'userrights-groupsmember-type' )
724  ->rawParams(
725  $language->commaList( $autoList ),
726  $language->commaList( $autoMembersList )
727  )->escaped();
728 
729  $grouplist = '';
730  $count = count( $list ) + count( $tempList );
731  if ( $count > 0 ) {
732  $grouplist = $this->msg( 'userrights-groupsmember' )
733  ->numParams( $count )
734  ->params( $user->getName() )
735  ->parse();
736  $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n";
737  }
738 
739  $count = count( $autoList );
740  if ( $count > 0 ) {
741  $autogrouplistintro = $this->msg( 'userrights-groupsmember-auto' )
742  ->numParams( $count )
743  ->params( $user->getName() )
744  ->parse();
745  $grouplist .= '<p>' . $autogrouplistintro . ' ' . $displayedAutolist . "</p>\n";
746  }
747 
748  $systemUser = $isUserInstance && $user->isSystemUser();
749  if ( $systemUser ) {
750  $systemusernote = $this->msg( 'userrights-systemuser' )
751  ->params( $user->getName() )
752  ->parse();
753  $grouplist .= '<p>' . $systemusernote . "</p>\n";
754  }
755 
756  // Only add an email link if the user is not a system user
757  $flags = $systemUser ? 0 : Linker::TOOL_LINKS_EMAIL;
758  $userToolLinks = Linker::userToolLinks(
759  $user->getId(),
760  $user->getName(),
761  false, /* default for redContribsWhenNoEdits */
762  $flags
763  );
764 
765  [ $groupCheckboxes, $canChangeAny ] =
766  $this->groupCheckboxes( $groupMemberships, $user );
767  $this->getOutput()->addHTML(
769  'form',
770  [
771  'method' => 'post',
772  'action' => $this->getPageTitle()->getLocalURL(),
773  'name' => 'editGroup',
774  'id' => 'mw-userrights-form2'
775  ]
776  ) .
777  Html::hidden( 'user', $this->mTarget ) .
778  Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) .
779  Html::hidden(
780  'conflictcheck-originalgroups',
781  implode( ',', $user->getGroups() )
782  ) . // Conflict detection
783  Xml::openElement( 'fieldset' ) .
784  Xml::element(
785  'legend',
786  [],
787  $this->msg(
788  $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup',
789  $user->getName()
790  )->text()
791  ) .
792  $this->msg(
793  $canChangeAny ? 'editinguser' : 'viewinguserrights'
794  )->params( wfEscapeWikiText( $user->getName() ) )
795  ->rawParams( $userToolLinks )->parse()
796  );
797  if ( $canChangeAny ) {
798  $this->getOutput()->addHTML(
799  $this->msg( 'userrights-groups-help', $user->getName() )->parse() .
800  $grouplist .
801  $groupCheckboxes .
802  Xml::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
803  "<tr>
804  <td class='mw-label'>" .
805  Xml::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) .
806  "</td>
807  <td class='mw-input'>" .
808  Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason' ) ?? false, [
809  'id' => 'wpReason',
810  // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
811  // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
812  // Unicode codepoints.
814  ] ) .
815  "</td>
816  </tr>
817  <tr>
818  <td></td>
819  <td class='mw-submit'>" .
820  Xml::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(),
821  [ 'name' => 'saveusergroups' ] +
822  Linker::tooltipAndAccesskeyAttribs( 'userrights-set' )
823  ) .
824  "</td>
825  </tr>" .
826  Xml::closeElement( 'table' ) . "\n"
827  );
828  } else {
829  $this->getOutput()->addHTML( $grouplist );
830  }
831  $this->getOutput()->addHTML(
832  Xml::closeElement( 'fieldset' ) .
833  Xml::closeElement( 'form' ) . "\n"
834  );
835  }
836 
840  protected static function getAllGroups() {
841  // TODO don't hard code false here (refers to local domain). See T14518
842  return MediaWikiServices::getInstance()
843  ->getUserGroupManagerFactory()
844  ->getUserGroupManager( false )
845  ->listAllGroups();
846  }
847 
857  private function groupCheckboxes( $usergroups, $user ) {
858  $allgroups = $this->getAllGroups();
859  $ret = '';
860 
861  // Get the list of preset expiry times from the system message
862  $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
863  $expiryOptions = $expiryOptionsMsg->isDisabled()
864  ? []
865  : XmlSelect::parseOptionsMessage( $expiryOptionsMsg->text() );
866 
867  // Put all column info into an associative array so that extensions can
868  // more easily manage it.
869  $columns = [ 'unchangeable' => [], 'changeable' => [] ];
870 
871  foreach ( $allgroups as $group ) {
872  $set = isset( $usergroups[$group] );
873  // Users who can add the group, but not remove it, can only lengthen
874  // expiries, not shorten them. So they should only see the expiry
875  // dropdown if the group currently has a finite expiry
876  $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
877  !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
878  // Should the checkbox be disabled?
879  $disabledCheckbox = !(
880  ( $set && $this->canRemove( $group ) ) ||
881  ( !$set && $this->canAdd( $group ) ) );
882  // Should the expiry elements be disabled?
883  $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
884  // Do we need to point out that this action is irreversible?
885  $irreversible = !$disabledCheckbox && (
886  ( $set && !$this->canAdd( $group ) ) ||
887  ( !$set && !$this->canRemove( $group ) ) );
888 
889  $checkbox = [
890  'set' => $set,
891  'disabled' => $disabledCheckbox,
892  'disabled-expiry' => $disabledExpiry,
893  'irreversible' => $irreversible
894  ];
895 
896  if ( $disabledCheckbox && $disabledExpiry ) {
897  $columns['unchangeable'][$group] = $checkbox;
898  } else {
899  $columns['changeable'][$group] = $checkbox;
900  }
901  }
902 
903  // Build the HTML table
904  $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
905  "<tr>\n";
906  foreach ( $columns as $name => $column ) {
907  if ( $column === [] ) {
908  continue;
909  }
910  // Messages: userrights-changeable-col, userrights-unchangeable-col
911  $ret .= Xml::element(
912  'th',
913  null,
914  $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text()
915  );
916  }
917 
918  $ret .= "</tr>\n<tr>\n";
919  $uiLanguage = $this->getLanguage();
920  $userName = $user->getName();
921  foreach ( $columns as $column ) {
922  if ( $column === [] ) {
923  continue;
924  }
925  $ret .= "\t<td style='vertical-align:top;'>\n";
926  foreach ( $column as $group => $checkbox ) {
927  $attr = [ 'class' => 'mw-userrights-groupcheckbox' ];
928  if ( $checkbox['disabled'] ) {
929  $attr['disabled'] = 'disabled';
930  }
931 
932  $member = $uiLanguage->getGroupMemberName( $group, $userName );
933  if ( $checkbox['irreversible'] ) {
934  $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
935  } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
936  $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
937  } else {
938  $text = $member;
939  }
940  $checkboxHtml = Xml::checkLabel( $text, "wpGroup-" . $group,
941  "wpGroup-" . $group, $checkbox['set'], $attr );
942 
943  if ( $this->canProcessExpiries() ) {
944  $uiUser = $this->getUser();
945 
946  $currentExpiry = isset( $usergroups[$group] ) ?
947  $usergroups[$group]->getExpiry() :
948  null;
949 
950  // If the user can't modify the expiry, print the current expiry below
951  // it in plain text. Otherwise provide UI to set/change the expiry
952  if ( $checkbox['set'] &&
953  ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] )
954  ) {
955  if ( $currentExpiry ) {
956  $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
957  $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
958  $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser );
959  $expiryHtml = Xml::element( 'span', null,
960  $this->msg( 'userrights-expiry-current' )->params(
961  $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text() );
962  } else {
963  $expiryHtml = Xml::element( 'span', null,
964  $this->msg( 'userrights-expiry-none' )->text() );
965  }
966  // T171345: Add a hidden form element so that other groups can still be manipulated,
967  // otherwise saving errors out with an invalid expiry time for this group.
968  $expiryHtml .= Html::hidden( "wpExpiry-$group",
969  $currentExpiry ? 'existing' : 'infinite' );
970  $expiryHtml .= "<br />\n";
971  } else {
972  $expiryHtml = Xml::element( 'span', null,
973  $this->msg( 'userrights-expiry' )->text() );
974  $expiryHtml .= Xml::openElement( 'span' );
975 
976  // add a form element to set the expiry date
977  $expiryFormOptions = new XmlSelect(
978  "wpExpiry-$group",
979  "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
980  $currentExpiry ? 'existing' : 'infinite'
981  );
982  if ( $checkbox['disabled-expiry'] ) {
983  $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
984  }
985 
986  if ( $currentExpiry ) {
987  $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
988  $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
989  $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
990  $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
991  $timestamp, $d, $t );
992  $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
993  }
994 
995  $expiryFormOptions->addOption(
996  $this->msg( 'userrights-expiry-none' )->text(),
997  'infinite'
998  );
999  $expiryFormOptions->addOption(
1000  $this->msg( 'userrights-expiry-othertime' )->text(),
1001  'other'
1002  );
1003 
1004  $expiryFormOptions->addOptions( $expiryOptions );
1005 
1006  // Add expiry dropdown
1007  $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
1008 
1009  // Add custom expiry field
1010  $attribs = [
1011  'id' => "mw-input-wpExpiry-$group-other",
1012  'class' => 'mw-userrights-expiryfield',
1013  ];
1014  if ( $checkbox['disabled-expiry'] ) {
1015  $attribs['disabled'] = 'disabled';
1016  }
1017  $expiryHtml .= Xml::input( "wpExpiry-$group-other", 30, '', $attribs );
1018 
1019  // If the user group is set but the checkbox is disabled, mimic a
1020  // checked checkbox in the form submission
1021  if ( $checkbox['set'] && $checkbox['disabled'] ) {
1022  $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
1023  }
1024 
1025  $expiryHtml .= Xml::closeElement( 'span' );
1026  }
1027 
1028  $divAttribs = [
1029  'id' => "mw-userrights-nested-wpGroup-$group",
1030  'class' => 'mw-userrights-nested',
1031  ];
1032  $checkboxHtml .= "\t\t\t" . Xml::tags( 'div', $divAttribs, $expiryHtml ) . "\n";
1033  }
1034  $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
1035  ? Xml::tags( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
1036  : Xml::tags( 'div', [], $checkboxHtml )
1037  ) . "\n";
1038  }
1039  $ret .= "\t</td>\n";
1040  }
1041  $ret .= Xml::closeElement( 'tr' ) . Xml::closeElement( 'table' );
1042 
1043  return [ $ret, (bool)$columns['changeable'] ];
1044  }
1045 
1050  private function canRemove( $group ) {
1051  $groups = $this->changeableGroups();
1052 
1053  return in_array(
1054  $group,
1055  $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] )
1056  );
1057  }
1058 
1063  private function canAdd( $group ) {
1064  $groups = $this->changeableGroups();
1065 
1066  return in_array(
1067  $group,
1068  $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] )
1069  );
1070  }
1071 
1080  protected function changeableGroups() {
1081  return $this->userGroupManager->getGroupsChangeableBy( $this->getContext()->getAuthority() );
1082  }
1083 
1090  protected function showLogFragment( $user, $output ) {
1091  $rightsLogPage = new LogPage( 'rights' );
1092  $output->addHTML( Xml::element( 'h2', null, $rightsLogPage->getName()->text() ) );
1093  LogEventsList::showLogExtract( $output, 'rights', $user->getUserPage() );
1094  }
1095 
1104  public function prefixSearchSubpages( $search, $limit, $offset ) {
1105  $search = $this->userNameUtils->getCanonical( $search );
1106  if ( !$search ) {
1107  // No prefix suggestion for invalid user
1108  return [];
1109  }
1110  // Autocomplete subpage as user list - public to allow caching
1111  return $this->userNamePrefixSearch
1112  ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
1113  }
1114 
1115  protected function getGroupName() {
1116  return 'users';
1117  }
1118 }
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 path to a specified script file, respecting file extensions; this is a wrapper around $wgScri...
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,...
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:236
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing '/'...
Definition: Html.php:256
static successBox( $html, $className='')
Return a success box.
Definition: Html.php:800
static errorBox( $html, $heading='', $className='')
Return an error box.
Definition: Html.php:788
static closeElement( $element)
Returns "</$element>".
Definition: Html.php:320
static hidden( $name, $value, array $attribs=[])
Convenience function to produce an input element with type=hidden.
Definition: Html.php:851
const TOOL_LINKS_EMAIL
Definition: Linker.php:46
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:1103
static tooltipAndAccesskeyAttribs( $name, array $msgParams=[], $options=null, $localizer=null, $user=null, $config=null, $relevantTitle=null)
Returns the attributes for the tooltip and access key.
Definition: Linker.php:2243
static showLogExtract(&$out, $types=[], $page='', $user='', $param=[])
Show log extract.
Class to simplify the use of log pages.
Definition: LogPage.php:40
Class for creating new log entries and inserting them into the database.
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Creates User objects.
Definition: UserFactory.php:38
Factory service for UserGroupManager instances.
Handles searching prefixes of user names.
UserNameUtils service.
Parent class for all special pages.
Definition: SpecialPage.php:44
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getOutput()
Get the OutputPage being used for this instance.
getUser()
Shortcut to get the User executing this instance.
getSkin()
Shortcut to get the skin being used for this instance.
getContext()
Gets the context this SpecialPage is executed in.
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getAuthority()
Shortcut to get the Authority executing this instance.
getConfig()
Shortcut to get main config object.
getRequest()
Get the WebRequest being used for this instance.
checkReadOnly()
If the wiki is currently in readonly mode, throws a ReadOnlyError.
getPageTitle( $subpage=false)
Get a self-referential title object.
getLanguage()
Shortcut to get user's language.
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
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.
Represents a "user group membership" – a specific instance of a user belonging to a group.
static getLink( $ugm, IContextSource $context, $format, $userName=null)
Gets a link for a user group, possibly including the expiry date if relevant.
static whoIs( $dbDomain, $id, $ignoreInvalidDB=false)
Same as User::whoIs()
static newFromName( $dbDomain, $name, $ignoreInvalidDB=false)
Factory function; get a remote user entry by name.
static validDatabase( $dbDomain)
Confirm the selected database name is a valid local interwiki database name.
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:70
clearInstanceCache( $reloadFrom=false)
Clear various cached data stored in this object.
Definition: User.php:1358
isHidden()
Check if user account is hidden.
Definition: User.php:1621
static whoIs( $id)
Get the username corresponding to a given user ID.
Definition: User.php:904
Special page to allow managing user group membership.
doSaveUserGroups( $user, array $add, array $remove, $reason='', array $tags=[], array $groupExpiries=[])
Save user groups changes in the database.
static expiryToTimestamp( $expiry)
Converts a user group membership expiry string into a timestamp.
showEditUserGroupsForm( $user, $groups, $groupMemberships)
Show the form to edit group memberships.
__construct(UserGroupManagerFactory $userGroupManagerFactory=null, UserNameUtils $userNameUtils=null, UserNamePrefixSearch $userNamePrefixSearch=null, UserFactory $userFactory=null)
switchForm()
Output a form to allow searching for a user.
null string $mTarget
The target of the local right-adjuster's interest.
prefixSearchSubpages( $search, $limit, $offset)
Return an array of subpages beginning with $search that this special page will accept.
canProcessExpiries()
Returns true if this user rights form can set and change user group expiries.
fetchUser( $username, $writing=true)
Normalize the input username, which may be local or remote, and return a user (or proxy) object for m...
null User $mFetchedUser
The user object of the target username or null.
showLogFragment( $user, $output)
Show a rights log fragment for the specified user.
userCanChangeRights(UserIdentity $targetUser, $checkIfSelf=true)
Check whether the current user (from context) can change the target user's rights.
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
execute( $par)
Manage forms to be shown according to posted data.
saveUserGroups( $username, $reason, $user)
Save user groups changes in the database.
static serialiseUgmForLog( $ugm)
Serialise a UserGroupMembership object for storage in the log_params section of the logging table.
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.
static isCurrentWikiId( $wikiId)
Definition: WikiMap.php:321
Class for generating HTML <select> or <datalist> elements.
Definition: XmlSelect.php:26
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:146
static closeElement( $element)
Shortcut to close an XML element.
Definition: Xml.php:121
static label( $label, $id, $attribs=[])
Convenience function to build an HTML form label.
Definition: Xml.php:367
static openElement( $element, $attribs=null)
This opens an XML element.
Definition: Xml.php:112
static input( $name, $size=false, $value=false, $attribs=[])
Convenience function to build an HTML text input field.
Definition: Xml.php:283
static submitButton( $value, $attribs=[])
Convenience function to build an HTML submit button When $wgUseMediaWikiUIEverywhere is true it will ...
Definition: Xml.php:469
static checkLabel( $label, $name, $id, $checked=false, $attribs=[])
Convenience function to build an HTML checkbox with a label.
Definition: Xml.php:428
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:389
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition: Xml.php:134
static element( $element, $attribs=null, $contents='', $allowShortTag=true)
Format an XML element with given attributes and, optionally, text content.
Definition: Xml.php:43
static fieldset( $legend=false, $content=false, $attribs=[])
Shortcut for creating fieldsets.
Definition: Xml.php:628
Interface for objects representing user identity.
getId( $wikiId=self::LOCAL)