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