MediaWiki 1.43.1
SpecialUserRights.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\Specials;
22
24use LogPage;
53
66 protected $mTarget;
70 protected $mFetchedUser = null;
72 protected $isself = false;
73
74 private UserGroupManagerFactory $userGroupManagerFactory;
75
77 private $userGroupManager = null;
78
79 private UserNameUtils $userNameUtils;
80 private UserNamePrefixSearch $userNamePrefixSearch;
81 private UserFactory $userFactory;
82 private ActorStoreFactory $actorStoreFactory;
83 private WatchlistManager $watchlistManager;
84 private TempUserConfig $tempUserConfig;
85
95 public function __construct(
96 ?UserGroupManagerFactory $userGroupManagerFactory = null,
97 ?UserNameUtils $userNameUtils = null,
98 ?UserNamePrefixSearch $userNamePrefixSearch = null,
99 ?UserFactory $userFactory = null,
100 ?ActorStoreFactory $actorStoreFactory = null,
101 ?WatchlistManager $watchlistManager = null,
102 ?TempUserConfig $tempUserConfig = null
103 ) {
104 parent::__construct( 'Userrights' );
105 $services = MediaWikiServices::getInstance();
106 // This class is extended and therefore falls back to global state - T263207
107 $this->userNameUtils = $userNameUtils ?? $services->getUserNameUtils();
108 $this->userNamePrefixSearch = $userNamePrefixSearch ?? $services->getUserNamePrefixSearch();
109 $this->userFactory = $userFactory ?? $services->getUserFactory();
110 $this->userGroupManagerFactory = $userGroupManagerFactory ?? $services->getUserGroupManagerFactory();
111 $this->actorStoreFactory = $actorStoreFactory ?? $services->getActorStoreFactory();
112 $this->watchlistManager = $watchlistManager ?? $services->getWatchlistManager();
113 $this->tempUserConfig = $tempUserConfig ?? $services->getTempUserConfig();
114 }
115
116 public function doesWrites() {
117 return true;
118 }
119
131 public function userCanChangeRights( UserIdentity $targetUser, $checkIfSelf = true ) {
132 if (
133 !$targetUser->isRegistered() ||
134 $this->userNameUtils->isTemp( $targetUser->getName() )
135 ) {
136 return false;
137 }
138
139 $userGroupManager = $this->userGroupManagerFactory
140 ->getUserGroupManager( $targetUser->getWikiId() );
141 $available = $userGroupManager->getGroupsChangeableBy( $this->getAuthority() );
142 if ( $available['add'] || $available['remove'] ) {
143 // can change some rights for any user
144 return true;
145 }
146
147 $isself = $this->getUser()->equals( $targetUser );
148 if ( ( $available['add-self'] || $available['remove-self'] )
149 && ( $isself || !$checkIfSelf )
150 ) {
151 // can change some rights for self
152 return true;
153 }
154
155 return false;
156 }
157
165 public function execute( $par ) {
166 $user = $this->getUser();
167 $request = $this->getRequest();
168 $session = $request->getSession();
169 $out = $this->getOutput();
170
171 $out->addModules( [ 'mediawiki.special.userrights' ] );
172
173 $this->mTarget = $par ?? $request->getVal( 'user' );
174 if ( $this->mTarget === null ) {
175 $fetchedStatus = Status::newFatal( 'nouserspecified' );
176
177 } else {
178 $this->mTarget = trim( $this->mTarget );
179
180 if ( $this->userNameUtils->getCanonical( $this->mTarget ) === $user->getName() ) {
181 $this->isself = true;
182 }
183
184 $fetchedStatus = $this->fetchUser( $this->mTarget, true );
185 }
186
187 if ( $fetchedStatus->isOK() ) {
188 $this->mFetchedUser = $fetchedUser = $fetchedStatus->value;
189 // Phan false positive on Status object - T323205
190 '@phan-var UserIdentity $fetchedUser';
191 $wikiId = $fetchedUser->getWikiId();
192 if ( $wikiId === UserIdentity::LOCAL ) {
193 // Set the 'relevant user' in the skin, so it displays links like Contributions,
194 // User logs, UserRights, etc.
195 $this->getSkin()->setRelevantUser( $this->mFetchedUser );
196 }
197 $this->userGroupManager = $this->userGroupManagerFactory
198 ->getUserGroupManager( $wikiId );
199 }
200
201 // show a successbox, if the user rights was saved successfully
202 if (
203 $session->get( 'specialUserrightsSaveSuccess' ) &&
204 $this->mFetchedUser !== null
205 ) {
206 // Remove session data for the success message
207 $session->remove( 'specialUserrightsSaveSuccess' );
208
209 $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
210 $out->addHTML(
211 Html::successBox(
213 'p',
214 [],
215 $this->msg( 'savedrights', $this->getDisplayUsername( $this->mFetchedUser ) )->text()
216 ),
217 'mw-notify-success'
218 )
219 );
220 }
221
222 $this->setHeaders();
223 $this->outputHeader();
224
225 $out->addModuleStyles( 'mediawiki.special' );
226 $this->addHelpLink( 'Help:Assigning permissions' );
227
228 $this->switchForm();
229
230 if (
231 $request->wasPosted() &&
232 $request->getCheck( 'saveusergroups' ) &&
233 $this->mTarget !== null &&
234 $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget )
235 ) {
236 /*
237 * If the user is blocked and they only have "partial" access
238 * (e.g. they don't have the userrights permission), then don't
239 * allow them to change any user rights.
240 */
241 if ( !$this->getAuthority()->isAllowed( 'userrights' ) ) {
242 $block = $user->getBlock();
243 if ( $block && $block->isSitewide() ) {
244 throw new UserBlockedError(
245 $block,
246 $user,
247 $this->getLanguage(),
248 $request->getIP()
249 );
250 }
251 }
252
253 $this->checkReadOnly();
254
255 // save settings
256 if ( !$fetchedStatus->isOK() ) {
257 $this->getOutput()->addWikiTextAsInterface(
258 $fetchedStatus->getWikiText( false, false, $this->getLanguage() )
259 );
260
261 return;
262 }
263
264 $targetUser = $this->mFetchedUser;
265 $conflictCheck = $request->getVal( 'conflictcheck-originalgroups' );
266 $conflictCheck = ( $conflictCheck === '' ) ? [] : explode( ',', $conflictCheck );
267 $userGroups = $this->userGroupManager->getUserGroups( $targetUser, IDBAccessObject::READ_LATEST );
268
269 if ( $userGroups !== $conflictCheck ) {
270 $out->addHTML( Html::errorBox(
271 $this->msg( 'userrights-conflict' )->parse()
272 ) );
273 } else {
274 $status = $this->saveUserGroups(
275 $request->getText( 'user-reason' ),
276 $targetUser
277 );
278
279 if ( $status->isOK() ) {
280 // Set session data for the success message
281 $session->set( 'specialUserrightsSaveSuccess', 1 );
282
283 $out->redirect( $this->getSuccessURL() );
284 return;
285 } else {
286 // Print an error message and redisplay the form
287 $out->wrapWikiTextAsInterface(
288 'error', $status->getWikiText( false, false, $this->getLanguage() )
289 );
290 }
291 }
292 }
293
294 // show some more forms
295 if ( $this->mTarget !== null ) {
296 $this->editUserGroupsForm( $this->mTarget );
297 }
298 }
299
300 private function getSuccessURL() {
301 return $this->getPageTitle( $this->mTarget )->getFullURL();
302 }
303
310 public function canProcessExpiries() {
311 return true;
312 }
313
323 public static function expiryToTimestamp( $expiry ) {
324 if ( wfIsInfinity( $expiry ) ) {
325 return null;
326 }
327
328 $unix = strtotime( $expiry );
329
330 if ( !$unix || $unix === -1 ) {
331 return false;
332 }
333
334 // @todo FIXME: Non-qualified absolute times are not in users specified timezone
335 // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
336 return wfTimestamp( TS_MW, $unix );
337 }
338
347 protected function saveUserGroups( string $reason, UserIdentity $user ) {
348 if ( $this->userNameUtils->isTemp( $user->getName() ) ) {
349 return Status::newFatal( 'userrights-no-tempuser' );
350 }
351
352 // Prevent cross-wiki assignment of groups to temporary accounts on wikis where the feature is not known.
353 if (
354 $user->getWikiId() !== UserIdentity::LOCAL &&
355 !$this->tempUserConfig->isKnown() &&
356 $this->tempUserConfig->isReservedName( $user->getName() )
357 ) {
358 return Status::newFatal( 'userrights-cross-wiki-assignment-for-reserved-name' );
359 }
360
361 $allgroups = $this->userGroupManager->listAllGroups();
362 $addgroup = [];
363 $groupExpiries = []; // associative array of (group name => expiry)
364 $removegroup = [];
365 $existingUGMs = $this->userGroupManager->getUserGroupMemberships( $user );
366
367 // This could possibly create a highly unlikely race condition if permissions are changed between
368 // when the form is loaded and when the form is saved. Ignoring it for the moment.
369 foreach ( $allgroups as $group ) {
370 // We'll tell it to remove all unchecked groups, and add all checked groups.
371 // Later on, this gets filtered for what can actually be removed
372 if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
373 $addgroup[] = $group;
374
375 if ( $this->canProcessExpiries() ) {
376 // read the expiry information from the request
377 $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
378 if ( $expiryDropdown === 'existing' ) {
379 continue;
380 }
381
382 if ( $expiryDropdown === 'other' ) {
383 $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
384 } else {
385 $expiryValue = $expiryDropdown;
386 }
387
388 // validate the expiry
389 $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue );
390
391 if ( $groupExpiries[$group] === false ) {
392 return Status::newFatal( 'userrights-invalid-expiry', $group );
393 }
394
395 // not allowed to have things expiring in the past
396 if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
397 return Status::newFatal( 'userrights-expiry-in-past', $group );
398 }
399
400 // if the user can only add this group (not remove it), the expiry time
401 // cannot be brought forward (T156784)
402 if ( !$this->canRemove( $group ) &&
403 isset( $existingUGMs[$group] ) &&
404 ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) >
405 ( $groupExpiries[$group] ?: 'infinity' )
406 ) {
407 return Status::newFatal( 'userrights-cannot-shorten-expiry', $group );
408 }
409 }
410 } else {
411 $removegroup[] = $group;
412 }
413 }
414
415 $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
416
417 if ( $user->getWikiId() === UserIdentity::LOCAL && $this->getRequest()->getCheck( 'wpWatch' ) ) {
418 $this->watchlistManager->addWatchIgnoringRights(
419 $this->getUser(),
420 Title::makeTitle( NS_USER, $user->getName() )
421 );
422 }
423
424 return Status::newGood();
425 }
426
442 public function doSaveUserGroups( $user, array $add, array $remove, string $reason = '',
443 array $tags = [], array $groupExpiries = []
444 ) {
445 // Validate input set...
446 $isself = $user->getName() == $this->getUser()->getName();
447 if ( $this->userGroupManager !== null ) {
448 // Used after form submit
449 $userGroupManager = $this->userGroupManager;
450 } else {
451 // Used as backend-function
452 $userGroupManager = $this->userGroupManagerFactory
453 ->getUserGroupManager( $user->getWikiId() );
454 }
455 $groups = $userGroupManager->getUserGroups( $user );
456 $ugms = $userGroupManager->getUserGroupMemberships( $user );
457 $changeable = $userGroupManager->getGroupsChangeableBy( $this->getAuthority() );
458 $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
459 $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );
460
461 $remove = array_unique( array_intersect( $remove, $removable, $groups ) );
462 $add = array_intersect( $add, $addable );
463
464 // add only groups that are not already present or that need their expiry updated,
465 // UNLESS the user can only add this group (not remove it) and the expiry time
466 // is being brought forward (T156784)
467 $add = array_filter( $add,
468 static function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
469 if ( isset( $groupExpiries[$group] ) &&
470 !in_array( $group, $removable ) &&
471 isset( $ugms[$group] ) &&
472 ( $ugms[$group]->getExpiry() ?: 'infinity' ) >
473 ( $groupExpiries[$group] ?: 'infinity' )
474 ) {
475 return false;
476 }
477 return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
478 } );
479
480 if ( $user->getWikiId() === UserIdentity::LOCAL ) {
481 // For compatibility local changes are provided as User object to the hook
482 $hookUser = $this->userFactory->newFromUserIdentity( $user );
483 } else {
484 // Interwiki changes are provided as UserIdentity since 1.41, was UserRightsProxy before
485 $hookUser = $user;
486 }
487 $this->getHookRunner()->onChangeUserGroups( $this->getUser(), $hookUser, $add, $remove );
488
489 $oldGroups = $groups;
490 $oldUGMs = $userGroupManager->getUserGroupMemberships( $user );
491 $newGroups = $oldGroups;
492
493 // Remove groups, then add new ones/update expiries of existing ones
494 if ( $remove ) {
495 foreach ( $remove as $index => $group ) {
496 if ( !$userGroupManager->removeUserFromGroup( $user, $group ) ) {
497 unset( $remove[$index] );
498 }
499 }
500 $newGroups = array_diff( $newGroups, $remove );
501 }
502 if ( $add ) {
503 foreach ( $add as $index => $group ) {
504 $expiry = $groupExpiries[$group] ?? null;
505 if ( !$userGroupManager->addUserToGroup( $user, $group, $expiry, true ) ) {
506 unset( $add[$index] );
507 }
508 }
509 $newGroups = array_merge( $newGroups, $add );
510 }
511 $newGroups = array_unique( $newGroups );
512 $newUGMs = $userGroupManager->getUserGroupMemberships( $user );
513
514 // Ensure that caches are cleared
515 $this->userFactory->invalidateCache( $user );
516
517 // update groups in external authentication database
518 $this->getHookRunner()->onUserGroupsChanged( $hookUser, $add, $remove,
519 $this->getUser(), $reason, $oldUGMs, $newUGMs );
520
521 wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) );
522 wfDebug( 'newGroups: ' . print_r( $newGroups, true ) );
523 wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) );
524 wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) );
525
526 // Only add a log entry if something actually changed
527 if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
528 $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
529 }
530
531 return [ $add, $remove ];
532 }
533
541 protected static function serialiseUgmForLog( $ugm ) {
542 if ( !$ugm instanceof UserGroupMembership ) {
543 return null;
544 }
545 return [ 'expiry' => $ugm->getExpiry() ];
546 }
547
558 protected function addLogEntry( $user, array $oldGroups, array $newGroups, string $reason,
559 array $tags, array $oldUGMs, array $newUGMs
560 ) {
561 // make sure $oldUGMs and $newUGMs are in the same order, and serialise
562 // each UGM object to a simplified array
563 $oldUGMs = array_map( static function ( $group ) use ( $oldUGMs ) {
564 return isset( $oldUGMs[$group] ) ?
565 self::serialiseUgmForLog( $oldUGMs[$group] ) :
566 null;
567 }, $oldGroups );
568 $newUGMs = array_map( static function ( $group ) use ( $newUGMs ) {
569 return isset( $newUGMs[$group] ) ?
570 self::serialiseUgmForLog( $newUGMs[$group] ) :
571 null;
572 }, $newGroups );
573
574 $logEntry = new ManualLogEntry( 'rights', 'rights' );
575 $logEntry->setPerformer( $this->getUser() );
576 $logEntry->setTarget( Title::makeTitle( NS_USER, $this->getDisplayUsername( $user ) ) );
577 $logEntry->setComment( $reason );
578 $logEntry->setParameters( [
579 '4::oldgroups' => $oldGroups,
580 '5::newgroups' => $newGroups,
581 'oldmetadata' => $oldUGMs,
582 'newmetadata' => $newUGMs,
583 ] );
584 $logid = $logEntry->insert();
585 if ( count( $tags ) ) {
586 $logEntry->addTags( $tags );
587 }
588 $logEntry->publish( $logid );
589 }
590
595 private function editUserGroupsForm( $username ) {
596 $status = $this->fetchUser( $username, true );
597 if ( !$status->isOK() ) {
598 $this->getOutput()->addWikiTextAsInterface(
599 $status->getWikiText( false, false, $this->getLanguage() )
600 );
601
602 return;
603 }
604
606 $user = $status->value;
607 '@phan-var UserIdentity $user';
608
609 $groups = $this->userGroupManager->getUserGroups( $user );
610 $groupMemberships = $this->userGroupManager->getUserGroupMemberships( $user );
611 $this->showEditUserGroupsForm( $user, $groups, $groupMemberships );
612
613 // This isn't really ideal logging behavior, but let's not hide the
614 // interwiki logs if we're using them as is.
615 $this->showLogFragment( $user, $this->getOutput() );
616 }
617
627 public function fetchUser( $username, $writing = true ) {
628 $parts = explode( $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter ),
629 $username );
630 if ( count( $parts ) < 2 ) {
631 $name = trim( $username );
632 $wikiId = UserIdentity::LOCAL;
633 } else {
634 [ $name, $wikiId ] = array_map( 'trim', $parts );
635
636 if ( WikiMap::isCurrentWikiId( $wikiId ) ) {
637 $wikiId = UserIdentity::LOCAL;
638 } else {
639 if ( $writing &&
640 !$this->getAuthority()->isAllowed( 'userrights-interwiki' )
641 ) {
642 return Status::newFatal( 'userrights-no-interwiki' );
643 }
644 $localDatabases = $this->getConfig()->get( MainConfigNames::LocalDatabases );
645 if ( !in_array( $wikiId, $localDatabases ) ) {
646 return Status::newFatal( 'userrights-nodatabase', $wikiId );
647 }
648 }
649 }
650
651 if ( $name === '' ) {
652 return Status::newFatal( 'nouserspecified' );
653 }
654
655 $userIdentityLookup = $this->actorStoreFactory->getUserIdentityLookup( $wikiId );
656 if ( $name[0] == '#' ) {
657 // Numeric ID can be specified...
658 $id = intval( substr( $name, 1 ) );
659
660 $user = $userIdentityLookup->getUserIdentityByUserId( $id );
661 if ( !$user ) {
662 // Different error message for compatibility
663 return Status::newFatal( 'noname' );
664 }
665 $name = $user->getName();
666 } else {
667 $name = $this->userNameUtils->getCanonical( $name );
668 if ( $name === false ) {
669 // invalid name
670 return Status::newFatal( 'nosuchusershort', $username );
671 }
672 $user = $userIdentityLookup->getUserIdentityByName( $name );
673 }
674
675 if ( $this->userNameUtils->isTemp( $name ) ) {
676 return Status::newFatal( 'userrights-no-group' );
677 }
678
679 if ( !$user || !$user->isRegistered() ) {
680 return Status::newFatal( 'nosuchusershort', $username );
681 }
682
683 // Prevent cross-wiki assignment of groups to temporary accounts on wikis where the feature is not known.
684 // We have to check this here, as ApiUserrights uses this to validate before assigning user rights.
685 if (
686 $user->getWikiId() !== UserIdentity::LOCAL &&
687 !$this->tempUserConfig->isKnown() &&
688 $this->tempUserConfig->isReservedName( $name )
689 ) {
690 return Status::newFatal( 'userrights-no-group' );
691 }
692
693 if ( $user->getWikiId() === UserIdentity::LOCAL &&
694 $this->userFactory->newFromUserIdentity( $user )->isHidden() &&
695 !$this->getAuthority()->isAllowed( 'hideuser' )
696 ) {
697 // Cannot see hidden users, pretend they don't exist
698 return Status::newFatal( 'nosuchusershort', $username );
699 }
700
701 return Status::newGood( $user );
702 }
703
711 public function makeGroupNameList( $ids ) {
712 if ( !$ids ) {
713 return $this->msg( 'rightsnone' )->inContentLanguage()->text();
714 } else {
715 return implode( ', ', $ids );
716 }
717 }
718
722 protected function switchForm() {
723 $formDescriptor = [
724 'user' => [
725 'class' => HTMLUserTextField::class,
726 'label-message' => 'userrights-user-editname',
727 'name' => 'user',
728 'ipallowed' => true,
729 'iprange' => true,
730 'excludetemp' => true, // Do not show temp users: T341684
731 'autofocus' => $this->mFetchedUser === null,
732 'default' => $this->mTarget,
733 ]
734 ];
735
736 $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
737 $htmlForm
738 ->setMethod( 'GET' )
739 ->setAction( wfScript() )
740 ->setName( 'uluser' )
741 ->setTitle( SpecialPage::getTitleFor( 'Userrights' ) )
742 ->setWrapperLegendMsg( 'userrights-lookup-user' )
743 ->setId( 'mw-userrights-form1' )
744 ->setSubmitTextMsg( 'editusergroup' )
745 ->prepareForm()
746 ->displayForm( true );
747 }
748
758 protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) {
759 $list = $membersList = $tempList = $tempMembersList = [];
760 foreach ( $groupMemberships as $ugm ) {
761 $linkG = UserGroupMembership::getLinkHTML( $ugm, $this->getContext() );
762 $linkM = UserGroupMembership::getLinkHTML( $ugm, $this->getContext(), $user->getName() );
763 if ( $ugm->getExpiry() ) {
764 $tempList[] = $linkG;
765 $tempMembersList[] = $linkM;
766 } else {
767 $list[] = $linkG;
768 $membersList[] = $linkM;
769
770 }
771 }
772
773 $autoList = [];
774 $autoMembersList = [];
775
776 if ( $user->getWikiId() === UserIdentity::LOCAL ) {
777 // Listing autopromote groups works only on the local wiki
778 foreach ( $this->userGroupManager->getUserAutopromoteGroups( $user ) as $group ) {
779 $autoList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
780 $autoMembersList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext(), $user->getName() );
781 }
782 }
783
784 $language = $this->getLanguage();
785 $displayedList = $this->msg( 'userrights-groupsmember-type' )
786 ->rawParams(
787 $language->commaList( array_merge( $tempList, $list ) ),
788 $language->commaList( array_merge( $tempMembersList, $membersList ) )
789 )->escaped();
790 $displayedAutolist = $this->msg( 'userrights-groupsmember-type' )
791 ->rawParams(
792 $language->commaList( $autoList ),
793 $language->commaList( $autoMembersList )
794 )->escaped();
795
796 $grouplist = '';
797 $count = count( $list ) + count( $tempList );
798 if ( $count > 0 ) {
799 $grouplist = $this->msg( 'userrights-groupsmember' )
800 ->numParams( $count )
801 ->params( $user->getName() )
802 ->parse();
803 $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n";
804 }
805
806 $count = count( $autoList );
807 if ( $count > 0 ) {
808 $autogrouplistintro = $this->msg( 'userrights-groupsmember-auto' )
809 ->numParams( $count )
810 ->params( $user->getName() )
811 ->parse();
812 $grouplist .= '<p>' . $autogrouplistintro . ' ' . $displayedAutolist . "</p>\n";
813 }
814
815 $systemUser = $user->getWikiId() === UserIdentity::LOCAL
816 && $this->userFactory->newFromUserIdentity( $user )->isSystemUser();
817 if ( $systemUser ) {
818 $systemusernote = $this->msg( 'userrights-systemuser' )
819 ->params( $user->getName() )
820 ->parse();
821 $grouplist .= '<p>' . $systemusernote . "</p>\n";
822 }
823
824 // Only add an email link if the user is not a system user
825 $flags = $systemUser ? 0 : Linker::TOOL_LINKS_EMAIL;
826 $userToolLinks = Linker::userToolLinks(
827 $user->getId( $user->getWikiId() ),
828 $this->getDisplayUsername( $user ),
829 false, /* default for redContribsWhenNoEdits */
830 $flags
831 );
832
833 [ $groupCheckboxes, $canChangeAny ] =
834 $this->groupCheckboxes( $groupMemberships, $user );
835 $this->getOutput()->addHTML(
836 Html::openElement(
837 'form',
838 [
839 'method' => 'post',
840 'action' => $this->getPageTitle()->getLocalURL(),
841 'name' => 'editGroup',
842 'id' => 'mw-userrights-form2'
843 ]
844 ) .
845 Html::hidden( 'user', $this->mTarget ) .
846 Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) .
847 Html::hidden(
848 'conflictcheck-originalgroups',
849 implode( ',', $this->userGroupManager->getUserGroups( $user ) )
850 ) . // Conflict detection
851 Html::openElement( 'fieldset' ) .
853 'legend',
854 [],
855 $this->msg(
856 $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup',
857 $user->getName()
858 )->text()
859 ) .
860 $this->msg(
861 $canChangeAny ? 'editinguser' : 'viewinguserrights'
862 )->params( wfEscapeWikiText( $this->getDisplayUsername( $user ) ) )
863 ->rawParams( $userToolLinks )->parse()
864 );
865 if ( $canChangeAny ) {
866 $this->getOutput()->addHTML(
867 $this->msg( 'userrights-groups-help', $user->getName() )->parse() .
868 $grouplist .
869 $groupCheckboxes .
870 Html::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
871 "<tr>
872 <td class='mw-label'>" .
873 Html::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) .
874 "</td>
875 <td class='mw-input'>" .
876 Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason' ) ?? false, [
877 'id' => 'wpReason',
878 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
879 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
880 // Unicode codepoints.
881 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
882 ] ) .
883 "</td>
884 </tr>
885 <tr>
886 <td></td>
887 <td class='mw-submit'>" .
888 Html::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(),
889 [ 'name' => 'saveusergroups' ] +
890 Linker::tooltipAndAccesskeyAttribs( 'userrights-set' )
891 ) .
892 "</td>
893 </tr>
894 <tr>
895 <td></td>
896 <td class='mw-input'>" .
897 Html::check( 'wpWatch', false, [ 'id' => 'wpWatch' ] ) .
898 '&nbsp;' . Html::label( $this->msg( 'userrights-watchuser' )->text(), 'wpWatch' ) .
899 "</td>
900 </tr>" .
901 Xml::closeElement( 'table' ) . "\n"
902 );
903 } else {
904 $this->getOutput()->addHTML( $grouplist );
905 }
906 $this->getOutput()->addHTML(
907 Xml::closeElement( 'fieldset' ) .
908 Xml::closeElement( 'form' ) . "\n"
909 );
910 }
911
921 private function groupCheckboxes( $usergroups, UserIdentity $user ) {
922 $allgroups = $this->userGroupManager->listAllGroups();
923 $ret = '';
924
925 // Get the list of preset expiry times from the system message
926 $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
927 $expiryOptions = $expiryOptionsMsg->isDisabled()
928 ? []
929 : XmlSelect::parseOptionsMessage( $expiryOptionsMsg->text() );
930
931 // Put all column info into an associative array so that extensions can
932 // more easily manage it.
933 $columns = [ 'unchangeable' => [], 'changeable' => [] ];
934
935 foreach ( $allgroups as $group ) {
936 $set = isset( $usergroups[$group] );
937 // Users who can add the group, but not remove it, can only lengthen
938 // expiries, not shorten them. So they should only see the expiry
939 // dropdown if the group currently has a finite expiry
940 $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
941 !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
942 // Should the checkbox be disabled?
943 $disabledCheckbox = !(
944 ( $set && $this->canRemove( $group ) ) ||
945 ( !$set && $this->canAdd( $group ) ) );
946 // Should the expiry elements be disabled?
947 $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
948 // Do we need to point out that this action is irreversible?
949 $irreversible = !$disabledCheckbox && (
950 ( $set && !$this->canAdd( $group ) ) ||
951 ( !$set && !$this->canRemove( $group ) ) );
952
953 $checkbox = [
954 'set' => $set,
955 'disabled' => $disabledCheckbox,
956 'disabled-expiry' => $disabledExpiry,
957 'irreversible' => $irreversible
958 ];
959
960 if ( $disabledCheckbox && $disabledExpiry ) {
961 $columns['unchangeable'][$group] = $checkbox;
962 } else {
963 $columns['changeable'][$group] = $checkbox;
964 }
965 }
966
967 // Build the HTML table
968 $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
969 "<tr>\n";
970 foreach ( $columns as $name => $column ) {
971 if ( $column === [] ) {
972 continue;
973 }
974 // Messages: userrights-changeable-col, userrights-unchangeable-col
975 $ret .= Xml::element(
976 'th',
977 null,
978 $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text()
979 );
980 }
981
982 $ret .= "</tr>\n<tr>\n";
983 $uiLanguage = $this->getLanguage();
984 $userName = $user->getName();
985 foreach ( $columns as $column ) {
986 if ( $column === [] ) {
987 continue;
988 }
989 $ret .= "\t<td style='vertical-align:top;'>\n";
990 foreach ( $column as $group => $checkbox ) {
991 $member = $uiLanguage->getGroupMemberName( $group, $userName );
992 if ( $checkbox['irreversible'] ) {
993 $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
994 } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
995 $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
996 } else {
997 $text = $member;
998 }
999 $checkboxHtml = Html::element( 'input', [
1000 'type' => 'checkbox', 'name' => "wpGroup-$group", 'value' => '1',
1001 'id' => "wpGroup-$group", 'checked' => $checkbox['set'],
1002 'class' => 'mw-userrights-groupcheckbox',
1003 'disabled' => $checkbox['disabled'],
1004 ] ) . '&nbsp;' . Html::label( $text, "wpGroup-$group" );
1005
1006 if ( $this->canProcessExpiries() ) {
1007 $uiUser = $this->getUser();
1008
1009 $currentExpiry = isset( $usergroups[$group] ) ?
1010 $usergroups[$group]->getExpiry() :
1011 null;
1012
1013 // If the user can't modify the expiry, print the current expiry below
1014 // it in plain text. Otherwise provide UI to set/change the expiry
1015 if ( $checkbox['set'] &&
1016 ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] )
1017 ) {
1018 if ( $currentExpiry ) {
1019 $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
1020 $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
1021 $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser );
1022 $expiryHtml = Xml::element( 'span', null,
1023 $this->msg( 'userrights-expiry-current' )->params(
1024 $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text() );
1025 } else {
1026 $expiryHtml = Xml::element( 'span', null,
1027 $this->msg( 'userrights-expiry-none' )->text() );
1028 }
1029 // T171345: Add a hidden form element so that other groups can still be manipulated,
1030 // otherwise saving errors out with an invalid expiry time for this group.
1031 $expiryHtml .= Html::hidden( "wpExpiry-$group",
1032 $currentExpiry ? 'existing' : 'infinite' );
1033 $expiryHtml .= "<br />\n";
1034 } else {
1035 $expiryHtml = Html::element( 'span', [],
1036 $this->msg( 'userrights-expiry' )->text() );
1037 $expiryHtml .= Html::openElement( 'span' );
1038
1039 // add a form element to set the expiry date
1040 $expiryFormOptions = new XmlSelect(
1041 "wpExpiry-$group",
1042 "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
1043 $currentExpiry ? 'existing' : 'infinite'
1044 );
1045 if ( $checkbox['disabled-expiry'] ) {
1046 $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
1047 }
1048
1049 if ( $currentExpiry ) {
1050 $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
1051 $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
1052 $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
1053 $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
1054 $timestamp, $d, $t );
1055 $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
1056 }
1057
1058 $expiryFormOptions->addOption(
1059 $this->msg( 'userrights-expiry-none' )->text(),
1060 'infinite'
1061 );
1062 $expiryFormOptions->addOption(
1063 $this->msg( 'userrights-expiry-othertime' )->text(),
1064 'other'
1065 );
1066
1067 $expiryFormOptions->addOptions( $expiryOptions );
1068
1069 // Add expiry dropdown
1070 $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
1071
1072 // Add custom expiry field
1073 $expiryHtml .= Html::element( 'input', [
1074 'name' => "wpExpiry-$group-other", 'size' => 30, 'value' => '',
1075 'id' => "mw-input-wpExpiry-$group-other",
1076 'class' => 'mw-userrights-expiryfield',
1077 'disabled' => $checkbox['disabled-expiry'],
1078 ] );
1079
1080 // If the user group is set but the checkbox is disabled, mimic a
1081 // checked checkbox in the form submission
1082 if ( $checkbox['set'] && $checkbox['disabled'] ) {
1083 $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
1084 }
1085
1086 $expiryHtml .= Html::closeElement( 'span' );
1087 }
1088
1089 $divAttribs = [
1090 'id' => "mw-userrights-nested-wpGroup-$group",
1091 'class' => 'mw-userrights-nested',
1092 ];
1093 $checkboxHtml .= "\t\t\t" . Html::rawElement( 'div', $divAttribs, $expiryHtml ) . "\n";
1094 }
1095 $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
1096 ? Html::rawElement( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
1097 : Html::rawElement( 'div', [], $checkboxHtml )
1098 ) . "\n";
1099 }
1100 $ret .= "\t</td>\n";
1101 }
1102 $ret .= Html::closeElement( 'tr' ) . Html::closeElement( 'table' );
1103
1104 return [ $ret, (bool)$columns['changeable'] ];
1105 }
1106
1111 private function canRemove( $group ) {
1112 $groups = $this->changeableGroups();
1113
1114 return in_array(
1115 $group,
1116 $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] )
1117 );
1118 }
1119
1124 private function canAdd( $group ) {
1125 $groups = $this->changeableGroups();
1126
1127 return in_array(
1128 $group,
1129 $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] )
1130 );
1131 }
1132
1142 protected function changeableGroups() {
1143 return $this->userGroupManager->getGroupsChangeableBy( $this->getContext()->getAuthority() );
1144 }
1145
1154 private function getDisplayUsername( UserIdentity $user ) {
1155 $userName = $user->getName();
1156 if ( $user->getWikiId() !== UserIdentity::LOCAL ) {
1157 $userName .= $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter )
1158 . $user->getWikiId();
1159 }
1160 return $userName;
1161 }
1162
1169 protected function showLogFragment( $user, $output ) {
1170 $rightsLogPage = new LogPage( 'rights' );
1171 $output->addHTML( Html::element( 'h2', [], $rightsLogPage->getName()->text() ) );
1172 LogEventsList::showLogExtract( $output, 'rights',
1173 Title::makeTitle( NS_USER, $this->getDisplayUsername( $user ) ) );
1174 }
1175
1184 public function prefixSearchSubpages( $search, $limit, $offset ) {
1185 $search = $this->userNameUtils->getCanonical( $search );
1186 if ( !$search ) {
1187 // No prefix suggestion for invalid user
1188 return [];
1189 }
1190 // Autocomplete subpage as user list - public to allow caching
1191 return $this->userNamePrefixSearch
1192 ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
1193 }
1194
1195 protected function getGroupName() {
1196 return 'users';
1197 }
1198}
1199
1204class_alias( SpecialUserRights::class, 'UserrightsPage' );
const NS_USER
Definition Defines.php:67
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.
wfEscapeWikiText( $input)
Escapes the given text so that it may be output using addWikiText() without any linking,...
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.
Class to simplify the use of log pages.
Definition LogPage.php:46
Class for creating new log entries and inserting them into the database.
Handle database storage of comments such as edit summaries and log reasons.
Implements a text input field for user names.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition HTMLForm.php:209
This class is a collection of static functions that serve two purposes:
Definition Html.php:57
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing '/'...
Definition Html.php:242
static input( $name, $value='', $type='text', array $attribs=[])
Convenience function to produce an <input> element.
Definition Html.php:659
static closeElement( $element)
Returns "</$element>".
Definition Html.php:306
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition Html.php:218
Some internal bits split of from Skin.php.
Definition Linker.php:63
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.
Parent class for all special pages.
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.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
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 By default the message key is the canonical name of...
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.
doesWrites()
Indicates whether POST requests to this special page require write access to the wiki.
doSaveUserGroups( $user, array $add, array $remove, string $reason='', array $tags=[], array $groupExpiries=[])
Save user groups changes in the database.
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.
addLogEntry( $user, array $oldGroups, array $newGroups, string $reason, array $tags, array $oldUGMs, array $newUGMs)
Add a rights log entry for an action.
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()
Display a HTMLUserTextField form to allow searching for a named user only.
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.
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.
saveUserGroups(string $reason, UserIdentity $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.
showLogFragment( $user, $output)
Show a rights log fragment for the specified user.
__construct(?UserGroupManagerFactory $userGroupManagerFactory=null, ?UserNameUtils $userNameUtils=null, ?UserNamePrefixSearch $userNamePrefixSearch=null, ?UserFactory $userFactory=null, ?ActorStoreFactory $actorStoreFactory=null, ?WatchlistManager $watchlistManager=null, ?TempUserConfig $tempUserConfig=null)
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
Represents a title within MediaWiki.
Definition Title.php:78
Creates User objects.
Factory service for UserGroupManager instances.
getUserGroups(UserIdentity $user, int $queryFlags=IDBAccessObject::READ_NORMAL)
Get the list of explicit group memberships this user has.
addUserToGroup(UserIdentity $user, string $group, ?string $expiry=null, bool $allowUpdate=false)
Add the user to the given group.
removeUserFromGroup(UserIdentity $user, string $group)
Remove the user from the given group.
getUserGroupMemberships(UserIdentity $user, int $queryFlags=IDBAccessObject::READ_NORMAL)
Loads and returns UserGroupMembership objects for all the groups a user currently belongs to.
getGroupsChangeableBy(Authority $authority)
Returns an array of groups that this $actor can add and remove.
Represents a "user group membership" – a specific instance of a user belonging to a group.
Handles searching prefixes of user names.
UserNameUtils service.
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:31
Class for generating HTML <select> or <datalist> elements.
Definition XmlSelect.php:30
Module of static functions for generating XML.
Definition Xml.php:37
Show an error when a user tries to do something they do not have the necessary permissions for.
Show an error when the user tries to do something whilst blocked.
getWikiId()
Get the ID of the wiki this page belongs to.
Interface for temporary user creation config and name matching.
Interface for objects representing user identity.
isRegistered()
This must be equivalent to getId() != 0 and is provided for code readability.
getId( $wikiId=self::LOCAL)
Interface for database access objects.
element(SerializerNode $parent, SerializerNode $node, $contents)