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 !== null ? 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 Html::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 Html::openElement( 'fieldset' ) .
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 Html::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) .
851 "<tr>
852 <td class='mw-label'>" .
853 Html::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 Html::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 Html::check( 'wpWatch', false, [ 'id' => 'wpWatch' ] ) .
878 '&nbsp;' . Html::label( $this->msg( 'userrights-watchuser' )->text(), 'wpWatch' ) .
879 "</td>
880 </tr>" .
881 Xml::closeElement( 'table' ) . "\n"
882 );
883 } else {
884 $this->getOutput()->addHTML( $grouplist );
885 }
886 $this->getOutput()->addHTML(
887 Xml::closeElement( 'fieldset' ) .
888 Xml::closeElement( 'form' ) . "\n"
889 );
890 }
891
901 private function groupCheckboxes( $usergroups, $user ) {
902 $allgroups = $this->userGroupManager->listAllGroups();
903 $ret = '';
904
905 // Get the list of preset expiry times from the system message
906 $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
907 $expiryOptions = $expiryOptionsMsg->isDisabled()
908 ? []
909 : XmlSelect::parseOptionsMessage( $expiryOptionsMsg->text() );
910
911 // Put all column info into an associative array so that extensions can
912 // more easily manage it.
913 $columns = [ 'unchangeable' => [], 'changeable' => [] ];
914
915 foreach ( $allgroups as $group ) {
916 $set = isset( $usergroups[$group] );
917 // Users who can add the group, but not remove it, can only lengthen
918 // expiries, not shorten them. So they should only see the expiry
919 // dropdown if the group currently has a finite expiry
920 $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
921 !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
922 // Should the checkbox be disabled?
923 $disabledCheckbox = !(
924 ( $set && $this->canRemove( $group ) ) ||
925 ( !$set && $this->canAdd( $group ) ) );
926 // Should the expiry elements be disabled?
927 $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
928 // Do we need to point out that this action is irreversible?
929 $irreversible = !$disabledCheckbox && (
930 ( $set && !$this->canAdd( $group ) ) ||
931 ( !$set && !$this->canRemove( $group ) ) );
932
933 $checkbox = [
934 'set' => $set,
935 'disabled' => $disabledCheckbox,
936 'disabled-expiry' => $disabledExpiry,
937 'irreversible' => $irreversible
938 ];
939
940 if ( $disabledCheckbox && $disabledExpiry ) {
941 $columns['unchangeable'][$group] = $checkbox;
942 } else {
943 $columns['changeable'][$group] = $checkbox;
944 }
945 }
946
947 // Build the HTML table
948 $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) .
949 "<tr>\n";
950 foreach ( $columns as $name => $column ) {
951 if ( $column === [] ) {
952 continue;
953 }
954 // Messages: userrights-changeable-col, userrights-unchangeable-col
955 $ret .= Xml::element(
956 'th',
957 null,
958 $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text()
959 );
960 }
961
962 $ret .= "</tr>\n<tr>\n";
963 $uiLanguage = $this->getLanguage();
964 $userName = $user->getName();
965 foreach ( $columns as $column ) {
966 if ( $column === [] ) {
967 continue;
968 }
969 $ret .= "\t<td style='vertical-align:top;'>\n";
970 foreach ( $column as $group => $checkbox ) {
971 $member = $uiLanguage->getGroupMemberName( $group, $userName );
972 if ( $checkbox['irreversible'] ) {
973 $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
974 } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
975 $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
976 } else {
977 $text = $member;
978 }
979 $checkboxHtml = Html::element( 'input', [
980 'type' => 'checkbox', 'name' => "wpGroup-$group", 'value' => '1',
981 'id' => "wpGroup-$group", 'checked' => $checkbox['set'],
982 'class' => 'mw-userrights-groupcheckbox',
983 'disabled' => $checkbox['disabled'],
984 ] ) . '&nbsp;' . Html::label( $text, "wpGroup-$group" );
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 = Html::element( 'span', [],
1016 $this->msg( 'userrights-expiry' )->text() );
1017 $expiryHtml .= Html::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 $expiryHtml .= Html::element( 'input', [
1054 'name' => "wpExpiry-$group-other", 'size' => 30, 'value' => '',
1055 'id' => "mw-input-wpExpiry-$group-other",
1056 'class' => 'mw-userrights-expiryfield',
1057 'disabled' => $checkbox['disabled-expiry'],
1058 ] );
1059
1060 // If the user group is set but the checkbox is disabled, mimic a
1061 // checked checkbox in the form submission
1062 if ( $checkbox['set'] && $checkbox['disabled'] ) {
1063 $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
1064 }
1065
1066 $expiryHtml .= Html::closeElement( 'span' );
1067 }
1068
1069 $divAttribs = [
1070 'id' => "mw-userrights-nested-wpGroup-$group",
1071 'class' => 'mw-userrights-nested',
1072 ];
1073 $checkboxHtml .= "\t\t\t" . Html::rawElement( 'div', $divAttribs, $expiryHtml ) . "\n";
1074 }
1075 $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
1076 ? Html::rawElement( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
1077 : Html::rawElement( 'div', [], $checkboxHtml )
1078 ) . "\n";
1079 }
1080 $ret .= "\t</td>\n";
1081 }
1082 $ret .= Html::closeElement( 'tr' ) . Html::closeElement( 'table' );
1083
1084 return [ $ret, (bool)$columns['changeable'] ];
1085 }
1086
1091 private function canRemove( $group ) {
1092 $groups = $this->changeableGroups();
1093
1094 return in_array(
1095 $group,
1096 $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] )
1097 );
1098 }
1099
1104 private function canAdd( $group ) {
1105 $groups = $this->changeableGroups();
1106
1107 return in_array(
1108 $group,
1109 $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] )
1110 );
1111 }
1112
1122 protected function changeableGroups() {
1123 return $this->userGroupManager->getGroupsChangeableBy( $this->getContext()->getAuthority() );
1124 }
1125
1134 private function getDisplayUsername( UserIdentity $user ) {
1135 $userName = $user->getName();
1136 if ( $user->getWikiId() !== UserIdentity::LOCAL ) {
1137 $userName .= $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter )
1138 . $user->getWikiId();
1139 }
1140 return $userName;
1141 }
1142
1149 protected function showLogFragment( $user, $output ) {
1150 $rightsLogPage = new LogPage( 'rights' );
1151 $output->addHTML( Html::element( 'h2', [], $rightsLogPage->getName()->text() ) );
1152 LogEventsList::showLogExtract( $output, 'rights',
1153 Title::makeTitle( NS_USER, $this->getDisplayUsername( $user ) ) );
1154 }
1155
1164 public function prefixSearchSubpages( $search, $limit, $offset ) {
1165 $search = $this->userNameUtils->getCanonical( $search );
1166 if ( !$search ) {
1167 // No prefix suggestion for invalid user
1168 return [];
1169 }
1170 // Autocomplete subpage as user list - public to allow caching
1171 return $this->userNamePrefixSearch
1172 ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
1173 }
1174
1175 protected function getGroupName() {
1176 return 'users';
1177 }
1178}
1179
1184class_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 openElement( $element, $attribs=null)
This opens an XML element.
Definition Xml.php:115
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...