Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
73 / 73 |
|
100.00% |
8 / 8 |
CRAP | |
100.00% |
1 / 1 |
Throttle | |
100.00% |
73 / 73 |
|
100.00% |
8 / 8 |
25 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
shouldDisableOtherConsequences | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
getSort | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isThrottled | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
setThrottled | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
throttleKey | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
throttleIdentifier | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
11 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence; |
4 | |
5 | use InvalidArgumentException; |
6 | use MediaWiki\Extension\AbuseFilter\Consequences\ConsequenceNotPrecheckedException; |
7 | use MediaWiki\Extension\AbuseFilter\Consequences\Parameters; |
8 | use MediaWiki\Title\Title; |
9 | use MediaWiki\User\UserEditTracker; |
10 | use MediaWiki\User\UserFactory; |
11 | use Psr\Log\LoggerInterface; |
12 | use Wikimedia\IPUtils; |
13 | use Wikimedia\ObjectCache\BagOStuff; |
14 | |
15 | /** |
16 | * Consequence that delays executing other actions until certain conditions are met |
17 | */ |
18 | class Throttle extends Consequence implements ConsequencesDisablerConsequence { |
19 | /** @var array */ |
20 | private $throttleParams; |
21 | /** @var BagOStuff */ |
22 | private $mainStash; |
23 | /** @var UserEditTracker */ |
24 | private $userEditTracker; |
25 | /** @var UserFactory */ |
26 | private $userFactory; |
27 | /** @var LoggerInterface */ |
28 | private $logger; |
29 | /** @var bool */ |
30 | private $filterIsCentral; |
31 | /** @var string|null */ |
32 | private $centralDB; |
33 | |
34 | /** @var bool|null */ |
35 | private $hitThrottle; |
36 | |
37 | private const IPV4_RANGE = '16'; |
38 | private const IPV6_RANGE = '64'; |
39 | |
40 | /** |
41 | * @param Parameters $parameters |
42 | * @param array $throttleParams |
43 | * @phan-param array{groups:string[],id:int|string,count:int,period:int} $throttleParams |
44 | * @param BagOStuff $mainStash |
45 | * @param UserEditTracker $userEditTracker |
46 | * @param UserFactory $userFactory |
47 | * @param LoggerInterface $logger |
48 | * @param bool $filterIsCentral |
49 | * @param string|null $centralDB |
50 | */ |
51 | public function __construct( |
52 | Parameters $parameters, |
53 | array $throttleParams, |
54 | BagOStuff $mainStash, |
55 | UserEditTracker $userEditTracker, |
56 | UserFactory $userFactory, |
57 | LoggerInterface $logger, |
58 | bool $filterIsCentral, |
59 | ?string $centralDB |
60 | ) { |
61 | parent::__construct( $parameters ); |
62 | $this->throttleParams = $throttleParams; |
63 | $this->mainStash = $mainStash; |
64 | $this->userEditTracker = $userEditTracker; |
65 | $this->userFactory = $userFactory; |
66 | $this->logger = $logger; |
67 | $this->filterIsCentral = $filterIsCentral; |
68 | $this->centralDB = $centralDB; |
69 | } |
70 | |
71 | /** |
72 | * @return bool Whether the throttling took place (i.e. the limit was NOT hit) |
73 | * @throws ConsequenceNotPrecheckedException |
74 | */ |
75 | public function execute(): bool { |
76 | if ( $this->hitThrottle === null ) { |
77 | throw new ConsequenceNotPrecheckedException(); |
78 | } |
79 | foreach ( $this->throttleParams['groups'] as $throttleType ) { |
80 | $this->setThrottled( $throttleType ); |
81 | } |
82 | return !$this->hitThrottle; |
83 | } |
84 | |
85 | /** |
86 | * @inheritDoc |
87 | */ |
88 | public function shouldDisableOtherConsequences(): bool { |
89 | $this->hitThrottle = false; |
90 | foreach ( $this->throttleParams['groups'] as $throttleType ) { |
91 | $this->hitThrottle = $this->isThrottled( $throttleType ) || $this->hitThrottle; |
92 | } |
93 | return !$this->hitThrottle; |
94 | } |
95 | |
96 | /** |
97 | * @inheritDoc |
98 | */ |
99 | public function getSort(): int { |
100 | return 0; |
101 | } |
102 | |
103 | /** |
104 | * Determines whether the throttle has been hit with the given parameters |
105 | * @note If caching is disabled, get() will return false, so the throttle count will never be reached (if >0). |
106 | * This means that filters with 'throttle' enabled won't ever trigger any consequence. |
107 | * |
108 | * @param string $types |
109 | * @return bool |
110 | */ |
111 | private function isThrottled( string $types ): bool { |
112 | $key = $this->throttleKey( $types ); |
113 | $newCount = (int)$this->mainStash->get( $key ) + 1; |
114 | |
115 | $this->logger->debug( |
116 | 'New value is {newCount} for throttle key {key}. Maximum is {rateCount}.', |
117 | [ |
118 | 'newCount' => $newCount, |
119 | 'key' => $key, |
120 | 'rateCount' => $this->throttleParams['count'], |
121 | ] |
122 | ); |
123 | |
124 | return $newCount > $this->throttleParams['count']; |
125 | } |
126 | |
127 | /** |
128 | * Updates the throttle status with the given parameters |
129 | * |
130 | * @param string $types |
131 | */ |
132 | private function setThrottled( string $types ): void { |
133 | $key = $this->throttleKey( $types ); |
134 | $this->logger->debug( |
135 | 'Increasing throttle key {key}', |
136 | [ 'key' => $key ] |
137 | ); |
138 | $this->mainStash->incrWithInit( $key, $this->throttleParams['period'] ); |
139 | } |
140 | |
141 | /** |
142 | * @param string $type |
143 | * @return string |
144 | */ |
145 | private function throttleKey( string $type ): string { |
146 | $types = explode( ',', $type ); |
147 | |
148 | $identifiers = []; |
149 | |
150 | foreach ( $types as $subtype ) { |
151 | $identifiers[] = $this->throttleIdentifier( $subtype ); |
152 | } |
153 | |
154 | $identifier = sha1( implode( ':', $identifiers ) ); |
155 | |
156 | if ( $this->parameters->getIsGlobalFilter() && !$this->filterIsCentral ) { |
157 | return $this->mainStash->makeGlobalKey( |
158 | 'abusefilter', 'throttle', $this->centralDB, $this->throttleParams['id'], $identifier |
159 | ); |
160 | } |
161 | |
162 | return $this->mainStash->makeKey( 'abusefilter', 'throttle', $this->throttleParams['id'], $identifier ); |
163 | } |
164 | |
165 | /** |
166 | * @param string $type |
167 | * @return string |
168 | */ |
169 | private function throttleIdentifier( string $type ): string { |
170 | $user = $this->parameters->getUser(); |
171 | switch ( $type ) { |
172 | case 'ip': |
173 | $identifier = $this->parameters->getActionSpecifier()->getIP(); |
174 | break; |
175 | case 'user': |
176 | // NOTE: This is always 0 for anons. Is this good/wanted? |
177 | $identifier = $user->getId(); |
178 | break; |
179 | case 'range': |
180 | $requestIP = $this->parameters->getActionSpecifier()->getIP(); |
181 | $range = IPUtils::isIPv6( $requestIP ) ? self::IPV6_RANGE : self::IPV4_RANGE; |
182 | $identifier = IPUtils::sanitizeRange( "{$requestIP}/$range" ); |
183 | break; |
184 | case 'creationdate': |
185 | // TODO Inject a proper service, not UserFactory, once getRegistration is moved away from User |
186 | $reg = (int)$this->userFactory->newFromUserIdentity( $user )->getRegistration(); |
187 | $identifier = $reg - ( $reg % 86400 ); |
188 | break; |
189 | case 'editcount': |
190 | // Hack for detecting different single-purpose accounts. |
191 | $identifier = $this->userEditTracker->getUserEditCount( $user ) ?: 0; |
192 | break; |
193 | case 'site': |
194 | $identifier = 1; |
195 | break; |
196 | case 'page': |
197 | $title = Title::castFromLinkTarget( $this->parameters->getTarget() ); |
198 | // TODO Replace with something from LinkTarget, e.g. namespace + text |
199 | $identifier = $title->getPrefixedText(); |
200 | break; |
201 | default: |
202 | // Should never happen |
203 | throw new InvalidArgumentException( "Invalid throttle type $type." ); |
204 | } |
205 | |
206 | return "$type-$identifier"; |
207 | } |
208 | } |