Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
109 / 109
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
UserDef
100.00% covered (success)
100.00%
109 / 109
100.00% covered (success)
100.00%
7 / 7
44
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 normalizeSettings
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 checkSettings
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 processUser
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
21
 getParamInfo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getHelpInfo
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\ParamValidator\TypeDef;
4
5use MediaWiki\Title\MalformedTitleException;
6use MediaWiki\Title\TitleParser;
7use MediaWiki\User\ExternalUserNames;
8use MediaWiki\User\UserIdentity;
9use MediaWiki\User\UserIdentityLookup;
10use MediaWiki\User\UserIdentityValue;
11use MediaWiki\User\UserNameUtils;
12use MediaWiki\User\UserRigorOptions;
13use Wikimedia\IPUtils;
14use Wikimedia\Message\MessageValue;
15use Wikimedia\ParamValidator\Callbacks;
16use Wikimedia\ParamValidator\ParamValidator;
17use Wikimedia\ParamValidator\TypeDef;
18
19/**
20 * Type definition for user types
21 *
22 * Failure codes:
23 *  - 'baduser': The value was not a valid MediaWiki user. No data.
24 *
25 * @since 1.35
26 */
27class UserDef extends TypeDef {
28
29    /**
30     * (string[]) Allowed types of user.
31     *
32     * One or more of the following values:
33     * - 'name': User names are allowed.
34     * - 'ip': IP ("anon") usernames are allowed.
35     * - 'temp': Temporary users are allowed.
36     * - 'cidr': IP ranges are allowed.
37     * - 'interwiki': Interwiki usernames are allowed.
38     * - 'id': Allow specifying user IDs, formatted like "#123".
39     *
40     * Default is `[ 'name', 'ip', 'temp', 'cidr', 'interwiki' ]`.
41     *
42     * Avoid combining 'id' with PARAM_ISMULTI, as it may result in excessive
43     * DB lookups. If you do combine them, consider setting low values for
44     * PARAM_ISMULTI_LIMIT1 and PARAM_ISMULTI_LIMIT2 to mitigate it.
45     */
46    public const PARAM_ALLOWED_USER_TYPES = 'param-allowed-user-types';
47
48    /**
49     * (bool) Whether to return a UserIdentity object.
50     *
51     * If false, the validated user name is returned as a string. Default is false.
52     *
53     * Avoid setting true with PARAM_ISMULTI, as it may result in excessive DB
54     * lookups. If you do combine them, consider setting low values for
55     * PARAM_ISMULTI_LIMIT1 and PARAM_ISMULTI_LIMIT2 to mitigate it.
56     */
57    public const PARAM_RETURN_OBJECT = 'param-return-object';
58
59    /** @var UserIdentityLookup */
60    private $userIdentityLookup;
61
62    /** @var TitleParser */
63    private $titleParser;
64
65    /** @var UserNameUtils */
66    private $userNameUtils;
67
68    /**
69     * @param Callbacks $callbacks
70     * @param UserIdentityLookup $userIdentityLookup
71     * @param TitleParser $titleParser
72     * @param UserNameUtils $userNameUtils
73     */
74    public function __construct(
75        Callbacks $callbacks,
76        UserIdentityLookup $userIdentityLookup,
77        TitleParser $titleParser,
78        UserNameUtils $userNameUtils
79    ) {
80        parent::__construct( $callbacks );
81        $this->userIdentityLookup = $userIdentityLookup;
82        $this->titleParser = $titleParser;
83        $this->userNameUtils = $userNameUtils;
84    }
85
86    public function validate( $name, $value, array $settings, array $options ) {
87        [ $type, $user ] = $this->processUser( $value );
88
89        if ( !$user || !in_array( $type, $settings[self::PARAM_ALLOWED_USER_TYPES], true ) ) {
90            // Message used: paramvalidator-baduser
91            $this->failure( 'baduser', $name, $value, $settings, $options );
92        }
93
94        return empty( $settings[self::PARAM_RETURN_OBJECT] ) ? $user->getName() : $user;
95    }
96
97    public function normalizeSettings( array $settings ) {
98        if ( isset( $settings[self::PARAM_ALLOWED_USER_TYPES] ) ) {
99            $settings[self::PARAM_ALLOWED_USER_TYPES] = array_values( array_intersect(
100                [ 'name', 'ip', 'temp', 'cidr', 'interwiki', 'id' ],
101                $settings[self::PARAM_ALLOWED_USER_TYPES]
102            ) );
103        }
104        if ( empty( $settings[self::PARAM_ALLOWED_USER_TYPES] ) ) {
105            $settings[self::PARAM_ALLOWED_USER_TYPES] = [ 'name', 'ip', 'temp', 'cidr', 'interwiki' ];
106        }
107
108        return parent::normalizeSettings( $settings );
109    }
110
111    public function checkSettings( string $name, $settings, array $options, array $ret ): array {
112        $ret = parent::checkSettings( $name, $settings, $options, $ret );
113
114        $ret['allowedKeys'] = array_merge( $ret['allowedKeys'], [
115            self::PARAM_ALLOWED_USER_TYPES, self::PARAM_RETURN_OBJECT,
116        ] );
117
118        if ( !is_bool( $settings[self::PARAM_RETURN_OBJECT] ?? false ) ) {
119            $ret['issues'][self::PARAM_RETURN_OBJECT] = 'PARAM_RETURN_OBJECT must be boolean, got '
120                . gettype( $settings[self::PARAM_RETURN_OBJECT] );
121        }
122
123        $hasId = false;
124        if ( isset( $settings[self::PARAM_ALLOWED_USER_TYPES] ) ) {
125            if ( !is_array( $settings[self::PARAM_ALLOWED_USER_TYPES] ) ) {
126                $ret['issues'][self::PARAM_ALLOWED_USER_TYPES] = 'PARAM_ALLOWED_USER_TYPES must be an array, '
127                    . 'got ' . gettype( $settings[self::PARAM_ALLOWED_USER_TYPES] );
128            } elseif ( $settings[self::PARAM_ALLOWED_USER_TYPES] === [] ) {
129                $ret['issues'][self::PARAM_ALLOWED_USER_TYPES] = 'PARAM_ALLOWED_USER_TYPES cannot be empty';
130            } else {
131                $bad = array_diff(
132                    $settings[self::PARAM_ALLOWED_USER_TYPES],
133                    [ 'name', 'ip', 'temp', 'cidr', 'interwiki', 'id' ]
134                );
135                if ( $bad ) {
136                    $ret['issues'][self::PARAM_ALLOWED_USER_TYPES] =
137                        'PARAM_ALLOWED_USER_TYPES contains invalid values: ' . implode( ', ', $bad );
138                }
139
140                $hasId = in_array( 'id', $settings[self::PARAM_ALLOWED_USER_TYPES], true );
141            }
142        }
143
144        if ( !empty( $settings[ParamValidator::PARAM_ISMULTI] ) &&
145            ( $hasId || !empty( $settings[self::PARAM_RETURN_OBJECT] ) ) &&
146            (
147                ( $settings[ParamValidator::PARAM_ISMULTI_LIMIT1] ?? 100 ) > 10 ||
148                ( $settings[ParamValidator::PARAM_ISMULTI_LIMIT2] ?? 100 ) > 10
149            )
150        ) {
151            $ret['issues'][] = 'Multi-valued user-type parameters with PARAM_RETURN_OBJECT or allowing IDs '
152                . 'should set low values (<= 10) for PARAM_ISMULTI_LIMIT1 and PARAM_ISMULTI_LIMIT2.'
153                . ' (Note that "<= 10" is arbitrary. If something hits this, we can investigate a real limit '
154                . 'once we have a real use case to look at.)';
155        }
156
157        return $ret;
158    }
159
160    /**
161     * Process $value to a UserIdentity, if possible
162     * @param string $value
163     * @return array [ string $type, UserIdentity|null $user ]
164     * @phan-return array{0:string,1:UserIdentity|null}
165     */
166    private function processUser( string $value ): array {
167        // A user ID?
168        if ( preg_match( '/^#(\d+)$/D', $value, $m ) ) {
169            // This used to use the IP address of the current request if the
170            // id was 0, to match the behavior of User objects, but was switched
171            // to "Unknown user" because the former behavior is likely unexpected.
172            // If the id corresponds to a user in the database, use that user, otherwise
173            // return a UserIdentityValue with id 0 (regardless of the input id) and
174            // the name "Unknown user"
175            $userId = (int)$m[1];
176            if ( $userId !== 0 ) {
177                // Check the database.
178                $userIdentity = $this->userIdentityLookup->getUserIdentityByUserId( $userId );
179                if ( $userIdentity ) {
180                    return [ 'id', $userIdentity ];
181                }
182            }
183            // Fall back to "Unknown user"
184            return [
185                'id',
186                new UserIdentityValue( 0, "Unknown user" )
187            ];
188        }
189
190        // An interwiki username?
191        if ( ExternalUserNames::isExternal( $value ) ) {
192            $name = $this->userNameUtils->getCanonical( $value, UserRigorOptions::RIGOR_NONE );
193            // UserIdentityValue has the username which includes the > separating the external
194            // wiki database and the actual name, but is created for the *local* wiki, like
195            // for User objects (local is the default, but we specify it anyway to show
196            // that its intentional even though the username is for a different wiki)
197            // NOTE: We deliberately use the raw $value instead of the canonical $name
198            // to avoid converting the first character of the interwiki prefix to uppercase
199            $user = $name !== false ? new UserIdentityValue( 0, $value, UserIdentityValue::LOCAL ) : null;
200            return [ 'interwiki', $user ];
201        }
202
203        // A temp user?
204        if ( $this->userNameUtils->isTemp( $value ) ) {
205            $userIdentity = $this->userIdentityLookup->getUserIdentityByName( $value );
206            return [ 'temp', $userIdentity ];
207        }
208
209        // A valid user name?
210        // Match behavior of UserFactory::newFromName with RIGOR_VALID and User::getId()
211        // we know that if there is a canonical form from UserNameUtils then this can't
212        // look like an IP, and since we checked for external user names above it isn't
213        // that either, so if this is a valid user name then we check the database for
214        // the id, and if there is no user with this name the id is 0
215        $canonicalName = $this->userNameUtils->getCanonical( $value, UserRigorOptions::RIGOR_VALID );
216        if ( $canonicalName !== false ) {
217            $userIdentity = $this->userIdentityLookup->getUserIdentityByName( $canonicalName );
218            if ( $userIdentity ) {
219                return [ 'name', $userIdentity ];
220            }
221            // Fall back to id 0
222            return [
223                'name',
224                new UserIdentityValue( 0, $canonicalName )
225            ];
226        }
227
228        // (T232672) Reproduce the normalization applied in UserNameUtils::getCanonical() when
229        // performing the checks below.
230        if ( strpos( $value, '#' ) !== false ) {
231            return [ '', null ];
232        }
233
234        try {
235            $t = $this->titleParser->parseTitle( $value );
236        } catch ( MalformedTitleException $_ ) {
237            $t = null;
238        }
239        if ( !$t || $t->getNamespace() !== NS_USER || $t->isExternal() ) { // likely
240            try {
241                $t = $this->titleParser->parseTitle( "User:$value" );
242            } catch ( MalformedTitleException $_ ) {
243                $t = null;
244            }
245        }
246        if ( !$t || $t->getNamespace() !== NS_USER || $t->isExternal() ) {
247            // If it wasn't a valid User-namespace title, fail.
248            return [ '', null ];
249        }
250        $value = $t->getText();
251
252        // An IP?
253        $b = IPUtils::RE_IP_BYTE;
254        if ( IPUtils::isValid( $value ) ||
255            // See comment for UserNameUtils::isIP. We don't just call that function
256            // here because it also returns true for things like
257            // 300.300.300.300 that are neither valid usernames nor valid IP
258            // addresses.
259            preg_match( "/^$b\.$b\.$b\.xxx$/D", $value )
260        ) {
261            $name = IPUtils::sanitizeIP( $value );
262            // We don't really need to use UserNameUtils::getCanonical() because for anonymous
263            // users the only validation is that there is no `#` (which is already the case if its
264            // a valid IP or matches the regex) and the only normalization is making the first
265            // character uppercase (doesn't matter for numbers) and replacing underscores with
266            // spaces (doesn't apply to IPs). But, better safe than sorry?
267            $name = $this->userNameUtils->getCanonical( $name, UserRigorOptions::RIGOR_NONE );
268            return [ 'ip', UserIdentityValue::newAnonymous( $name ) ];
269        }
270
271        // A range?
272        if ( IPUtils::isValidRange( $value ) ) {
273            $name = IPUtils::sanitizeIP( $value );
274            // Per above, the UserNameUtils call isn't strictly needed, but doesn't hurt
275            $name = $this->userNameUtils->getCanonical( $name, UserRigorOptions::RIGOR_NONE );
276            return [ 'cidr', UserIdentityValue::newAnonymous( $name ) ];
277        }
278
279        // Fail.
280        return [ '', null ];
281    }
282
283    public function getParamInfo( $name, array $settings, array $options ) {
284        $info = parent::getParamInfo( $name, $settings, $options );
285
286        $info['subtypes'] = $settings[self::PARAM_ALLOWED_USER_TYPES];
287
288        return $info;
289    }
290
291    public function getHelpInfo( $name, array $settings, array $options ) {
292        $info = parent::getParamInfo( $name, $settings, $options );
293
294        $isMulti = !empty( $settings[ParamValidator::PARAM_ISMULTI] );
295
296        $subtypes = [];
297        foreach ( $settings[self::PARAM_ALLOWED_USER_TYPES] as $st ) {
298            // Messages: paramvalidator-help-type-user-subtype-name,
299            // paramvalidator-help-type-user-subtype-ip, paramvalidator-help-type-user-subtype-cidr,
300            // paramvalidator-help-type-user-subtype-interwiki, paramvalidator-help-type-user-subtype-id,
301            // paramvalidator-help-type-user-subtype-temp
302            $subtypes[] = MessageValue::new( "paramvalidator-help-type-user-subtype-$st" );
303        }
304        $info[ParamValidator::PARAM_TYPE] = MessageValue::new( 'paramvalidator-help-type-user' )
305            ->params( $isMulti ? 2 : 1 )
306            ->textListParams( $subtypes )
307            ->numParams( count( $subtypes ) );
308
309        return $info;
310    }
311
312}