MediaWiki master
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 $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
227 $this->addHelpLink( 'Help:Assigning permissions' );
228
229 $this->switchForm();
230
231 if (
232 $request->wasPosted() &&
233 $request->getCheck( 'saveusergroups' ) &&
234 $this->mTarget !== null &&
235 $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget )
236 ) {
237 /*
238 * If the user is blocked and they only have "partial" access
239 * (e.g. they don't have the userrights permission), then don't
240 * allow them to change any user rights.
241 */
242 if ( !$this->getAuthority()->isAllowed( 'userrights' ) ) {
243 $block = $user->getBlock();
244 if ( $block && $block->isSitewide() ) {
245 throw new UserBlockedError(
246 $block,
247 $user,
248 $this->getLanguage(),
249 $request->getIP()
250 );
251 }
252 }
253
254 $this->checkReadOnly();
255
256 // save settings
257 if ( !$fetchedStatus->isOK() ) {
258 $this->getOutput()->addWikiTextAsInterface(
259 $fetchedStatus->getWikiText( false, false, $this->getLanguage() )
260 );
261
262 return;
263 }
264
265 $targetUser = $this->mFetchedUser;
266 $conflictCheck = $request->getVal( 'conflictcheck-originalgroups' );
267 $conflictCheck = ( $conflictCheck === '' ) ? [] : explode( ',', $conflictCheck );
268 $userGroups = $this->userGroupManager->getUserGroups( $targetUser, IDBAccessObject::READ_LATEST );
269
270 if ( $userGroups !== $conflictCheck ) {
271 $out->addHTML( Html::errorBox(
272 $this->msg( 'userrights-conflict' )->parse()
273 ) );
274 } else {
275 $status = $this->saveUserGroups(
276 $request->getText( 'user-reason' ),
277 $targetUser
278 );
279
280 if ( $status->isOK() ) {
281 // Set session data for the success message
282 $session->set( 'specialUserrightsSaveSuccess', 1 );
283
284 $out->redirect( $this->getSuccessURL() );
285 return;
286 } else {
287 // Print an error message and redisplay the form
288 foreach ( $status->getMessages() as $msg ) {
289 $out->addHTML( Html::errorBox(
290 $this->msg( $msg )->parse()
291 ) );
292 }
293 }
294 }
295 }
296
297 // show some more forms
298 if ( $this->mTarget !== null ) {
299 $this->editUserGroupsForm( $this->mTarget );
300 }
301 }
302
303 private function getSuccessURL() {
304 return $this->getPageTitle( $this->mTarget )->getFullURL();
305 }
306
313 public function canProcessExpiries() {
314 return true;
315 }
316
326 public static function expiryToTimestamp( $expiry ) {
327 if ( wfIsInfinity( $expiry ) ) {
328 return null;
329 }
330
331 $unix = strtotime( $expiry );
332
333 if ( !$unix || $unix === -1 ) {
334 return false;
335 }
336
337 // @todo FIXME: Non-qualified absolute times are not in users specified timezone
338 // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
339 return wfTimestamp( TS_MW, $unix );
340 }
341
350 protected function saveUserGroups( string $reason, UserIdentity $user ) {
351 if ( $this->userNameUtils->isTemp( $user->getName() ) ) {
352 return Status::newFatal( 'userrights-no-tempuser' );
353 }
354
355 // Prevent cross-wiki assignment of groups to temporary accounts on wikis where the feature is not known.
356 if (
357 $user->getWikiId() !== UserIdentity::LOCAL &&
358 !$this->tempUserConfig->isKnown() &&
359 $this->tempUserConfig->isReservedName( $user->getName() )
360 ) {
361 return Status::newFatal( 'userrights-cross-wiki-assignment-for-reserved-name' );
362 }
363
364 $allgroups = $this->userGroupManager->listAllGroups();
365 $addgroup = [];
366 $groupExpiries = []; // associative array of (group name => expiry)
367 $removegroup = [];
368 $existingUGMs = $this->userGroupManager->getUserGroupMemberships( $user );
369
370 // This could possibly create a highly unlikely race condition if permissions are changed between
371 // when the form is loaded and when the form is saved. Ignoring it for the moment.
372 foreach ( $allgroups as $group ) {
373 // We'll tell it to remove all unchecked groups, and add all checked groups.
374 // Later on, this gets filtered for what can actually be removed
375 if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
376 $addgroup[] = $group;
377
378 if ( $this->canProcessExpiries() ) {
379 // read the expiry information from the request
380 $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
381 if ( $expiryDropdown === 'existing' ) {
382 continue;
383 }
384
385 if ( $expiryDropdown === 'other' ) {
386 $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
387 } else {
388 $expiryValue = $expiryDropdown;
389 }
390
391 // validate the expiry
392 $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue );
393
394 if ( $groupExpiries[$group] === false ) {
395 return Status::newFatal( 'userrights-invalid-expiry', $group );
396 }
397
398 // not allowed to have things expiring in the past
399 if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
400 return Status::newFatal( 'userrights-expiry-in-past', $group );
401 }
402
403 // if the user can only add this group (not remove it), the expiry time
404 // cannot be brought forward (T156784)
405 if ( !$this->canRemove( $group ) &&
406 isset( $existingUGMs[$group] ) &&
407 ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) >
408 ( $groupExpiries[$group] ?: 'infinity' )
409 ) {
410 return Status::newFatal( 'userrights-cannot-shorten-expiry', $group );
411 }
412 }
413 } else {
414 $removegroup[] = $group;
415 }
416 }
417
418 $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
419
420 if ( $user->getWikiId() === UserIdentity::LOCAL && $this->getRequest()->getCheck( 'wpWatch' ) ) {
421 $this->watchlistManager->addWatchIgnoringRights(
422 $this->getUser(),
423 Title::makeTitle( NS_USER, $user->getName() )
424 );
425 }
426
427 return Status::newGood();
428 }
429
445 public function doSaveUserGroups( $user, array $add, array $remove, string $reason = '',
446 array $tags = [], array $groupExpiries = []
447 ) {
448 // Validate input set...
449 $isself = $user->getName() == $this->getUser()->getName();
450 if ( $this->userGroupManager !== null ) {
451 // Used after form submit
452 $userGroupManager = $this->userGroupManager;
453 } else {
454 // Used as backend-function
455 $userGroupManager = $this->userGroupManagerFactory
456 ->getUserGroupManager( $user->getWikiId() );
457 }
458 $groups = $userGroupManager->getUserGroups( $user );
459 $ugms = $userGroupManager->getUserGroupMemberships( $user );
460 $changeable = $userGroupManager->getGroupsChangeableBy( $this->getAuthority() );
461 $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
462 $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );
463
464 $remove = array_unique( array_intersect( $remove, $removable, $groups ) );
465 $add = array_intersect( $add, $addable );
466
467 // add only groups that are not already present or that need their expiry updated,
468 // UNLESS the user can only add this group (not remove it) and the expiry time
469 // is being brought forward (T156784)
470 $add = array_filter( $add,
471 static function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
472 if ( isset( $groupExpiries[$group] ) &&
473 !in_array( $group, $removable ) &&
474 isset( $ugms[$group] ) &&
475 ( $ugms[$group]->getExpiry() ?: 'infinity' ) >
476 ( $groupExpiries[$group] ?: 'infinity' )
477 ) {
478 return false;
479 }
480 return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
481 } );
482
483 if ( $user->getWikiId() === UserIdentity::LOCAL ) {
484 // For compatibility local changes are provided as User object to the hook
485 $hookUser = $this->userFactory->newFromUserIdentity( $user );
486 } else {
487 // Interwiki changes are provided as UserIdentity since 1.41, was UserRightsProxy before
488 $hookUser = $user;
489 }
490 $this->getHookRunner()->onChangeUserGroups( $this->getUser(), $hookUser, $add, $remove );
491
492 $oldGroups = $groups;
493 $oldUGMs = $userGroupManager->getUserGroupMemberships( $user );
494 $newGroups = $oldGroups;
495
496 // Remove groups, then add new ones/update expiries of existing ones
497 if ( $remove ) {
498 foreach ( $remove as $index => $group ) {
499 if ( !$userGroupManager->removeUserFromGroup( $user, $group ) ) {
500 unset( $remove[$index] );
501 }
502 }
503 $newGroups = array_diff( $newGroups, $remove );
504 }
505 if ( $add ) {
506 foreach ( $add as $index => $group ) {
507 $expiry = $groupExpiries[$group] ?? null;
508 if ( !$userGroupManager->addUserToGroup( $user, $group, $expiry, true ) ) {
509 unset( $add[$index] );
510 }
511 }
512 $newGroups = array_merge( $newGroups, $add );
513 }
514 $newGroups = array_unique( $newGroups );
515 $newUGMs = $userGroupManager->getUserGroupMemberships( $user );
516
517 // Ensure that caches are cleared
518 $this->userFactory->invalidateCache( $user );
519
520 // update groups in external authentication database
521 $this->getHookRunner()->onUserGroupsChanged( $hookUser, $add, $remove,
522 $this->getUser(), $reason, $oldUGMs, $newUGMs );
523
524 wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) );
525 wfDebug( 'newGroups: ' . print_r( $newGroups, true ) );
526 wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) );
527 wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) );
528
529 // Only add a log entry if something actually changed
530 if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
531 $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
532 }
533
534 return [ $add, $remove ];
535 }
536
544 protected static function serialiseUgmForLog( $ugm ) {
545 if ( !$ugm instanceof UserGroupMembership ) {
546 return null;
547 }
548 return [ 'expiry' => $ugm->getExpiry() ];
549 }
550
561 protected function addLogEntry( $user, array $oldGroups, array $newGroups, string $reason,
562 array $tags, array $oldUGMs, array $newUGMs
563 ) {
564 // make sure $oldUGMs and $newUGMs are in the same order, and serialise
565 // each UGM object to a simplified array
566 $oldUGMs = array_map( static function ( $group ) use ( $oldUGMs ) {
567 return isset( $oldUGMs[$group] ) ?
568 self::serialiseUgmForLog( $oldUGMs[$group] ) :
569 null;
570 }, $oldGroups );
571 $newUGMs = array_map( static function ( $group ) use ( $newUGMs ) {
572 return isset( $newUGMs[$group] ) ?
573 self::serialiseUgmForLog( $newUGMs[$group] ) :
574 null;
575 }, $newGroups );
576
577 $logEntry = new ManualLogEntry( 'rights', 'rights' );
578 $logEntry->setPerformer( $this->getUser() );
579 $logEntry->setTarget( Title::makeTitle( NS_USER, $this->getDisplayUsername( $user ) ) );
580 $logEntry->setComment( $reason );
581 $logEntry->setParameters( [
582 '4::oldgroups' => $oldGroups,
583 '5::newgroups' => $newGroups,
584 'oldmetadata' => $oldUGMs,
585 'newmetadata' => $newUGMs,
586 ] );
587 $logid = $logEntry->insert();
588 if ( count( $tags ) ) {
589 $logEntry->addTags( $tags );
590 }
591 $logEntry->publish( $logid );
592 }
593
598 private function editUserGroupsForm( $username ) {
599 $status = $this->fetchUser( $username, true );
600 if ( !$status->isOK() ) {
601 $this->getOutput()->addWikiTextAsInterface(
602 $status->getWikiText( false, false, $this->getLanguage() )
603 );
604
605 return;
606 }
607
609 $user = $status->value;
610 '@phan-var UserIdentity $user';
611
612 $groups = $this->userGroupManager->getUserGroups( $user );
613 $groupMemberships = $this->userGroupManager->getUserGroupMemberships( $user );
614 $this->showEditUserGroupsForm( $user, $groups, $groupMemberships );
615
616 // This isn't really ideal logging behavior, but let's not hide the
617 // interwiki logs if we're using them as is.
618 $this->showLogFragment( $user, $this->getOutput() );
619 }
620
630 public function fetchUser( $username, $writing = true ) {
631 $parts = explode( $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter ),
632 $username );
633 if ( count( $parts ) < 2 ) {
634 $name = trim( $username );
635 $wikiId = UserIdentity::LOCAL;
636 } else {
637 [ $name, $wikiId ] = array_map( 'trim', $parts );
638
639 if ( WikiMap::isCurrentWikiId( $wikiId ) ) {
640 $wikiId = UserIdentity::LOCAL;
641 } else {
642 if ( $writing &&
643 !$this->getAuthority()->isAllowed( 'userrights-interwiki' )
644 ) {
645 return Status::newFatal( 'userrights-no-interwiki' );
646 }
647 $localDatabases = $this->getConfig()->get( MainConfigNames::LocalDatabases );
648 if ( !in_array( $wikiId, $localDatabases ) ) {
649 return Status::newFatal( 'userrights-nodatabase', $wikiId );
650 }
651 }
652 }
653
654 if ( $name === '' ) {
655 return Status::newFatal( 'nouserspecified' );
656 }
657
658 $userIdentityLookup = $this->actorStoreFactory->getUserIdentityLookup( $wikiId );
659 if ( $name[0] == '#' ) {
660 // Numeric ID can be specified...
661 $id = intval( substr( $name, 1 ) );
662
663 $user = $userIdentityLookup->getUserIdentityByUserId( $id );
664 if ( !$user ) {
665 // Different error message for compatibility
666 return Status::newFatal( 'noname' );
667 }
668 $name = $user->getName();
669 } else {
670 $name = $this->userNameUtils->getCanonical( $name );
671 if ( $name === false ) {
672 // invalid name
673 return Status::newFatal( 'nosuchusershort', $username );
674 }
675 $user = $userIdentityLookup->getUserIdentityByName( $name );
676 }
677
678 if ( $this->userNameUtils->isTemp( $name ) ) {
679 return Status::newFatal( 'userrights-no-group' );
680 }
681
682 if ( !$user || !$user->isRegistered() ) {
683 return Status::newFatal( 'nosuchusershort', $username );
684 }
685
686 // Prevent cross-wiki assignment of groups to temporary accounts on wikis where the feature is not known.
687 // We have to check this here, as ApiUserrights uses this to validate before assigning user rights.
688 if (
689 $user->getWikiId() !== UserIdentity::LOCAL &&
690 !$this->tempUserConfig->isKnown() &&
691 $this->tempUserConfig->isReservedName( $name )
692 ) {
693 return Status::newFatal( 'userrights-no-group' );
694 }
695
696 if ( $user->getWikiId() === UserIdentity::LOCAL &&
697 $this->userFactory->newFromUserIdentity( $user )->isHidden() &&
698 !$this->getAuthority()->isAllowed( 'hideuser' )
699 ) {
700 // Cannot see hidden users, pretend they don't exist
701 return Status::newFatal( 'nosuchusershort', $username );
702 }
703
704 return Status::newGood( $user );
705 }
706
714 public function makeGroupNameList( $ids ) {
715 if ( !$ids ) {
716 return $this->msg( 'rightsnone' )->inContentLanguage()->text();
717 } else {
718 return implode( ', ', $ids );
719 }
720 }
721
725 protected function switchForm() {
726 $formDescriptor = [
727 'user' => [
728 'class' => HTMLUserTextField::class,
729 'label-message' => 'userrights-user-editname',
730 'name' => 'user',
731 'ipallowed' => true,
732 'iprange' => true,
733 'excludetemp' => true, // Do not show temp users: T341684
734 'autofocus' => $this->mFetchedUser === null,
735 'default' => $this->mTarget,
736 ]
737 ];
738
739 $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
740 $htmlForm
741 ->setMethod( 'GET' )
742 ->setAction( wfScript() )
743 ->setName( 'uluser' )
744 ->setTitle( SpecialPage::getTitleFor( 'Userrights' ) )
745 ->setWrapperLegendMsg( 'userrights-lookup-user' )
746 ->setId( 'mw-userrights-form1' )
747 ->setSubmitTextMsg( 'editusergroup' )
748 ->prepareForm()
749 ->displayForm( true );
750 }
751
761 protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) {
762 $list = $membersList = $tempList = $tempMembersList = [];
763 foreach ( $groupMemberships as $ugm ) {
764 $linkG = UserGroupMembership::getLinkHTML( $ugm, $this->getContext() );
765 $linkM = UserGroupMembership::getLinkHTML( $ugm, $this->getContext(), $user->getName() );
766 if ( $ugm->getExpiry() ) {
767 $tempList[] = $linkG;
768 $tempMembersList[] = $linkM;
769 } else {
770 $list[] = $linkG;
771 $membersList[] = $linkM;
772
773 }
774 }
775
776 $autoList = [];
777 $autoMembersList = [];
778
779 if ( $user->getWikiId() === UserIdentity::LOCAL ) {
780 // Listing autopromote groups works only on the local wiki
781 foreach ( $this->userGroupManager->getUserAutopromoteGroups( $user ) as $group ) {
782 $autoList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
783 $autoMembersList[] = UserGroupMembership::getLinkHTML( $group, $this->getContext(), $user->getName() );
784 }
785 }
786
787 $language = $this->getLanguage();
788 $displayedList = $this->msg( 'userrights-groupsmember-type' )
789 ->rawParams(
790 $language->commaList( array_merge( $tempList, $list ) ),
791 $language->commaList( array_merge( $tempMembersList, $membersList ) )
792 )->escaped();
793 $displayedAutolist = $this->msg( 'userrights-groupsmember-type' )
794 ->rawParams(
795 $language->commaList( $autoList ),
796 $language->commaList( $autoMembersList )
797 )->escaped();
798
799 $grouplist = '';
800 $count = count( $list ) + count( $tempList );
801 if ( $count > 0 ) {
802 $grouplist = $this->msg( 'userrights-groupsmember' )
803 ->numParams( $count )
804 ->params( $user->getName() )
805 ->parse();
806 $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n";
807 }
808
809 $count = count( $autoList );
810 if ( $count > 0 ) {
811 $autogrouplistintro = $this->msg( 'userrights-groupsmember-auto' )
812 ->numParams( $count )
813 ->params( $user->getName() )
814 ->parse();
815 $grouplist .= '<p>' . $autogrouplistintro . ' ' . $displayedAutolist . "</p>\n";
816 }
817
818 $systemUser = $user->getWikiId() === UserIdentity::LOCAL
819 && $this->userFactory->newFromUserIdentity( $user )->isSystemUser();
820 if ( $systemUser ) {
821 $systemusernote = $this->msg( 'userrights-systemuser' )
822 ->params( $user->getName() )
823 ->parse();
824 $grouplist .= '<p>' . $systemusernote . "</p>\n";
825 }
826
827 // Only add an email link if the user is not a system user
828 $flags = $systemUser ? 0 : Linker::TOOL_LINKS_EMAIL;
829 $userToolLinks = Linker::userToolLinks(
830 $user->getId( $user->getWikiId() ),
831 $this->getDisplayUsername( $user ),
832 false, /* default for redContribsWhenNoEdits */
833 $flags
834 );
835
836 [ $groupCheckboxes, $canChangeAny ] =
837 $this->groupCheckboxes( $groupMemberships, $user );
838 $this->getOutput()->addHTML(
839 Html::openElement(
840 'form',
841 [
842 'method' => 'post',
843 'action' => $this->getPageTitle()->getLocalURL(),
844 'name' => 'editGroup',
845 'id' => 'mw-userrights-form2'
846 ]
847 ) .
848 Html::hidden( 'user', $this->mTarget ) .
849 Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) .
850 Html::hidden(
851 'conflictcheck-originalgroups',
852 implode( ',', $this->userGroupManager->getUserGroups( $user ) )
853 ) . // Conflict detection
854 Html::openElement( 'fieldset' ) .
856 'legend',
857 [],
858 $this->msg(
859 $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup',
860 $user->getName()
861 )->text()
862 ) .
863 $this->msg(
864 $canChangeAny ? 'editinguser' : 'viewinguserrights'
865 )->params( wfEscapeWikiText( $this->getDisplayUsername( $user ) ) )
866 ->rawParams( $userToolLinks )->parse()
867 );
868 if ( $canChangeAny ) {
869 $this->getOutput()->addHTML(
870 $this->msg( 'userrights-groups-help', $user->getName() )->parse() .
871 $grouplist .
872 $groupCheckboxes .
873 Html::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
874 "<tr>
875 <td class='mw-label'>" .
876 Html::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) .
877 "</td>
878 <td class='mw-input'>" .
879 Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason' ) ?? false, [
880 'id' => 'wpReason',
881 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
882 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
883 // Unicode codepoints.
884 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
885 ] ) .
886 "</td>
887 </tr>
888 <tr>
889 <td></td>
890 <td class='mw-submit'>" .
891 Html::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(),
892 [ 'name' => 'saveusergroups' ] +
893 Linker::tooltipAndAccesskeyAttribs( 'userrights-set' )
894 ) .
895 "</td>
896 </tr>
897 <tr>
898 <td></td>
899 <td class='mw-input'>" .
900 Html::check( 'wpWatch', false, [ 'id' => 'wpWatch' ] ) .
901 '&nbsp;' . Html::label( $this->msg( 'userrights-watchuser' )->text(), 'wpWatch' ) .
902 "</td>
903 </tr>" .
904 Xml::closeElement( 'table' ) . "\n"
905 );
906 } else {
907 $this->getOutput()->addHTML( $grouplist );
908 }
909 $this->getOutput()->addHTML(
910 Xml::closeElement( 'fieldset' ) .
911 Xml::closeElement( 'form' ) . "\n"
912 );
913 }
914
924 private function groupCheckboxes( $usergroups, UserIdentity $user ) {
925 $allgroups = $this->userGroupManager->listAllGroups();
926 $ret = '';
927
928 // Get the list of preset expiry times from the system message
929 $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
930 $expiryOptions = $expiryOptionsMsg->isDisabled()
931 ? []
932 : XmlSelect::parseOptionsMessage( $expiryOptionsMsg->text() );
933
934 // Put all column info into an associative array so that extensions can
935 // more easily manage it.
936 $columns = [ 'unchangeable' => [], 'changeable' => [] ];
937
938 foreach ( $allgroups as $group ) {
939 $set = isset( $usergroups[$group] );
940 // Users who can add the group, but not remove it, can only lengthen
941 // expiries, not shorten them. So they should only see the expiry
942 // dropdown if the group currently has a finite expiry
943 $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
944 !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
945 // Should the checkbox be disabled?
946 $disabledCheckbox = !(
947 ( $set && $this->canRemove( $group ) ) ||
948 ( !$set && $this->canAdd( $group ) ) );
949 // Should the expiry elements be disabled?
950 $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
951 // Do we need to point out that this action is irreversible?
952 $irreversible = !$disabledCheckbox && (
953 ( $set && !$this->canAdd( $group ) ) ||
954 ( !$set && !$this->canRemove( $group ) ) );
955
956 $checkbox = [
957 'set' => $set,
958 'disabled' => $disabledCheckbox,
959 'disabled-expiry' => $disabledExpiry,
960 'irreversible' => $irreversible
961 ];
962
963 if ( $disabledCheckbox && $disabledExpiry ) {
964 $columns['unchangeable'][$group] = $checkbox;
965 } else {
966 $columns['changeable'][$group] = $checkbox;
967 }
968 }
969
970 // Build the HTML table
971 $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
972 "<tr>\n";
973 foreach ( $columns as $name => $column ) {
974 if ( $column === [] ) {
975 continue;
976 }
977 // Messages: userrights-changeable-col, userrights-unchangeable-col
978 $ret .= Xml::element(
979 'th',
980 null,
981 $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text()
982 );
983 }
984
985 $ret .= "</tr>\n<tr>\n";
986 $uiLanguage = $this->getLanguage();
987 $userName = $user->getName();
988 foreach ( $columns as $column ) {
989 if ( $column === [] ) {
990 continue;
991 }
992 $ret .= "\t<td style='vertical-align:top;'>\n";
993 foreach ( $column as $group => $checkbox ) {
994 $member = $uiLanguage->getGroupMemberName( $group, $userName );
995 if ( $checkbox['irreversible'] ) {
996 $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
997 } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
998 $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
999 } else {
1000 $text = $member;
1001 }
1002 $checkboxHtml = Html::element( 'input', [
1003 'type' => 'checkbox', 'name' => "wpGroup-$group", 'value' => '1',
1004 'id' => "wpGroup-$group", 'checked' => $checkbox['set'],
1005 'class' => 'mw-userrights-groupcheckbox',
1006 'disabled' => $checkbox['disabled'],
1007 ] ) . '&nbsp;' . Html::label( $text, "wpGroup-$group" );
1008
1009 if ( $this->canProcessExpiries() ) {
1010 $uiUser = $this->getUser();
1011
1012 $currentExpiry = isset( $usergroups[$group] ) ?
1013 $usergroups[$group]->getExpiry() :
1014 null;
1015
1016 // If the user can't modify the expiry, print the current expiry below
1017 // it in plain text. Otherwise provide UI to set/change the expiry
1018 if ( $checkbox['set'] &&
1019 ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] )
1020 ) {
1021 if ( $currentExpiry ) {
1022 $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
1023 $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
1024 $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser );
1025 $expiryHtml = Xml::element( 'span', null,
1026 $this->msg( 'userrights-expiry-current' )->params(
1027 $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text() );
1028 } else {
1029 $expiryHtml = Xml::element( 'span', null,
1030 $this->msg( 'userrights-expiry-none' )->text() );
1031 }
1032 // T171345: Add a hidden form element so that other groups can still be manipulated,
1033 // otherwise saving errors out with an invalid expiry time for this group.
1034 $expiryHtml .= Html::hidden( "wpExpiry-$group",
1035 $currentExpiry ? 'existing' : 'infinite' );
1036 $expiryHtml .= "<br />\n";
1037 } else {
1038 $expiryHtml = Html::element( 'span', [],
1039 $this->msg( 'userrights-expiry' )->text() );
1040 $expiryHtml .= Html::openElement( 'span' );
1041
1042 // add a form element to set the expiry date
1043 $expiryFormOptions = new XmlSelect(
1044 "wpExpiry-$group",
1045 "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
1046 $currentExpiry ? 'existing' : 'infinite'
1047 );
1048 if ( $checkbox['disabled-expiry'] ) {
1049 $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
1050 }
1051
1052 if ( $currentExpiry ) {
1053 $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
1054 $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
1055 $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
1056 $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
1057 $timestamp, $d, $t );
1058 $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
1059 }
1060
1061 $expiryFormOptions->addOption(
1062 $this->msg( 'userrights-expiry-none' )->text(),
1063 'infinite'
1064 );
1065 $expiryFormOptions->addOption(
1066 $this->msg( 'userrights-expiry-othertime' )->text(),
1067 'other'
1068 );
1069
1070 $expiryFormOptions->addOptions( $expiryOptions );
1071
1072 // Add expiry dropdown
1073 $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
1074
1075 // Add custom expiry field
1076 $expiryHtml .= Html::element( 'input', [
1077 'name' => "wpExpiry-$group-other", 'size' => 30, 'value' => '',
1078 'id' => "mw-input-wpExpiry-$group-other",
1079 'class' => 'mw-userrights-expiryfield',
1080 'disabled' => $checkbox['disabled-expiry'],
1081 ] );
1082
1083 // If the user group is set but the checkbox is disabled, mimic a
1084 // checked checkbox in the form submission
1085 if ( $checkbox['set'] && $checkbox['disabled'] ) {
1086 $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
1087 }
1088
1089 $expiryHtml .= Html::closeElement( 'span' );
1090 }
1091
1092 $divAttribs = [
1093 'id' => "mw-userrights-nested-wpGroup-$group",
1094 'class' => 'mw-userrights-nested',
1095 ];
1096 $checkboxHtml .= "\t\t\t" . Html::rawElement( 'div', $divAttribs, $expiryHtml ) . "\n";
1097 }
1098 $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
1099 ? Html::rawElement( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
1100 : Html::rawElement( 'div', [], $checkboxHtml )
1101 ) . "\n";
1102 }
1103 $ret .= "\t</td>\n";
1104 }
1105 $ret .= Html::closeElement( 'tr' ) . Html::closeElement( 'table' );
1106
1107 return [ $ret, (bool)$columns['changeable'] ];
1108 }
1109
1114 private function canRemove( $group ) {
1115 $groups = $this->changeableGroups();
1116
1117 return in_array(
1118 $group,
1119 $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] )
1120 );
1121 }
1122
1127 private function canAdd( $group ) {
1128 $groups = $this->changeableGroups();
1129
1130 return in_array(
1131 $group,
1132 $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] )
1133 );
1134 }
1135
1145 protected function changeableGroups() {
1146 return $this->userGroupManager->getGroupsChangeableBy( $this->getContext()->getAuthority() );
1147 }
1148
1157 private function getDisplayUsername( UserIdentity $user ) {
1158 $userName = $user->getName();
1159 if ( $user->getWikiId() !== UserIdentity::LOCAL ) {
1160 $userName .= $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter )
1161 . $user->getWikiId();
1162 }
1163 return $userName;
1164 }
1165
1172 protected function showLogFragment( $user, $output ) {
1173 $rightsLogPage = new LogPage( 'rights' );
1174 $output->addHTML( Html::element( 'h2', [], $rightsLogPage->getName()->text() ) );
1175 LogEventsList::showLogExtract( $output, 'rights',
1176 Title::makeTitle( NS_USER, $this->getDisplayUsername( $user ) ) );
1177 }
1178
1187 public function prefixSearchSubpages( $search, $limit, $offset ) {
1188 $search = $this->userNameUtils->getCanonical( $search );
1189 if ( !$search ) {
1190 // No prefix suggestion for invalid user
1191 return [];
1192 }
1193 // Autocomplete subpage as user list - public to allow caching
1194 return $this->userNamePrefixSearch
1195 ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
1196 }
1197
1198 protected function getGroupName() {
1199 return 'users';
1200 }
1201}
1202
1207class_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:56
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing '/'...
Definition Html.php:240
static input( $name, $value='', $type='text', array $attribs=[])
Convenience function to produce an <input> element.
Definition Html.php:657
static closeElement( $element)
Returns "</$element>".
Definition Html.php:304
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition Html.php:216
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)