Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.73% covered (warning)
87.73%
143 / 163
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialUserRights
88.27% covered (warning)
88.27%
143 / 162
63.64% covered (warning)
63.64%
7 / 11
40.33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
87.04% covered (warning)
87.04%
47 / 54
0.00% covered (danger)
0.00%
0 / 1
14.43
 initialize
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 getSuccessURL
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveUserGroups
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
6.64
 formatInvalidGroupsStatus
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 switchForm
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 getTargetUserToolLinks
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 buildFormExtraInfo
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 categorizeUserGroupsForDisplay
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\Exception\PermissionsError;
10use MediaWiki\Exception\UserBlockedError;
11use MediaWiki\Html\Html;
12use MediaWiki\HTMLForm\Field\HTMLUserTextField;
13use MediaWiki\HTMLForm\HTMLForm;
14use MediaWiki\Language\FormatterFactory;
15use MediaWiki\Linker\Linker;
16use MediaWiki\SpecialPage\SpecialPage;
17use MediaWiki\SpecialPage\UserGroupsSpecialPage;
18use MediaWiki\Status\Status;
19use MediaWiki\Title\Title;
20use MediaWiki\User\MultiFormatUserIdentityLookup;
21use MediaWiki\User\UserFactory;
22use MediaWiki\User\UserGroupAssignmentService;
23use MediaWiki\User\UserGroupManager;
24use MediaWiki\User\UserGroupManagerFactory;
25use MediaWiki\User\UserIdentity;
26use MediaWiki\User\UserNamePrefixSearch;
27use MediaWiki\User\UserNameUtils;
28use MediaWiki\Watchlist\WatchlistManager;
29use Wikimedia\Rdbms\IDBAccessObject;
30
31/**
32 * Special page to allow managing user group membership
33 *
34 * @ingroup SpecialPage
35 */
36class SpecialUserRights extends UserGroupsSpecialPage {
37    /**
38     * @var UserIdentity The user object of the target username.
39     */
40    protected UserIdentity $targetUser;
41
42    /** @var UserGroupManager The UserGroupManager of the target username */
43    private UserGroupManager $userGroupManager;
44
45    /** @var list<string> Names of the groups the current target is automatically in */
46    private array $autopromoteGroups = [];
47
48    public function __construct(
49        private readonly UserGroupManagerFactory $userGroupManagerFactory,
50        private readonly UserNameUtils $userNameUtils,
51        private readonly UserNamePrefixSearch $userNamePrefixSearch,
52        private readonly UserFactory $userFactory,
53        private readonly WatchlistManager $watchlistManager,
54        private readonly UserGroupAssignmentService $userGroupAssignmentService,
55        private readonly MultiFormatUserIdentityLookup $multiFormatUserIdentityLookup,
56        private readonly FormatterFactory $formatterFactory,
57    ) {
58        parent::__construct( 'Userrights' );
59    }
60
61    /**
62     * Manage forms to be shown according to posted data.
63     * Depending on the submit button used, call a form or a save function.
64     *
65     * @param string|null $subPage String if any subpage provided, else null
66     * @throws UserBlockedError|PermissionsError
67     */
68    public function execute( $subPage ) {
69        $user = $this->getUser();
70        $request = $this->getRequest();
71        $out = $this->getOutput();
72
73        $this->setHeaders();
74        $this->outputHeader();
75        $this->addModules();
76        $this->addHelpLink( 'Help:Assigning permissions' );
77
78        $targetName = $subPage ?? $request->getText( 'user' );
79        $this->switchForm( $targetName );
80
81        // If the user just viewed this page, without trying to submit, return early
82        // It prevents from showing "nouserspecified" error message on first view
83        if ( $subPage === null && !$request->getCheck( 'user' ) ) {
84            return;
85        }
86
87        // No need to check if $target is non-empty or non-canonical, this is done in the lookup service
88        $fetchedStatus = $this->multiFormatUserIdentityLookup->getUserIdentity( $targetName, $this->getAuthority() );
89        if ( !$fetchedStatus->isOK() ) {
90            $out->addHTML( Html::warningBox(
91                $this->formatterFactory->getStatusFormatter( $this->getContext() )
92                    ->getMessage( $fetchedStatus )->parse()
93            ) );
94            return;
95        }
96
97        $fetchedUser = $fetchedStatus->value;
98        // Phan false positive on Status object - T323205
99        '@phan-var UserIdentity $fetchedUser';
100
101        if ( !$this->userGroupAssignmentService->targetCanHaveUserGroups( $fetchedUser ) ) {
102            // Differentiate between temp accounts and IP addresses. Eventually we might want
103            // to edit the messages so that the same can be shown for both cases.
104            $messageKey = $fetchedUser->isRegistered() ? 'userrights-no-group' : 'nosuchusershort';
105            $out->addHTML( Html::warningBox(
106                $this->msg( $messageKey, $fetchedUser->getName() )->parse()
107            ) );
108            return;
109        }
110
111        $this->initialize( $fetchedUser );
112        $this->showMessageOnSuccess();
113
114        if (
115            $request->wasPosted() &&
116            $request->getCheck( 'saveusergroups' ) &&
117            $user->matchEditToken( $request->getVal( 'wpEditToken' ), $targetName )
118        ) {
119            /*
120             * If the user is blocked and they only have "partial" access
121             * (e.g. they don't have the userrights permission), then don't
122             * allow them to change any user rights.
123             */
124            if ( !$this->getAuthority()->isAllowed( 'userrights' ) ) {
125                $block = $user->getBlock();
126                if ( $block && $block->isSitewide() ) {
127                    throw new UserBlockedError(
128                        $block,
129                        $user,
130                        $this->getLanguage(),
131                        $request->getIP()
132                    );
133                }
134            }
135
136            $this->checkReadOnly();
137
138            $status = $this->saveUserGroups(
139                $request->getText( 'user-reason' ),
140                $fetchedUser,
141            );
142
143            if ( $status->isOK() ) {
144                $this->setSuccessFlag();
145                $out->redirect( $this->getSuccessURL( $targetName ) );
146                return;
147            } else {
148                // Print an error message and redisplay the form
149                foreach ( $status->getMessages() as $msg ) {
150                    $out->addHTML( Html::errorBox(
151                        $this->msg( $msg )->parse()
152                    ) );
153                }
154            }
155        }
156
157        // Show the form (either edit or view)
158        $out->addHTML( $this->buildGroupsForm() );
159        $this->showLogFragment( 'rights', 'rights' );
160    }
161
162    /**
163     * Initializes the class with data related to the current target user. This method should be called
164     * before delegating any operations related to viewing, editing or saving user groups to the parent class.
165     */
166    private function initialize( UserIdentity $user ): void {
167        $this->targetUser = $user;
168        $this->setTargetName(
169            $user->getName(),
170            $this->userGroupAssignmentService->getPageTitleForTargetUser( $user )
171        );
172
173        $wikiId = $user->getWikiId();
174        $userGroupManager = $this->userGroupManagerFactory->getUserGroupManager( $wikiId );
175        $this->explicitGroups = $userGroupManager->listAllGroups();
176        $this->groupMemberships = $userGroupManager->getUserGroupMemberships( $user );
177        $this->userGroupManager = $userGroupManager;
178
179        // Don't evaluate private conditions for restricted groups here, so that we don't leak
180        // information about them through the checkboxes being disabled or enabled
181        // An exception is if a user tries to change their own groups - it doesn't leak anything
182        $evaluatePrivateConditions = $user->equals( $this->getAuthority()->getUser() );
183
184        $changeableGroups = $this->userGroupAssignmentService->getChangeableGroups(
185            $this->getAuthority(), $user, $evaluatePrivateConditions );
186        $this->setChangeableGroups( $changeableGroups );
187
188        $isLocalWiki = $wikiId === UserIdentity::LOCAL;
189        $this->enableWatchUser = $isLocalWiki;
190        if ( $isLocalWiki ) {
191            // Listing autopromote groups is only available on the local wiki
192            $this->autopromoteGroups = $userGroupManager->getUserAutopromoteGroups( $this->targetUser );
193            // Set the 'relevant user' in the skin, so it displays links like Contributions,
194            // User logs, UserRights, etc.
195            $this->getSkin()->setRelevantUser( $user );
196        }
197    }
198
199    private function getSuccessURL( string $target ): string {
200        return $this->getPageTitle( $target )->getFullURL();
201    }
202
203    /**
204     * Save user groups changes in the database.
205     * Data comes from the editUserGroupsForm() form function
206     *
207     * @param string $reason Reason for group change
208     * @param UserIdentity $user The target user
209     * @return Status
210     */
211    protected function saveUserGroups( string $reason, UserIdentity $user ) {
212        // This conflict check doesn't prevent from a situation when two concurrent DB transactions
213        // update the same user's groups, but that's highly unlikely.
214        $userGroupsPrimary = $this->userGroupManager->getUserGroupMemberships( $user, IDBAccessObject::READ_LATEST );
215        if ( $this->conflictOccured( $userGroupsPrimary ) ) {
216            return Status::newFatal( 'userrights-conflict' );
217        }
218
219        $newGroupsStatus = $this->readGroupsForm();
220
221        if ( !$newGroupsStatus->isOK() ) {
222            return $newGroupsStatus;
223        }
224        $newGroups = $newGroupsStatus->value;
225
226        // addgroup contains also existing groups with changed expiry
227        [ $addgroup, $removegroup, $groupExpiries ] = $this->splitGroupsIntoAddRemove(
228            $newGroups, $this->groupMemberships );
229
230        $invalidGroups = $this->userGroupAssignmentService->validateUserGroups(
231            $this->getAuthority(), $user, $addgroup, $removegroup, $groupExpiries, $this->groupMemberships );
232        if ( $invalidGroups ) {
233            // We cannot simply use $invalidGroups for logging, as it doesn't contain groups with satisfied conditions
234            // (and lack of error may be an information itself, which should be logged)
235            $this->userGroupAssignmentService->logAccessToPrivateConditions(
236                $this->getAuthority(), $user, $addgroup, $groupExpiries, $this->groupMemberships );
237            return $this->formatInvalidGroupsStatus( $invalidGroups, $user->getName() );
238        }
239
240        $this->userGroupAssignmentService->saveChangesToUserGroups( $this->getAuthority(), $user, $addgroup,
241            $removegroup, $groupExpiries, $reason );
242
243        if ( $user->getWikiId() === UserIdentity::LOCAL && $this->getRequest()->getCheck( 'wpWatch' ) ) {
244            $this->watchlistManager->addWatchIgnoringRights(
245                $this->getUser(),
246                Title::makeTitle( NS_USER, $user->getName() )
247            );
248        }
249
250        return Status::newGood();
251    }
252
253    /**
254     * When there's an attempt to change user's groups in a way that the performer shouldn't do,
255     * this function formats the Status result telling what and why happened.
256     * @param array<string,string> $invalidGroups
257     * @param string $targetUserName Name of the target user, for use in {{GENDER:}}
258     */
259    private function formatInvalidGroupsStatus( array $invalidGroups, string $targetUserName ): Status {
260        $listItems = '';
261        foreach ( $invalidGroups as $group => $reason ) {
262            $groupName = $this->getLanguage()->getGroupName( $group );
263
264            if ( $reason === 'rights' ) {
265                $reasonMessage = $this->msg( 'userrights-insufficient-rights' );
266            } else {
267                // Use the same message as for annotation next to the group checkbox
268                $customMessageKey = 'userrights-restricted-group-' . $group;
269                $messageKey = $this->msg( $customMessageKey )->exists() ?
270                    $customMessageKey :
271                    'userrights-restricted-group-warning';
272                $reasonMessage = $this->msg( $messageKey );
273            }
274
275            $message = $this->msg( 'userrights-unable-to-change-row', $groupName, $reasonMessage )->parse();
276            $listItems .= Html::rawElement( 'li', [], $message );
277        }
278
279        $formattedList = Html::rawElement( 'ul', [], $listItems );
280        return Status::newFatal(
281            'userrights-unable-to-change', $formattedList, $targetUserName, count( $invalidGroups ) );
282    }
283
284    /**
285     * Display a HTMLUserTextField form to allow searching for a named user only
286     */
287    protected function switchForm( string $target ) {
288        $formDescriptor = [
289            'user' => [
290                'class' => HTMLUserTextField::class,
291                'label-message' => 'userrights-user-editname',
292                'name' => 'user',
293                'ipallowed' => true,
294                'iprange' => true,
295                'excludetemp' => true, // Do not show temp users: T341684
296                'autofocus' => $target === '',
297                'default' => $target,
298            ]
299        ];
300
301        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
302        $htmlForm
303            ->setMethod( 'GET' )
304            ->setAction( wfScript() )
305            ->setName( 'uluser' )
306            ->setTitle( SpecialPage::getTitleFor( 'Userrights' ) )
307            ->setWrapperLegendMsg( 'userrights-lookup-user' )
308            ->setId( 'mw-userrights-form1' )
309            ->setSubmitTextMsg( 'editusergroup' )
310            ->prepareForm()
311            ->displayForm( true );
312    }
313
314    /** @inheritDoc */
315    protected function getTargetUserToolLinks(): string {
316        $targetWiki = $this->targetUser->getWikiId();
317        $systemUser = $targetWiki === UserIdentity::LOCAL
318            && $this->userFactory->newFromUserIdentity( $this->targetUser )->isSystemUser();
319
320        // Only add an email link if the user is not a system user
321        $flags = $systemUser ? 0 : Linker::TOOL_LINKS_EMAIL;
322        return Linker::userToolLinks(
323            $this->targetUser->getId( $targetWiki ),
324            $this->targetDisplayName,
325            false, /* default for redContribsWhenNoEdits */
326            $flags
327        );
328    }
329
330    protected function buildFormExtraInfo(): ?string {
331        // Display a note if this is a system user
332        $systemUser = $this->targetUser->getWikiId() === UserIdentity::LOCAL
333            && $this->userFactory->newFromUserIdentity( $this->targetUser )->isSystemUser();
334        if ( $systemUser ) {
335            return $this->msg( 'userrights-systemuser' )
336                ->params( $this->targetUser->getName() )
337                ->parse();
338        }
339        return null;
340    }
341
342    /** @inheritDoc */
343    protected function categorizeUserGroupsForDisplay( array $userGroups ): array {
344        return [
345            'userrights-groupsmember' => array_values( $userGroups ),
346            'userrights-groupsmember-auto' => $this->autopromoteGroups,
347        ];
348    }
349
350    /**
351     * Return an array of subpages beginning with $search that this special page will accept.
352     *
353     * @param string $search Prefix to search for
354     * @param int $limit Maximum number of results to return (usually 10)
355     * @param int $offset Number of results to skip (usually 0)
356     * @return string[] Matching subpages
357     */
358    public function prefixSearchSubpages( $search, $limit, $offset ) {
359        $search = $this->userNameUtils->getCanonical( $search );
360        if ( !$search ) {
361            // No prefix suggestion for invalid user
362            return [];
363        }
364        // Autocomplete subpage as user list - public to allow caching
365        return $this->userNamePrefixSearch
366            ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
367    }
368}
369
370/**
371 * Retain the old class name for backwards compatibility.
372 * @deprecated since 1.40
373 */
374class_alias( SpecialUserRights::class, 'UserrightsPage' );