Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.62% covered (warning)
85.62%
131 / 153
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiUserrights
86.18% covered (warning)
86.18%
131 / 152
50.00% covered (danger)
50.00%
5 / 10
39.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 execute
98.39% covered (success)
98.39%
61 / 62
0.00% covered (danger)
0.00%
0 / 1
16
 getUrUser
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
10.20
 mustBePosted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
82.61% covered (warning)
82.61%
38 / 46
0.00% covered (danger)
0.00%
0 / 1
3.05
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWebUITokenSalt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * API userrights module
5 *
6 * Copyright © 2009 Roan Kattouw <roan.kattouw@gmail.com>
7 *
8 * @license GPL-2.0-or-later
9 * @file
10 */
11
12namespace MediaWiki\Api;
13
14use MediaWiki\ChangeTags\ChangeTags;
15use MediaWiki\MainConfigNames;
16use MediaWiki\ParamValidator\TypeDef\UserDef;
17use MediaWiki\Title\Title;
18use MediaWiki\User\MultiFormatUserIdentityLookup;
19use MediaWiki\User\Options\UserOptionsLookup;
20use MediaWiki\User\UserGroupAssignmentService;
21use MediaWiki\User\UserGroupManager;
22use MediaWiki\User\UserIdentity;
23use MediaWiki\Watchlist\WatchedItemStoreInterface;
24use MediaWiki\Watchlist\WatchlistManager;
25use MediaWiki\WikiMap\WikiMap;
26use Wikimedia\ParamValidator\ParamValidator;
27use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
28use Wikimedia\Rdbms\IDBAccessObject;
29
30/**
31 * @ingroup API
32 */
33class ApiUserrights extends ApiBase {
34
35    use ApiWatchlistTrait;
36
37    /** @var UserIdentity|null */
38    private $mUser = null;
39
40    public function __construct(
41        ApiMain $mainModule,
42        string $moduleName,
43        private readonly UserGroupManager $userGroupManager,
44        WatchedItemStoreInterface $watchedItemStore,
45        WatchlistManager $watchlistManager,
46        UserOptionsLookup $userOptionsLookup,
47        private readonly UserGroupAssignmentService $userGroupAssignmentService,
48        private readonly MultiFormatUserIdentityLookup $multiFormatUserIdentityLookup,
49    ) {
50        parent::__construct( $mainModule, $moduleName );
51        $this->watchedItemStore = $watchedItemStore;
52
53        // Variables needed in ApiWatchlistTrait trait
54        $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry );
55        $this->watchlistMaxDuration =
56            $this->getConfig()->get( MainConfigNames::WatchlistExpiryMaxDuration );
57        $this->watchlistManager = $watchlistManager;
58        $this->userOptionsLookup = $userOptionsLookup;
59    }
60
61    public function execute() {
62        $pUser = $this->getUser();
63
64        // Deny if the user is blocked and doesn't have the full 'userrights' permission.
65        // This matches what Special:UserRights does for the web UI.
66        if ( !$this->getAuthority()->isAllowed( 'userrights' ) ) {
67            $block = $pUser->getBlock( IDBAccessObject::READ_LATEST );
68            if ( $block && $block->isSitewide() ) {
69                $this->dieBlocked( $block );
70            }
71        }
72
73        $params = $this->extractRequestParams();
74
75        // Figure out expiry times from the input
76        $expiry = (array)$params['expiry'];
77        $add = (array)$params['add'];
78        if ( !$add ) {
79            $expiry = [];
80        } elseif ( count( $expiry ) !== count( $add ) ) {
81            if ( count( $expiry ) === 1 ) {
82                $expiry = array_fill( 0, count( $add ), $expiry[0] );
83            } else {
84                $this->dieWithError( [
85                    'apierror-toofewexpiries',
86                    count( $expiry ),
87                    count( $add )
88                ] );
89            }
90        }
91
92        // Validate the expiries
93        $groupExpiries = [];
94        foreach ( $expiry as $index => $expiryValue ) {
95            $group = $add[$index];
96            $groupExpiries[$group] = UserGroupAssignmentService::expiryToTimestamp( $expiryValue );
97
98            if ( $groupExpiries[$group] === false ) {
99                $this->dieWithError( [ 'apierror-invalidexpiry', wfEscapeWikiText( $expiryValue ) ] );
100            }
101
102            // not allowed to have things expiring in the past
103            if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
104                $this->dieWithError( [ 'apierror-pastexpiry', wfEscapeWikiText( $expiryValue ) ] );
105            }
106        }
107
108        $user = $this->getUrUser( $params );
109
110        $tags = $params['tags'];
111
112        // Check if user can add tags
113        if ( $tags !== null ) {
114            $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $tags, $this->getAuthority() );
115            if ( !$ableToTag->isOK() ) {
116                $this->dieStatus( $ableToTag );
117            }
118        }
119
120        $r = [];
121        $r['user'] = $user->getName();
122        $r['userid'] = $user->getId( $user->getWikiId() );
123        [ $r['added'], $r['removed'] ] = $this->userGroupAssignmentService->saveChangesToUserGroups(
124            $this->getUser(),
125            $user,
126            $add,
127            // Don't pass null to saveChangesToUserGroups() for array params, cast to empty array
128            (array)$params['remove'],
129            $groupExpiries,
130            $params['reason'],
131            (array)$tags
132        );
133
134        $userPage = Title::makeTitle( NS_USER, $user->getName() );
135        $watchlistExpiry = $this->getExpiryFromParams( $params, $userPage, $this->getUser() );
136        $watchuser = $params['watchuser'];
137        if ( $watchuser && $user->getWikiId() === UserIdentity::LOCAL ) {
138            $this->setWatch( 'watch', $userPage, $this->getUser(), null, $watchlistExpiry );
139        } else {
140            $watchuser = false;
141            $watchlistExpiry = null;
142        }
143        $r['watchuser'] = $watchuser;
144        if ( $watchlistExpiry !== null ) {
145            $r['watchlistexpiry'] = $this->getWatchlistExpiry(
146                $this->watchedItemStore,
147                $userPage,
148                $this->getUser()
149            );
150        }
151
152        $result = $this->getResult();
153        ApiResult::setIndexedTagName( $r['added'], 'group' );
154        ApiResult::setIndexedTagName( $r['removed'], 'group' );
155        $result->addValue( null, $this->getModuleName(), $r );
156    }
157
158    /**
159     * @param array $params
160     * @return UserIdentity
161     */
162    private function getUrUser( array $params ) {
163        if ( $this->mUser !== null ) {
164            return $this->mUser;
165        }
166
167        $this->requireOnlyOneParameter( $params, 'user', 'userid' );
168
169        $userDesignator = $params['user'] ?? '#' . $params['userid'];
170
171        // T422085: Check userrights-interwiki permission BEFORE looking up the user,
172        // to prevent user enumeration on private/remote wikis.
173        // This mirrors the fix applied to SpecialUserRights in Gerrit change I15306e63.
174        if ( isset( $params['user'] ) ) {
175            $interwikiDelimiter = $this->getConfig()->get( MainConfigNames::UserrightsInterwikiDelimiter );
176            if ( str_contains( $userDesignator, $interwikiDelimiter ) ) {
177                $targetParts = explode( $interwikiDelimiter, $userDesignator );
178                $remoteWikiId = $targetParts[1] ?? '';
179                if (
180                    $remoteWikiId !== '' &&
181                    !WikiMap::isCurrentWikiId( $remoteWikiId ) &&
182                    !$this->getAuthority()->isAllowed( 'userrights-interwiki' )
183                ) {
184                    $this->dieWithError( 'apierror-permissiondenied-userrights-interwiki', 'permissiondenied' );
185                }
186            }
187        }
188
189        $status = $this->multiFormatUserIdentityLookup->getUserIdentity( $userDesignator, $this->getAuthority() );
190        if ( !$status->isOK() ) {
191            $this->dieStatus( $status );
192        }
193
194        $user = $status->value;
195        $canHaveRights = $this->userGroupAssignmentService->targetCanHaveUserGroups( $user );
196        if ( !$canHaveRights ) {
197            // Return different errors for anons and temp. accounts to keep consistent behavior
198            $this->dieWithError(
199                $user->isRegistered() ? [ 'userrights-no-group', $user->getName() ] : 'nosuchusershort'
200            );
201        }
202
203        $this->mUser = $user;
204
205        return $user;
206    }
207
208    /** @inheritDoc */
209    public function mustBePosted() {
210        return true;
211    }
212
213    /** @inheritDoc */
214    public function isWriteMode() {
215        return true;
216    }
217
218    /** @inheritDoc */
219    public function getAllowedParams( $flags = 0 ) {
220        $allGroups = $this->userGroupManager->listAllGroups();
221
222        if ( $flags & ApiBase::GET_VALUES_FOR_HELP ) {
223            sort( $allGroups );
224        }
225
226        $params = [
227            'user' => [
228                ParamValidator::PARAM_TYPE => 'user',
229                UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'id' ],
230            ],
231            'userid' => [
232                ParamValidator::PARAM_TYPE => 'integer',
233                ParamValidator::PARAM_DEPRECATED => true,
234            ],
235            'add' => [
236                ParamValidator::PARAM_TYPE => $allGroups,
237                ParamValidator::PARAM_ISMULTI => true
238            ],
239            'expiry' => [
240                ParamValidator::PARAM_ISMULTI => true,
241                ParamValidator::PARAM_ALLOW_DUPLICATES => true,
242                ParamValidator::PARAM_DEFAULT => 'infinite',
243            ],
244            'remove' => [
245                ParamValidator::PARAM_TYPE => $allGroups,
246                ParamValidator::PARAM_ISMULTI => true
247            ],
248            'reason' => [
249                ParamValidator::PARAM_DEFAULT => ''
250            ],
251            'token' => [
252                // Standard definition automatically inserted
253                ApiBase::PARAM_HELP_MSG_APPEND => [ 'api-help-param-token-webui' ],
254            ],
255            'tags' => [
256                ParamValidator::PARAM_TYPE => 'tags',
257                ParamValidator::PARAM_ISMULTI => true
258            ],
259            'watchuser' => false,
260        ];
261
262        // Params appear in the docs in the order they are defined,
263        // which is why this is here and not at the bottom.
264        // @todo Find better way to support insertion at arbitrary position
265        if ( $this->watchlistExpiryEnabled ) {
266            $params += [
267                'watchlistexpiry' => [
268                    ParamValidator::PARAM_TYPE => 'expiry',
269                    ExpiryDef::PARAM_MAX => $this->watchlistMaxDuration,
270                    ExpiryDef::PARAM_USE_MAX => true,
271                ]
272            ];
273        }
274
275        return $params;
276    }
277
278    /** @inheritDoc */
279    public function needsToken() {
280        return 'userrights';
281    }
282
283    /** @inheritDoc */
284    protected function getWebUITokenSalt( array $params ) {
285        return $this->getUrUser( $params )->getName();
286    }
287
288    /** @inheritDoc */
289    protected function getExamplesMessages() {
290        return [
291            'action=userrights&user=FooBot&add=bot&remove=sysop|bureaucrat&token=123ABC'
292                => 'apihelp-userrights-example-user',
293            'action=userrights&userid=123&add=bot&remove=sysop|bureaucrat&token=123ABC'
294                => 'apihelp-userrights-example-userid',
295            'action=userrights&user=SometimeSysop&add=sysop&expiry=1%20month&token=123ABC'
296                => 'apihelp-userrights-example-expiry',
297        ];
298    }
299
300    /** @inheritDoc */
301    public function getHelpUrls() {
302        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:User_group_membership';
303    }
304}
305
306/** @deprecated class alias since 1.43 */
307class_alias( ApiUserrights::class, 'ApiUserrights' );