Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.77% covered (success)
90.77%
59 / 65
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserRequirementsConditionEvaluator
90.77% covered (success)
90.77%
59 / 65
66.67% covered (warning)
66.67%
2 / 3
27.57
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
 checkCondition
90.48% covered (success)
90.48%
57 / 63
0.00% covered (danger)
0.00%
0 / 1
25.54
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use LogicException;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\Context\IContextSource;
12use MediaWiki\MainConfigNames;
13use MediaWiki\Parser\Sanitizer;
14use MediaWiki\Permissions\GroupPermissionsLookup;
15use MediaWiki\User\Registration\UserRegistrationLookup;
16use MediaWiki\WikiMap\WikiMap;
17use Wikimedia\IPUtils;
18use Wikimedia\Rdbms\IDBAccessObject;
19use Wikimedia\Timestamp\TimestampFormat as TS;
20
21class UserRequirementsConditionEvaluator extends UserRequirementsConditionEvaluatorBase {
22
23    /** @internal For use by UserRequirementsConditionCheckerFactory */
24    public const CONSTRUCTOR_OPTIONS = [
25        MainConfigNames::AutoConfirmAge,
26        MainConfigNames::AutoConfirmCount,
27        MainConfigNames::EmailAuthentication,
28    ];
29
30    /**
31     * @internal For use preventing an infinite loop when checking APCOND_BLOCKED
32     * @var array An assoc. array mapping the getCacheKey userKey to a bool indicating
33     * an ongoing condition check.
34     */
35    private array $recursionMap = [];
36
37    public function __construct(
38        private readonly ServiceOptions $options,
39        private readonly GroupPermissionsLookup $groupPermissionsLookup,
40        private readonly UserEditTracker $userEditTracker,
41        private readonly UserRegistrationLookup $userRegistrationLookup,
42        private readonly UserFactory $userFactory,
43        private readonly IContextSource $context,
44        private readonly UserGroupManager $userGroupManager,
45    ) {
46        $this->options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
47    }
48
49    /** @inheritDoc */
50    public function checkCondition(
51        string|int $conditionType,
52        array $args,
53        UserIdentity $user,
54        bool $isPerformingRequest
55    ): ?bool {
56        // Some checks depend on hooks or other dynamically-determined state, so we can fetch them only
57        // for the local wiki and not for remote users. The latter may require API requests to the remote
58        // wiki, which has not been implemented for now due to performance concerns.
59        $isCurrentWiki = ( $user->getWikiId() === false ) || WikiMap::isCurrentWikiId( $user->getWikiId() );
60
61        switch ( $conditionType ) {
62            case APCOND_EMAILCONFIRMED:
63                if ( !$isCurrentWiki ) {
64                    return false;
65                }
66                $userObject = $this->userFactory->newFromUserIdentity( $user );
67                return Sanitizer::validateEmail( $userObject->getEmail() ) &&
68                    ( !$this->options->get( MainConfigNames::EmailAuthentication ) ||
69                        $userObject->getEmailAuthenticationTimestamp() );
70            case APCOND_EDITCOUNT:
71                $reqEditCount = $args[0] ?? $this->options->get( MainConfigNames::AutoConfirmCount );
72
73                // T157718: Avoid edit count lookup if the specified edit count is 0 or invalid
74                if ( $reqEditCount <= 0 ) {
75                    return true;
76                }
77                return (int)$this->userEditTracker->getUserEditCount( $user ) >= $reqEditCount;
78            case APCOND_AGE:
79                $reqAge = $args[0] ?? $this->options->get( MainConfigNames::AutoConfirmAge );
80                if ( $reqAge <= 0 ) {
81                    return true;
82                }
83                $registration = $this->userRegistrationLookup->getRegistration( $user );
84                $age = time() - (int)wfTimestampOrNull( TS::UNIX, $registration );
85                return $age >= $reqAge;
86            case APCOND_AGE_FROM_EDIT:
87                $reqAge = $args[0] ?? $this->options->get( MainConfigNames::AutoConfirmAge );
88                if ( $reqAge <= 0 ) {
89                    return true;
90                }
91                $firstEdit = $this->userEditTracker->getFirstEditTimestamp( $user );
92                if ( $firstEdit === false ) {
93                    // If the user has no edits, then they don't meet the requirement.
94                    return false;
95                }
96                $age = time() - (int)wfTimestampOrNull( TS::UNIX, $firstEdit );
97                return $age >= $reqAge;
98            case APCOND_INGROUPS:
99                if ( !$isCurrentWiki ) {
100                    return false;
101                }
102                return count( array_intersect(
103                        $args,
104                        $this->userGroupManager->getUserGroups( $user )
105                    ) ) === count( $args );
106            case APCOND_ISIP:
107                // Since the IPs are not permanently bound to users, the IP conditions can only be checked
108                // for the requesting user. Otherwise, assume the condition is false.
109                return $isPerformingRequest && $args[0] === $this->context->getRequest()->getIP();
110            case APCOND_IPINRANGE:
111                return $isPerformingRequest && IPUtils::isInRange( $this->context->getRequest()->getIP(), $args[0] );
112            case APCOND_BLOCKED:
113                if ( !$isCurrentWiki ) {
114                    // This condition is more likely to be used as "! APCOND_BLOCKED", so ensure it can't be bypassed
115                    // when tested from a remote wiki.
116                    return true;
117                }
118                // Because checking for ipblock-exempt leads back to here (thus infinite recursion),
119                // we if we've been here before for this user without having returned a value.
120                // See T270145 and T349608
121                $userKey = $this->getCacheKey( $user );
122                if ( $this->recursionMap[$userKey] ?? false ) {
123                    throw new LogicException(
124                        "Unexpected recursion! APCOND_BLOCKED is being checked during" .
125                        " an existing APCOND_BLOCKED check for \"{$user->getName()}\"!"
126                    );
127                }
128                $this->recursionMap[$userKey] = true;
129                // Setting the second parameter here to true prevents us from getting back here
130                // during standard MediaWiki core behavior
131                $userObject = $this->userFactory->newFromUserIdentity( $user );
132                $block = $userObject->getBlock( IDBAccessObject::READ_LATEST, true );
133                $this->recursionMap[$userKey] = false;
134
135                return (bool)$block?->isSitewide();
136            case APCOND_ISBOT:
137                if ( !$isCurrentWiki ) {
138                    return false;
139                }
140                return in_array( 'bot', $this->groupPermissionsLookup
141                    ->getGroupPermissions(
142                        $this->userGroupManager->getUserGroups( $user )
143                    )
144                );
145            default:
146                return null;
147        }
148    }
149
150    /** Gets a unique key for various caches. */
151    private function getCacheKey( UserIdentity $user ): string {
152        // As long as it's for in-memory caches, identifying users by name is fine
153        return $user->getWikiId() . ':' . $user->getName();
154    }
155}