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;
42 use OutputPage;
44 use SpecialPage;
45 use Status;
46 use User;
49 use UserRightsProxy;
50 use Xml;
51 use XmlSelect;
52 
65  protected $mTarget;
69  protected $mFetchedUser = null;
70  protected $isself = false;
71 
73  private $userGroupManagerFactory;
74 
76  private $userGroupManager = null;
77 
79  private $userNameUtils;
80 
82  private $userNamePrefixSearch;
83 
85  private $userFactory;
86 
93  public function __construct(
94  UserGroupManagerFactory $userGroupManagerFactory = null,
95  UserNameUtils $userNameUtils = null,
96  UserNamePrefixSearch $userNamePrefixSearch = null,
97  UserFactory $userFactory = 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  }
107 
108  public function doesWrites() {
109  return true;
110  }
111 
123  public function userCanChangeRights( UserIdentity $targetUser, $checkIfSelf = true ) {
124  $isself = $this->getUser()->equals( $targetUser );
125 
126  $userGroupManager = $this->userGroupManagerFactory
127  ->getUserGroupManager( $targetUser->getWikiId() );
128  $available = $userGroupManager->getGroupsChangeableBy( $this->getAuthority() );
129  if ( $targetUser->getId() === 0 ) {
130  return false;
131  }
132 
133  if ( $available['add'] || $available['remove'] ) {
134  // can change some rights for any user
135  return true;
136  }
137 
138  if ( ( $available['add-self'] || $available['remove-self'] )
139  && ( $isself || !$checkIfSelf )
140  ) {
141  // can change some rights for self
142  return true;
143  }
144 
145  return false;
146  }
147 
155  public function execute( $par ) {
156  $user = $this->getUser();
157  $request = $this->getRequest();
158  $session = $request->getSession();
159  $out = $this->getOutput();
160 
161  $out->addModules( [ 'mediawiki.special.userrights' ] );
162 
163  $this->mTarget = $par ?? $request->getVal( 'user' );
164 
165  if ( is_string( $this->mTarget ) ) {
166  $this->mTarget = trim( $this->mTarget );
167  }
168 
169  if ( $this->mTarget !== null && $this->userNameUtils->getCanonical( $this->mTarget ) === $user->getName() ) {
170  $this->isself = true;
171  }
172 
173  $fetchedStatus = $this->mTarget === null ? Status::newFatal( 'nouserspecified' ) :
174  $this->fetchUser( $this->mTarget, true );
175  if ( $fetchedStatus->isOK() ) {
176  $this->mFetchedUser = $fetchedUser = $fetchedStatus->value;
177  // Phan false positive on Status object - T323205
178  '@phan-var UserIdentity $fetchedUser';
179  $wikiId = $fetchedUser->getWikiId();
180  if ( $wikiId === UserIdentity::LOCAL ) {
181  // Set the 'relevant user' in the skin, so it displays links like Contributions,
182  // User logs, UserRights, etc.
183  $this->getSkin()->setRelevantUser( $this->mFetchedUser );
184  }
185  $this->userGroupManager = $this->userGroupManagerFactory
186  ->getUserGroupManager( $wikiId );
187  }
188 
189  // show a successbox, if the user rights was saved successfully
190  if (
191  $session->get( 'specialUserrightsSaveSuccess' ) &&
192  $this->mFetchedUser !== null
193  ) {
194  // Remove session data for the success message
195  $session->remove( 'specialUserrightsSaveSuccess' );
196 
197  $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
198  $out->addHTML(
201  'p',
202  [],
203  $this->msg( 'savedrights', $this->mFetchedUser->getName() )->text()
204  ),
205  'mw-notify-success'
206  )
207  );
208  }
209 
210  $this->setHeaders();
211  $this->outputHeader();
212 
213  $out->addModuleStyles( 'mediawiki.special' );
214  $this->addHelpLink( 'Help:Assigning permissions' );
215 
216  $this->switchForm();
217 
218  if (
219  $request->wasPosted() &&
220  $request->getCheck( 'saveusergroups' ) &&
221  $this->mTarget !== null &&
222  $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget )
223  ) {
224  /*
225  * If the user is blocked and they only have "partial" access
226  * (e.g. they don't have the userrights permission), then don't
227  * allow them to change any user rights.
228  */
229  if ( !$this->getAuthority()->isAllowed( 'userrights' ) ) {
230  $block = $user->getBlock();
231  if ( $block && $block->isSitewide() ) {
232  throw new UserBlockedError(
233  $block,
234  $user,
235  $this->getLanguage(),
236  $request->getIP()
237  );
238  }
239  }
240 
241  $this->checkReadOnly();
242 
243  // save settings
244  if ( !$fetchedStatus->isOK() ) {
245  $this->getOutput()->addWikiTextAsInterface(
246  $fetchedStatus->getWikiText( false, false, $this->getLanguage() )
247  );
248 
249  return;
250  }
251 
252  $targetUser = $this->mFetchedUser;
253  $conflictCheck = $request->getVal( 'conflictcheck-originalgroups' );
254  $conflictCheck = ( $conflictCheck === '' ) ? [] : explode( ',', $conflictCheck );
255  $userGroups = $this->userGroupManager->getUserGroups( $targetUser, UserGroupManager::READ_LATEST );
256 
257  if ( $userGroups !== $conflictCheck ) {
258  $out->addHTML( Html::errorBox(
259  $this->msg( 'userrights-conflict' )->parse()
260  ) );
261  } else {
262  $status = $this->saveUserGroups(
263  $this->mTarget,
264  $request->getVal( 'user-reason' ),
265  $targetUser
266  );
267 
268  if ( $status->isOK() ) {
269  // Set session data for the success message
270  $session->set( 'specialUserrightsSaveSuccess', 1 );
271 
272  $out->redirect( $this->getSuccessURL() );
273  return;
274  } else {
275  // Print an error message and redisplay the form
276  $out->wrapWikiTextAsInterface(
277  'error', $status->getWikiText( false, false, $this->getLanguage() )
278  );
279  }
280  }
281  }
282 
283  // show some more forms
284  if ( $this->mTarget !== null ) {
285  $this->editUserGroupsForm( $this->mTarget );
286  }
287  }
288 
289  private function getSuccessURL() {
290  return $this->getPageTitle( $this->mTarget )->getFullURL();
291  }
292 
299  public function canProcessExpiries() {
300  return true;
301  }
302 
312  public static function expiryToTimestamp( $expiry ) {
313  if ( wfIsInfinity( $expiry ) ) {
314  return null;
315  }
316 
317  $unix = strtotime( $expiry );
318 
319  if ( !$unix || $unix === -1 ) {
320  return false;
321  }
322 
323  // @todo FIXME: Non-qualified absolute times are not in users specified timezone
324  // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
325  return wfTimestamp( TS_MW, $unix );
326  }
327 
337  protected function saveUserGroups( $username, $reason, $user ) {
338  $allgroups = $this->userGroupManager->listAllGroups();
339  $addgroup = [];
340  $groupExpiries = []; // associative array of (group name => expiry)
341  $removegroup = [];
342  $existingUGMs = $this->userGroupManager->getUserGroupMemberships( $user );
343 
344  // This could possibly create a highly unlikely race condition if permissions are changed between
345  // when the form is loaded and when the form is saved. Ignoring it for the moment.
346  foreach ( $allgroups as $group ) {
347  // We'll tell it to remove all unchecked groups, and add all checked groups.
348  // Later on, this gets filtered for what can actually be removed
349  if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
350  $addgroup[] = $group;
351 
352  if ( $this->canProcessExpiries() ) {
353  // read the expiry information from the request
354  $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
355  if ( $expiryDropdown === 'existing' ) {
356  continue;
357  }
358 
359  if ( $expiryDropdown === 'other' ) {
360  $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
361  } else {
362  $expiryValue = $expiryDropdown;
363  }
364 
365  // validate the expiry
366  $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue );
367 
368  if ( $groupExpiries[$group] === false ) {
369  return Status::newFatal( 'userrights-invalid-expiry', $group );
370  }
371 
372  // not allowed to have things expiring in the past
373  if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
374  return Status::newFatal( 'userrights-expiry-in-past', $group );
375  }
376 
377  // if the user can only add this group (not remove it), the expiry time
378  // cannot be brought forward (T156784)
379  if ( !$this->canRemove( $group ) &&
380  isset( $existingUGMs[$group] ) &&
381  ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) >
382  ( $groupExpiries[$group] ?: 'infinity' )
383  ) {
384  return Status::newFatal( 'userrights-cannot-shorten-expiry', $group );
385  }
386  }
387  } else {
388  $removegroup[] = $group;
389  }
390  }
391 
392  $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
393 
394  return Status::newGood();
395  }
396 
412  public function doSaveUserGroups( $user, array $add, array $remove, $reason = '',
413  array $tags = [], array $groupExpiries = []
414  ) {
415  // Validate input set...
416  $isself = $user->getName() == $this->getUser()->getName();
417  if ( $this->userGroupManager !== null ) {
418  // Used after form submit
419  $userGroupManager = $this->userGroupManager;
420  } else {
421  // Used as backend-function
422  $userGroupManager = $this->userGroupManagerFactory
423  ->getUserGroupManager( $user->getWikiId() );
424  }
425  $groups = $userGroupManager->getUserGroups( $user );
426  $ugms = $userGroupManager->getUserGroupMemberships( $user );
427  $changeable = $userGroupManager->getGroupsChangeableBy( $this->getAuthority() );
428  $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
429  $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );
430 
431  $remove = array_unique( array_intersect( $remove, $removable, $groups ) );
432  $add = array_intersect( $add, $addable );
433 
434  // add only groups that are not already present or that need their expiry updated,
435  // UNLESS the user can only add this group (not remove it) and the expiry time
436  // is being brought forward (T156784)
437  $add = array_filter( $add,
438  static function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
439  if ( isset( $groupExpiries[$group] ) &&
440  !in_array( $group, $removable ) &&
441  isset( $ugms[$group] ) &&
442  ( $ugms[$group]->getExpiry() ?: 'infinity' ) >
443  ( $groupExpiries[$group] ?: 'infinity' )
444  ) {
445  return false;
446  }
447  return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
448  } );
449 
450  $this->getHookRunner()->onChangeUserGroups( $this->getUser(), $user, $add, $remove );
451 
452  $oldGroups = $groups;
453  $oldUGMs = $userGroupManager->getUserGroupMemberships( $user );
454  $newGroups = $oldGroups;
455 
456  // Remove groups, then add new ones/update expiries of existing ones
457  if ( $remove ) {
458  foreach ( $remove as $index => $group ) {
459  if ( !$userGroupManager->removeUserFromGroup( $user, $group ) ) {
460  unset( $remove[$index] );
461  }
462  }
463  $newGroups = array_diff( $newGroups, $remove );
464  }
465  if ( $add ) {
466  foreach ( $add as $index => $group ) {
467  $expiry = $groupExpiries[$group] ?? null;
468  if ( !$userGroupManager->addUserToGroup( $user, $group, $expiry, true ) ) {
469  unset( $add[$index] );
470  }
471  }
472  $newGroups = array_merge( $newGroups, $add );
473  }
474  $newGroups = array_unique( $newGroups );
475  $newUGMs = $userGroupManager->getUserGroupMemberships( $user );
476 
477  // Ensure that caches are cleared
478  $this->userFactory->invalidateCache( $user );
479 
480  // update groups in external authentication database
481  $this->getHookRunner()->onUserGroupsChanged( $user, $add, $remove,
482  $this->getUser(), $reason, $oldUGMs, $newUGMs );
483 
484  wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) );
485  wfDebug( 'newGroups: ' . print_r( $newGroups, true ) );
486  wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) );
487  wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) );
488 
489  // Only add a log entry if something actually changed
490  if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
491  $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
492  }
493 
494  return [ $add, $remove ];
495  }
496 
504  protected static function serialiseUgmForLog( $ugm ) {
505  if ( !$ugm instanceof UserGroupMembership ) {
506  return null;
507  }
508  return [ 'expiry' => $ugm->getExpiry() ];
509  }
510 
521  protected function addLogEntry( $user, array $oldGroups, array $newGroups, $reason,
522  array $tags, array $oldUGMs, array $newUGMs
523  ) {
524  // make sure $oldUGMs and $newUGMs are in the same order, and serialise
525  // each UGM object to a simplified array
526  $oldUGMs = array_map( function ( $group ) use ( $oldUGMs ) {
527  return isset( $oldUGMs[$group] ) ?
528  self::serialiseUgmForLog( $oldUGMs[$group] ) :
529  null;
530  }, $oldGroups );
531  $newUGMs = array_map( function ( $group ) use ( $newUGMs ) {
532  return isset( $newUGMs[$group] ) ?
533  self::serialiseUgmForLog( $newUGMs[$group] ) :
534  null;
535  }, $newGroups );
536 
537  $logEntry = new ManualLogEntry( 'rights', 'rights' );
538  $logEntry->setPerformer( $this->getUser() );
539  $logEntry->setTarget( Title::makeTitle( NS_USER, $user->getName() ) );
540  $logEntry->setComment( is_string( $reason ) ? $reason : "" );
541  $logEntry->setParameters( [
542  '4::oldgroups' => $oldGroups,
543  '5::newgroups' => $newGroups,
544  'oldmetadata' => $oldUGMs,
545  'newmetadata' => $newUGMs,
546  ] );
547  $logid = $logEntry->insert();
548  if ( count( $tags ) ) {
549  $logEntry->addTags( $tags );
550  }
551  $logEntry->publish( $logid );
552  }
553 
558  private function editUserGroupsForm( $username ) {
559  $status = $this->fetchUser( $username, true );
560  if ( !$status->isOK() ) {
561  $this->getOutput()->addWikiTextAsInterface(
562  $status->getWikiText( false, false, $this->getLanguage() )
563  );
564 
565  return;
566  }
567 
569  $user = $status->value;
570  '@phan-var UserIdentity $user';
571 
572  $groups = $this->userGroupManager->getUserGroups( $user );
573  $groupMemberships = $this->userGroupManager->getUserGroupMemberships( $user );
574  $this->showEditUserGroupsForm( $user, $groups, $groupMemberships );
575 
576  // This isn't really ideal logging behavior, but let's not hide the
577  // interwiki logs if we're using them as is.
578  $this->showLogFragment( $user, $this->getOutput() );
579  }
580 
590  public function fetchUser( $username, $writing = true ) {
591  $parts = explode( $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter ),
592  $username );
593  if ( count( $parts ) < 2 ) {
594  $name = trim( $username );
595  $dbDomain = '';
596  } else {
597  [ $name, $dbDomain ] = array_map( 'trim', $parts );
598 
599  if ( WikiMap::isCurrentWikiId( $dbDomain ) ) {
600  $dbDomain = '';
601  } else {
602  if ( $writing &&
603  !$this->getAuthority()->isAllowed( 'userrights-interwiki' )
604  ) {
605  return Status::newFatal( 'userrights-no-interwiki' );
606  }
607  if ( !UserRightsProxy::validDatabase( $dbDomain ) ) {
608  return Status::newFatal( 'userrights-nodatabase', $dbDomain );
609  }
610  }
611  }
612 
613  if ( $name === '' ) {
614  return Status::newFatal( 'nouserspecified' );
615  }
616 
617  if ( $name[0] == '#' ) {
618  // Numeric ID can be specified...
619  // We'll do a lookup for the name internally.
620  $id = intval( substr( $name, 1 ) );
621 
622  if ( $dbDomain == '' ) {
623  $name = User::whoIs( $id );
624  } else {
625  $name = UserRightsProxy::whoIs( $dbDomain, $id );
626  }
627 
628  if ( !$name ) {
629  return Status::newFatal( 'noname' );
630  }
631  } else {
632  $name = $this->userNameUtils->getCanonical( $name );
633  if ( $name === false ) {
634  // invalid name
635  return Status::newFatal( 'nosuchusershort', $username );
636  }
637  }
638 
639  if ( $dbDomain == '' ) {
640  $user = $this->userFactory->newFromName( $name );
641  } else {
642  $user = UserRightsProxy::newFromName( $dbDomain, $name );
643  }
644 
645  if ( !$user || $user->isAnon() ) {
646  return Status::newFatal( 'nosuchusershort', $username );
647  }
648 
649  if ( $user->getWikiId() === UserIdentity::LOCAL &&
650  $this->userFactory->newFromUserIdentity( $user )->isHidden() &&
651  !$this->getAuthority()->isAllowed( 'hideuser' )
652  ) {
653  // Cannot see hidden users, pretend they don't exist
654  return Status::newFatal( 'nosuchusershort', $username );
655  }
656 
657  return Status::newGood( $user );
658  }
659 
667  public function makeGroupNameList( $ids ) {
668  if ( empty( $ids ) ) {
669  return $this->msg( 'rightsnone' )->inContentLanguage()->text();
670  } else {
671  return implode( ', ', $ids );
672  }
673  }
674 
678  protected function switchForm() {
679  $this->getOutput()->addModules( 'mediawiki.userSuggest' );
680 
681  $this->getOutput()->addHTML(
683  'form',
684  [
685  'method' => 'get',
686  'action' => wfScript(),
687  'name' => 'uluser',
688  'id' => 'mw-userrights-form1'
689  ]
690  ) .
691  Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
692  Xml::fieldset( $this->msg( 'userrights-lookup-user' )->text() ) .
694  $this->msg( 'userrights-user-editname' )->text(),
695  'user',
696  'username',
697  30,
698  $this->mTarget ? str_replace( '_', ' ', $this->mTarget ) : '',
699  [
700  'class' => 'mw-autocomplete-user', // used by mediawiki.userSuggest
701  ] + (
702  // Set autofocus on blank input and error input
703  $this->mFetchedUser === null ? [ 'autofocus' => '' ] : []
704  )
705  ) . ' ' .
707  $this->msg( 'editusergroup' )->text()
708  ) .
709  Html::closeElement( 'fieldset' ) .
710  Html::closeElement( 'form' ) . "\n"
711  );
712  }
713 
723  protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) {
724  $list = $membersList = $tempList = $tempMembersList = [];
725  foreach ( $groupMemberships as $ugm ) {
726  $linkG = UserGroupMembership::getLinkHTML( $ugm, $this->getContext() );
727  $linkM = UserGroupMembership::getLinkHTML( $ugm, $this->getContext(), $user->getName() );
728  if ( $ugm->getExpiry() ) {
729  $tempList[] = $linkG;
730  $tempMembersList[] = $linkM;
731  } else {
732  $list[] = $linkG;
733  $membersList[] = $linkM;
734 
735  }
736  }
737 
738  $autoList = [];
739  $autoMembersList = [];
740 
741  if ( $user->getWikiId() === UserIdentity::LOCAL ) {
742  // Listing autopromote groups works only on the local wiki
743  foreach ( $this->userGroupManager->getUserAutopromoteGroups( $user ) as $group ) {
744  $autoList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
745  $autoMembersList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext(), $user->getName() );
746  }
747  }
748 
749  $language = $this->getLanguage();
750  $displayedList = $this->msg( 'userrights-groupsmember-type' )
751  ->rawParams(
752  $language->commaList( array_merge( $tempList, $list ) ),
753  $language->commaList( array_merge( $tempMembersList, $membersList ) )
754  )->escaped();
755  $displayedAutolist = $this->msg( 'userrights-groupsmember-type' )
756  ->rawParams(
757  $language->commaList( $autoList ),
758  $language->commaList( $autoMembersList )
759  )->escaped();
760 
761  $grouplist = '';
762  $count = count( $list ) + count( $tempList );
763  if ( $count > 0 ) {
764  $grouplist = $this->msg( 'userrights-groupsmember' )
765  ->numParams( $count )
766  ->params( $user->getName() )
767  ->parse();
768  $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n";
769  }
770 
771  $count = count( $autoList );
772  if ( $count > 0 ) {
773  $autogrouplistintro = $this->msg( 'userrights-groupsmember-auto' )
774  ->numParams( $count )
775  ->params( $user->getName() )
776  ->parse();
777  $grouplist .= '<p>' . $autogrouplistintro . ' ' . $displayedAutolist . "</p>\n";
778  }
779 
780  $systemUser = $user->getWikiId() === UserIdentity::LOCAL
781  && $this->userFactory->newFromUserIdentity( $user )->isSystemUser();
782  if ( $systemUser ) {
783  $systemusernote = $this->msg( 'userrights-systemuser' )
784  ->params( $user->getName() )
785  ->parse();
786  $grouplist .= '<p>' . $systemusernote . "</p>\n";
787  }
788 
789  // Only add an email link if the user is not a system user
790  $flags = $systemUser ? 0 : Linker::TOOL_LINKS_EMAIL;
791  $userToolLinks = Linker::userToolLinks(
792  $user->getId(),
793  $user->getName(),
794  false, /* default for redContribsWhenNoEdits */
795  $flags
796  );
797 
798  [ $groupCheckboxes, $canChangeAny ] =
799  $this->groupCheckboxes( $groupMemberships, $user );
800  $this->getOutput()->addHTML(
802  'form',
803  [
804  'method' => 'post',
805  'action' => $this->getPageTitle()->getLocalURL(),
806  'name' => 'editGroup',
807  'id' => 'mw-userrights-form2'
808  ]
809  ) .
810  Html::hidden( 'user', $this->mTarget ) .
811  Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) .
812  Html::hidden(
813  'conflictcheck-originalgroups',
814  implode( ',', $this->userGroupManager->getUserGroups( $user ) )
815  ) . // Conflict detection
816  Xml::openElement( 'fieldset' ) .
817  Xml::element(
818  'legend',
819  [],
820  $this->msg(
821  $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup',
822  $user->getName()
823  )->text()
824  ) .
825  $this->msg(
826  $canChangeAny ? 'editinguser' : 'viewinguserrights'
827  )->params( wfEscapeWikiText( $user->getName() ) )
828  ->rawParams( $userToolLinks )->parse()
829  );
830  if ( $canChangeAny ) {
831  $this->getOutput()->addHTML(
832  $this->msg( 'userrights-groups-help', $user->getName() )->parse() .
833  $grouplist .
834  $groupCheckboxes .
835  Xml::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
836  "<tr>
837  <td class='mw-label'>" .
838  Xml::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) .
839  "</td>
840  <td class='mw-input'>" .
841  Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason' ) ?? false, [
842  'id' => 'wpReason',
843  // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
844  // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
845  // Unicode codepoints.
847  ] ) .
848  "</td>
849  </tr>
850  <tr>
851  <td></td>
852  <td class='mw-submit'>" .
853  Xml::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(),
854  [ 'name' => 'saveusergroups' ] +
855  Linker::tooltipAndAccesskeyAttribs( 'userrights-set' )
856  ) .
857  "</td>
858  </tr>" .
859  Xml::closeElement( 'table' ) . "\n"
860  );
861  } else {
862  $this->getOutput()->addHTML( $grouplist );
863  }
864  $this->getOutput()->addHTML(
865  Xml::closeElement( 'fieldset' ) .
866  Xml::closeElement( 'form' ) . "\n"
867  );
868  }
869 
879  private function groupCheckboxes( $usergroups, $user ) {
880  $allgroups = $this->userGroupManager->listAllGroups();
881  $ret = '';
882 
883  // Get the list of preset expiry times from the system message
884  $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
885  $expiryOptions = $expiryOptionsMsg->isDisabled()
886  ? []
887  : XmlSelect::parseOptionsMessage( $expiryOptionsMsg->text() );
888 
889  // Put all column info into an associative array so that extensions can
890  // more easily manage it.
891  $columns = [ 'unchangeable' => [], 'changeable' => [] ];
892 
893  foreach ( $allgroups as $group ) {
894  $set = isset( $usergroups[$group] );
895  // Users who can add the group, but not remove it, can only lengthen
896  // expiries, not shorten them. So they should only see the expiry
897  // dropdown if the group currently has a finite expiry
898  $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
899  !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
900  // Should the checkbox be disabled?
901  $disabledCheckbox = !(
902  ( $set && $this->canRemove( $group ) ) ||
903  ( !$set && $this->canAdd( $group ) ) );
904  // Should the expiry elements be disabled?
905  $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
906  // Do we need to point out that this action is irreversible?
907  $irreversible = !$disabledCheckbox && (
908  ( $set && !$this->canAdd( $group ) ) ||
909  ( !$set && !$this->canRemove( $group ) ) );
910 
911  $checkbox = [
912  'set' => $set,
913  'disabled' => $disabledCheckbox,
914  'disabled-expiry' => $disabledExpiry,
915  'irreversible' => $irreversible
916  ];
917 
918  if ( $disabledCheckbox && $disabledExpiry ) {
919  $columns['unchangeable'][$group] = $checkbox;
920  } else {
921  $columns['changeable'][$group] = $checkbox;
922  }
923  }
924 
925  // Build the HTML table
926  $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
927  "<tr>\n";
928  foreach ( $columns as $name => $column ) {
929  if ( $column === [] ) {
930  continue;
931  }
932  // Messages: userrights-changeable-col, userrights-unchangeable-col
933  $ret .= Xml::element(
934  'th',
935  null,
936  $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text()
937  );
938  }
939 
940  $ret .= "</tr>\n<tr>\n";
941  $uiLanguage = $this->getLanguage();
942  $userName = $user->getName();
943  foreach ( $columns as $column ) {
944  if ( $column === [] ) {
945  continue;
946  }
947  $ret .= "\t<td style='vertical-align:top;'>\n";
948  foreach ( $column as $group => $checkbox ) {
949  $attr = [ 'class' => 'mw-userrights-groupcheckbox' ];
950  if ( $checkbox['disabled'] ) {
951  $attr['disabled'] = 'disabled';
952  }
953 
954  $member = $uiLanguage->getGroupMemberName( $group, $userName );
955  if ( $checkbox['irreversible'] ) {
956  $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
957  } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
958  $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
959  } else {
960  $text = $member;
961  }
962  $checkboxHtml = Xml::checkLabel( $text, "wpGroup-" . $group,
963  "wpGroup-" . $group, $checkbox['set'], $attr );
964 
965  if ( $this->canProcessExpiries() ) {
966  $uiUser = $this->getUser();
967 
968  $currentExpiry = isset( $usergroups[$group] ) ?
969  $usergroups[$group]->getExpiry() :
970  null;
971 
972  // If the user can't modify the expiry, print the current expiry below
973  // it in plain text. Otherwise provide UI to set/change the expiry
974  if ( $checkbox['set'] &&
975  ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] )
976  ) {
977  if ( $currentExpiry ) {
978  $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
979  $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
980  $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser );
981  $expiryHtml = Xml::element( 'span', null,
982  $this->msg( 'userrights-expiry-current' )->params(
983  $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text() );
984  } else {
985  $expiryHtml = Xml::element( 'span', null,
986  $this->msg( 'userrights-expiry-none' )->text() );
987  }
988  // T171345: Add a hidden form element so that other groups can still be manipulated,
989  // otherwise saving errors out with an invalid expiry time for this group.
990  $expiryHtml .= Html::hidden( "wpExpiry-$group",
991  $currentExpiry ? 'existing' : 'infinite' );
992  $expiryHtml .= "<br />\n";
993  } else {
994  $expiryHtml = Xml::element( 'span', null,
995  $this->msg( 'userrights-expiry' )->text() );
996  $expiryHtml .= Xml::openElement( 'span' );
997 
998  // add a form element to set the expiry date
999  $expiryFormOptions = new XmlSelect(
1000  "wpExpiry-$group",
1001  "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
1002  $currentExpiry ? 'existing' : 'infinite'
1003  );
1004  if ( $checkbox['disabled-expiry'] ) {
1005  $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
1006  }
1007 
1008  if ( $currentExpiry ) {
1009  $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
1010  $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
1011  $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
1012  $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
1013  $timestamp, $d, $t );
1014  $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
1015  }
1016 
1017  $expiryFormOptions->addOption(
1018  $this->msg( 'userrights-expiry-none' )->text(),
1019  'infinite'
1020  );
1021  $expiryFormOptions->addOption(
1022  $this->msg( 'userrights-expiry-othertime' )->text(),
1023  'other'
1024  );
1025 
1026  $expiryFormOptions->addOptions( $expiryOptions );
1027 
1028  // Add expiry dropdown
1029  $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
1030 
1031  // Add custom expiry field
1032  $attribs = [
1033  'id' => "mw-input-wpExpiry-$group-other",
1034  'class' => 'mw-userrights-expiryfield',
1035  ];
1036  if ( $checkbox['disabled-expiry'] ) {
1037  $attribs['disabled'] = 'disabled';
1038  }
1039  $expiryHtml .= Xml::input( "wpExpiry-$group-other", 30, '', $attribs );
1040 
1041  // If the user group is set but the checkbox is disabled, mimic a
1042  // checked checkbox in the form submission
1043  if ( $checkbox['set'] && $checkbox['disabled'] ) {
1044  $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
1045  }
1046 
1047  $expiryHtml .= Xml::closeElement( 'span' );
1048  }
1049 
1050  $divAttribs = [
1051  'id' => "mw-userrights-nested-wpGroup-$group",
1052  'class' => 'mw-userrights-nested',
1053  ];
1054  $checkboxHtml .= "\t\t\t" . Xml::tags( 'div', $divAttribs, $expiryHtml ) . "\n";
1055  }
1056  $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
1057  ? Xml::tags( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
1058  : Xml::tags( 'div', [], $checkboxHtml )
1059  ) . "\n";
1060  }
1061  $ret .= "\t</td>\n";
1062  }
1063  $ret .= Xml::closeElement( 'tr' ) . Xml::closeElement( 'table' );
1064 
1065  return [ $ret, (bool)$columns['changeable'] ];
1066  }
1067 
1072  private function canRemove( $group ) {
1073  $groups = $this->changeableGroups();
1074 
1075  return in_array(
1076  $group,
1077  $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] )
1078  );
1079  }
1080 
1085  private function canAdd( $group ) {
1086  $groups = $this->changeableGroups();
1087 
1088  return in_array(
1089  $group,
1090  $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] )
1091  );
1092  }
1093 
1102  protected function changeableGroups() {
1103  return $this->userGroupManager->getGroupsChangeableBy( $this->getContext()->getAuthority() );
1104  }
1105 
1112  protected function showLogFragment( $user, $output ) {
1113  $rightsLogPage = new LogPage( 'rights' );
1114  $output->addHTML( Xml::element( 'h2', null, $rightsLogPage->getName()->text() ) );
1115  LogEventsList::showLogExtract( $output, 'rights', Title::makeTitle( NS_USER, $user->getName() ) );
1116  }
1117 
1126  public function prefixSearchSubpages( $search, $limit, $offset ) {
1127  $search = $this->userNameUtils->getCanonical( $search );
1128  if ( !$search ) {
1129  // No prefix suggestion for invalid user
1130  return [];
1131  }
1132  // Autocomplete subpage as user list - public to allow caching
1133  return $this->userNamePrefixSearch
1134  ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
1135  }
1136 
1137  protected function getGroupName() {
1138  return 'users';
1139  }
1140 }
1141 
1146 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 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,...
static showLogExtract(&$out, $types=[], $page='', $user='', $param=[])
Show log extract.
Class to simplify the use of log pages.
Definition: LogPage.php:41
Class for creating new log entries and inserting them into the database.
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
Handle database storage of comments such as edit summaries and log reasons.
This class is a collection of static functions that serve two purposes:
Definition: Html.php:55
static successBox( $html, $className='')
Return a success box.
Definition: Html.php:808
static errorBox( $html, $heading='', $className='')
Return an error box.
Definition: Html.php:796
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing '/'...
Definition: Html.php:264
static hidden( $name, $value, array $attribs=[])
Convenience function to produce an input element with type=hidden.
Definition: Html.php:859
static closeElement( $element)
Returns "</$element>".
Definition: Html.php:328
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:240
Some internal bits split of from Skin.php.
Definition: Linker.php:67
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:1223
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:2388
A class containing constants representing the names of configuration variables.
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.
Special page to allow managing user group membership.
execute( $par)
Manage forms to be shown according to posted data.
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.
saveUserGroups( $username, $reason, $user)
Save user groups changes in the database.
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.
null UserIdentity $mFetchedUser
The user object of the target username or null.
__construct(UserGroupManagerFactory $userGroupManagerFactory=null, UserNameUtils $userNameUtils=null, UserNamePrefixSearch $userNamePrefixSearch=null, UserFactory $userFactory=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 (or proxy) object for m...
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.
Represents a title within MediaWiki.
Definition: Title.php:82
static makeTitle( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:693
Creates User objects.
Definition: UserFactory.php:42
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.
Handles searching prefixes of user names.
UserNameUtils service.
Helper tools for dealing with other locally-hosted wikis.
Definition: WikiMap.php:33
static isCurrentWikiId( $wikiId)
Definition: WikiMap.php:321
This is one of the Core classes and should be read at least once by any new developers.
Definition: OutputPage.php:60
Show an error when a user tries to do something they do not have the necessary permissions for.
Parent class for all special pages.
Definition: SpecialPage.php:45
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
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:46
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 getLinkHTML( $ugm, IContextSource $context, $userName=null)
Gets a link for a user group, possibly including the expiry date if relevant.
Cut-down copy of User interface for local-interwiki-database user rights manipulation.
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:71
static whoIs( $id)
Get the username corresponding to a given user ID.
Definition: User.php:902
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:31
static closeElement( $element)
Shortcut to close an XML element.
Definition: Xml.php:122
static label( $label, $id, $attribs=[])
Convenience function to build an HTML form label.
Definition: Xml.php:365
static openElement( $element, $attribs=null)
This opens an XML element.
Definition: Xml.php:113
static input( $name, $size=false, $value=false, $attribs=[])
Convenience function to build an HTML text input field.
Definition: Xml.php:281
static submitButton( $value, $attribs=[])
Convenience function to build an HTML submit button When $wgUseMediaWikiUIEverywhere is true it will ...
Definition: Xml.php:467
static checkLabel( $label, $name, $id, $checked=false, $attribs=[])
Convenience function to build an HTML checkbox with a label.
Definition: Xml.php:426
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:387
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition: Xml.php:135
static element( $element, $attribs=null, $contents='', $allowShortTag=true)
Format an XML element with given attributes and, optionally, text content.
Definition: Xml.php:44
static fieldset( $legend=false, $content=false, $attribs=[])
Shortcut for creating fieldsets.
Definition: Xml.php:626
getWikiId()
Get the ID of the wiki this page belongs to.
Interface for objects representing user identity.
getId( $wikiId=self::LOCAL)