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