Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.65% covered (success)
95.65%
132 / 138
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserRequirementsConditionChecker
95.65% covered (success)
95.65%
132 / 138
85.71% covered (warning)
85.71%
6 / 7
64
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 checkCondition
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
1 / 1
26
 recursivelyCheckCondition
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 recursivelyCheckConditionInternal
86.05% covered (warning)
86.05%
37 / 43
0.00% covered (danger)
0.00%
0 / 1
27.84
 extractPrivateConditions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 extractPrivateConditionsInternal
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use InvalidArgumentException;
10use LogicException;
11use MediaWiki\Config\ServiceOptions;
12use MediaWiki\Context\IContextSource;
13use MediaWiki\HookContainer\HookContainer;
14use MediaWiki\HookContainer\HookRunner;
15use MediaWiki\MainConfigNames;
16use MediaWiki\Parser\Sanitizer;
17use MediaWiki\Permissions\GroupPermissionsLookup;
18use MediaWiki\User\Registration\UserRegistrationLookup;
19use MediaWiki\WikiMap\WikiMap;
20use Psr\Log\LoggerInterface;
21use Wikimedia\IPUtils;
22use Wikimedia\Rdbms\IDBAccessObject;
23use Wikimedia\Timestamp\TimestampFormat as TS;
24
25/**
26 * @since 1.45
27 * @stable to extend Since 1.46
28 */
29class UserRequirementsConditionChecker {
30
31    /**
32     * Logical operators recognized in $wgAutopromote.
33     *
34     * @since 1.45
35     */
36    public const VALID_OPS = [ '&', '|', '^', '!' ];
37
38    /** @internal For use by ServiceWiring */
39    public const CONSTRUCTOR_OPTIONS = [
40        MainConfigNames::AutoConfirmAge,
41        MainConfigNames::AutoConfirmCount,
42        MainConfigNames::EmailAuthentication,
43        MainConfigNames::UserRequirementsPrivateConditions,
44    ];
45
46    /**
47     * @internal For use preventing an infinite loop when checking APCOND_BLOCKED
48     * @var array An assoc. array mapping the getCacheKey userKey to a bool indicating
49     * an ongoing condition check.
50     */
51    private array $recursionMap = [];
52
53    private HookRunner $hookRunner;
54
55    public function __construct(
56        private readonly ServiceOptions $options,
57        private readonly GroupPermissionsLookup $groupPermissionsLookup,
58        HookContainer $hookContainer,
59        private readonly LoggerInterface $logger,
60        private readonly UserEditTracker $userEditTracker,
61        private readonly UserRegistrationLookup $userRegistrationLookup,
62        private readonly UserFactory $userFactory,
63        private readonly IContextSource $context,
64        private readonly UserGroupManager $userGroupManager,
65        private readonly string|false $wikiId = UserIdentity::LOCAL,
66    ) {
67        $this->hookRunner = new HookRunner( $hookContainer );
68        $this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
69    }
70
71    /**
72     * As recursivelyCheckCondition, but *not* recursive. The only valid conditions
73     * are those whose first element is one of APCOND_* defined in Defines.php.
74     * Other types will throw an exception if no extension evaluates them.
75     *
76     * @param array $cond A condition, which must not contain other conditions. This array must contain at least
77     *     one item, which is the condition type.
78     * @param UserIdentity $user The user to check the condition against
79     * @return ?bool Whether the condition is true for the user. Null if it's a private condition
80     *     and we're not supposed to evaluate these.
81     * @throws InvalidArgumentException if autopromote condition was not recognized.
82     * @throws LogicException if APCOND_BLOCKED is checked again before returning a result.
83     * @stable to override Since 1.46
84     */
85    protected function checkCondition( array $cond, UserIdentity $user ): ?bool {
86        $isPerformingRequest = !defined( 'MW_NO_SESSION' ) && $user->equals( $this->context->getUser() );
87
88        // Some checks depend on hooks or other dynamically-determined state, so we can fetch them only
89        // for the local wiki and not for remote users. The latter may require API requests to the remote
90        // wiki, which has not been implemented for now due to performance concerns.
91        $isCurrentWiki = ( $user->getWikiId() === false ) || WikiMap::isCurrentWikiId( $user->getWikiId() );
92
93        switch ( $cond[0] ) {
94            case APCOND_EMAILCONFIRMED:
95                if ( !$isCurrentWiki ) {
96                    return false;
97                }
98                $userObject = $this->userFactory->newFromUserIdentity( $user );
99                return Sanitizer::validateEmail( $userObject->getEmail() ) &&
100                    ( !$this->options->get( MainConfigNames::EmailAuthentication ) ||
101                        $userObject->getEmailAuthenticationTimestamp() );
102            case APCOND_EDITCOUNT:
103                $reqEditCount = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmCount );
104
105                // T157718: Avoid edit count lookup if the specified edit count is 0 or invalid
106                if ( $reqEditCount <= 0 ) {
107                    return true;
108                }
109                return (int)$this->userEditTracker->getUserEditCount( $user ) >= $reqEditCount;
110            case APCOND_AGE:
111                $reqAge = $cond[1] ?? $this->options->get( MainConfigNames::AutoConfirmAge );
112                $registration = $this->userRegistrationLookup->getRegistration( $user );
113                $age = time() - (int)wfTimestampOrNull( TS::UNIX, $registration );
114                return $age >= $reqAge;
115            case APCOND_AGE_FROM_EDIT:
116                $age = time() - (int)wfTimestampOrNull(
117                    TS::UNIX,
118                    $this->userEditTracker->getFirstEditTimestamp( $user )
119                );
120                return $age >= $cond[1];
121            case APCOND_INGROUPS:
122                if ( !$isCurrentWiki ) {
123                    return false;
124                }
125                $groups = array_slice( $cond, 1 );
126                return count( array_intersect(
127                        $groups,
128                        $this->userGroupManager->getUserGroups( $user )
129                    ) ) === count( $groups );
130            case APCOND_ISIP:
131                // Since the IPs are not permanently bound to users, the IP conditions can only be checked
132                // for the requesting user. Otherwise, assume the condition is false.
133                return $isPerformingRequest && $cond[1] === $this->context->getRequest()->getIP();
134            case APCOND_IPINRANGE:
135                return $isPerformingRequest && IPUtils::isInRange( $this->context->getRequest()->getIP(), $cond[1] );
136            case APCOND_BLOCKED:
137                if ( !$isCurrentWiki ) {
138                    // This condition is more likely to be used as "! APCOND_BLOCKED", so ensure it can't be bypassed
139                    // when tested from a remote wiki.
140                    return true;
141                }
142                // Because checking for ipblock-exempt leads back to here (thus infinite recursion),
143                // we if we've been here before for this user without having returned a value.
144                // See T270145 and T349608
145                $userKey = $this->getCacheKey( $user );
146                if ( $this->recursionMap[$userKey] ?? false ) {
147                    throw new LogicException(
148                        "Unexpected recursion! APCOND_BLOCKED is being checked during" .
149                        " an existing APCOND_BLOCKED check for \"{$user->getName()}\"!"
150                    );
151                }
152                $this->recursionMap[$userKey] = true;
153                // Setting the second parameter here to true prevents us from getting back here
154                // during standard MediaWiki core behavior
155                $userObject = $this->userFactory->newFromUserIdentity( $user );
156                $block = $userObject->getBlock( IDBAccessObject::READ_LATEST, true );
157                $this->recursionMap[$userKey] = false;
158
159                return (bool)$block?->isSitewide();
160            case APCOND_ISBOT:
161                if ( !$isCurrentWiki ) {
162                    return false;
163                }
164                return in_array( 'bot', $this->groupPermissionsLookup
165                    ->getGroupPermissions(
166                        $this->userGroupManager->getUserGroups( $user )
167                    )
168                );
169            default:
170                $result = null;
171                $type = $cond[0];
172                $args = array_slice( $cond, 1 );
173                $this->hookRunner->onUserRequirementsCondition( $type, $args, $user, $isPerformingRequest, $result );
174
175                if ( $isPerformingRequest && $isCurrentWiki ) {
176                    // The legacy hook is run only if the tested user is the one performing
177                    // the request (like for autopromote), and the user is from the local wiki.
178                    // If any of these conditions is not met, we cannot invoke the hook,
179                    // as it may produce incorrect results.
180                    $userObject = $this->userFactory->newFromUserIdentity( $user );
181                    $this->hookRunner->onAutopromoteCondition( $type, $args, $userObject, $result );
182                }
183
184                if ( $result === null ) {
185                    throw new InvalidArgumentException(
186                        "Unrecognized condition $type in UserRequirementsCondition!"
187                    );
188                }
189
190                return (bool)$result;
191        }
192    }
193
194    /**
195     * Recursively check a condition. Conditions are in the form
196     *   [ '&' or '|' or '^' or '!', cond1, cond2, ... ]
197     * where cond1, cond2, ... are themselves conditions; *OR*
198     *   APCOND_EMAILCONFIRMED, *OR*
199     *   [ APCOND_EMAILCONFIRMED ], *OR*
200     *   [ APCOND_EDITCOUNT, number of edits ], *OR*
201     *   [ APCOND_AGE, seconds since registration ], *OR*
202     *   similar constructs defined by extensions.
203     * This function evaluates the former type recursively, and passes off to
204     * checkCondition for evaluation of the latter type.
205     *
206     * If you change the logic of this method, please update
207     * ApiQuerySiteinfo::appendAutoPromote(), as it depends on this method.
208     *
209     * @param mixed $cond A condition, possibly containing other conditions
210     * @param UserIdentity $user The user to check the conditions against
211     * @param bool $usePrivateConditions Whether to evaluate private conditions
212     *
213     * @return ?bool Whether the condition is true; will be null if the condition value depends on any of the
214     *      unevaluated private conditions. Non-null value means that the skipped conditions have no effect
215     *      on the result. Null can be returned only if $usePrivateConditions is false.
216     */
217    public function recursivelyCheckCondition( $cond, UserIdentity $user, bool $usePrivateConditions = true ): ?bool {
218        $skippedConditions = [];
219        if ( !$usePrivateConditions ) {
220            $skippedConditions = $this->options->get( MainConfigNames::UserRequirementsPrivateConditions );
221            $skippedConditions = array_fill_keys( $skippedConditions, true );
222        }
223
224        return $this->recursivelyCheckConditionInternal( $cond, $user, $skippedConditions );
225    }
226
227    /**
228     * Internal version of recursivelyCheckCondition, which operates on three-valued logic, for
229     * the purpose of supporting private conditions. The third state, beyond false and true, is
230     * null, which is recognized as an unknown value (e.g., false | null = null, true | null = true).
231     *
232     * @param mixed $cond A condition, possibly containing other conditions
233     * @param UserIdentity $user The user to check the conditions against
234     * @param array<string,bool> $skippedConditions Array whose keys tell which conditions to skip while evaluating
235     * @return ?bool Whether the condition is true; will be null if the condition value depends on any of
236     *     $skippedConditions. Non-null value means that the skipped conditions have no effect on the result.
237     */
238    private function recursivelyCheckConditionInternal( $cond, UserIdentity $user, array $skippedConditions ): ?bool {
239        if ( is_array( $cond ) && count( $cond ) >= 2 && in_array( $cond[0], self::VALID_OPS ) ) {
240            // Recursive condition
241
242            // AND (all conditions pass)
243            if ( $cond[0] === '&' ) {
244                $hasNulls = false;
245                foreach ( array_slice( $cond, 1 ) as $subcond ) {
246                    $result = $this->recursivelyCheckConditionInternal( $subcond, $user, $skippedConditions );
247                    if ( $result === false ) {
248                        return false;
249                    }
250                    $hasNulls = $hasNulls || $result === null;
251                }
252
253                return $hasNulls ? null : true;
254            }
255
256            // OR (at least one condition passes)
257            if ( $cond[0] === '|' ) {
258                $hasNulls = false;
259                foreach ( array_slice( $cond, 1 ) as $subcond ) {
260                    $result = $this->recursivelyCheckConditionInternal( $subcond, $user, $skippedConditions );
261                    if ( $result === true ) {
262                        return true;
263                    }
264                    $hasNulls = $hasNulls || $result === null;
265                }
266
267                return $hasNulls ? null : false;
268            }
269
270            // XOR (exactly one condition passes)
271            if ( $cond[0] === '^' ) {
272                if ( count( $cond ) > 3 ) {
273                    $this->logger->warning(
274                        'recursivelyCheckCondition() given XOR ("^") condition on three or more conditions.' .
275                        ' Check your $wgRestrictedGroups, $wgAutopromote and $wgAutopromoteOnce settings.'
276                    );
277                }
278                $result1 = $this->recursivelyCheckConditionInternal( $cond[1], $user, $skippedConditions );
279                $result2 = $this->recursivelyCheckConditionInternal( $cond[2], $user, $skippedConditions );
280                if ( $result1 === null || $result2 === null ) {
281                    return null;
282                }
283                return $result1 xor $result2;
284            }
285
286            // NOT (no conditions pass)
287            if ( $cond[0] === '!' ) {
288                $hasNulls = false;
289                foreach ( array_slice( $cond, 1 ) as $subcond ) {
290                    $result = $this->recursivelyCheckConditionInternal( $subcond, $user, $skippedConditions );
291                    if ( $result === true ) {
292                        return false;
293                    }
294                    $hasNulls = $hasNulls || $result === null;
295                }
296
297                return $hasNulls ? null : true;
298            }
299        }
300        // If we got here, the array presumably does not contain other conditions;
301        // it's not recursive. Pass it off to checkCondition.
302        if ( !is_array( $cond ) ) {
303            $cond = [ $cond ];
304        }
305
306        // Ensure the condition makes sense at all
307        if ( count( $cond ) < 1 ) {
308            return false;
309        }
310
311        if ( isset( $skippedConditions[$cond[0]] ) ) {
312            return null;
313        }
314
315        return $this->checkCondition( $cond, $user );
316    }
317
318    /**
319     * Goes through a condition passed as the input and extracts all private conditions that are used within it.
320     * @param mixed $cond A condition, possibly containing other conditions.
321     * @return list<mixed> A list of unique private conditions present in $cond
322     */
323    public function extractPrivateConditions( $cond ): array {
324        $privateConditions = $this->options->get( MainConfigNames::UserRequirementsPrivateConditions );
325        $result = $this->extractPrivateConditionsInternal( $cond, $privateConditions );
326        return array_values( array_unique( $result ) );
327    }
328
329    /**
330     * Internal backend for {@see extractPrivateConditions}. It returns a list of all private conditions found
331     * in the input conditions. The result may contain duplicates.
332     * @param mixed $cond
333     * @param list<string> $privateConditions
334     * @return list<mixed>
335     */
336    private function extractPrivateConditionsInternal( $cond, array $privateConditions ): array {
337        $result = [];
338        if ( is_array( $cond ) ) {
339            $op = $cond[0];
340            if ( in_array( $op, self::VALID_OPS ) ) {
341                foreach ( array_slice( $cond, 1 ) as $subcond ) {
342                    $result = array_merge(
343                        $result, $this->extractPrivateConditionsInternal( $subcond, $privateConditions ) );
344                }
345            } elseif ( in_array( $op, $privateConditions ) ) {
346                $result[] = $op;
347            }
348        } elseif ( in_array( $cond, $privateConditions ) ) {
349            $result[] = $cond;
350        }
351        return $result;
352    }
353
354    /**
355     * Gets a unique key for various caches.
356     */
357    private function getCacheKey( UserIdentity $user ): string {
358        return $user->isRegistered() ? "u:{$user->getId( $this->wikiId )}" : "anon:{$user->getName()}";
359    }
360}