Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
99.53% |
214 / 215 |
|
93.75% |
15 / 16 |
CRAP | |
0.00% |
0 / 1 |
FilterLookup | |
99.53% |
214 / 215 |
|
93.75% |
15 / 16 |
47 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getFilter | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
getAllActiveFiltersInGroup | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
6 | |||
getAllActiveFiltersInGroupFromDB | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
4 | |||
getDBConnection | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getActionsFromDB | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
getFilterVersion | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
getLastHistoryVersion | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
getClosestVersion | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
5 | |||
getFirstFilterVersionID | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
clearLocalCache | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
purgeGroupWANCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getGlobalRulesKey | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
filterFromHistoryRow | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
3 | |||
filterFromRow | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
4 | |||
getCacheKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter; |
4 | |
5 | use DBAccessObjectUtils; |
6 | use IDBAccessObject; |
7 | use MediaWiki\Extension\AbuseFilter\Filter\ClosestFilterVersionNotFoundException; |
8 | use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter; |
9 | use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException; |
10 | use MediaWiki\Extension\AbuseFilter\Filter\FilterVersionNotFoundException; |
11 | use MediaWiki\Extension\AbuseFilter\Filter\Flags; |
12 | use MediaWiki\Extension\AbuseFilter\Filter\HistoryFilter; |
13 | use MediaWiki\Extension\AbuseFilter\Filter\LastEditInfo; |
14 | use MediaWiki\Extension\AbuseFilter\Filter\Specs; |
15 | use stdClass; |
16 | use WANObjectCache; |
17 | use Wikimedia\Rdbms\IDatabase; |
18 | use Wikimedia\Rdbms\ILoadBalancer; |
19 | |
20 | /** |
21 | * This class provides read access to the filters stored in the database. |
22 | * |
23 | * @todo Cache exceptions |
24 | */ |
25 | class FilterLookup implements IDBAccessObject { |
26 | public const SERVICE_NAME = 'AbuseFilterFilterLookup'; |
27 | |
28 | // Used in getClosestVersion |
29 | public const DIR_PREV = 'prev'; |
30 | public const DIR_NEXT = 'next'; |
31 | |
32 | /** |
33 | * @var string[] The FULL list of fields in the abuse_filter table |
34 | */ |
35 | private const ALL_ABUSE_FILTER_FIELDS = [ |
36 | 'af_id', |
37 | 'af_pattern', |
38 | 'af_user', |
39 | 'af_user_text', |
40 | 'af_timestamp', |
41 | 'af_enabled', |
42 | 'af_comments', |
43 | 'af_public_comments', |
44 | 'af_hidden', |
45 | 'af_hit_count', |
46 | 'af_throttled', |
47 | 'af_deleted', |
48 | 'af_actions', |
49 | 'af_global', |
50 | 'af_group' |
51 | ]; |
52 | |
53 | /** |
54 | * @var ExistingFilter[] Individual filters cache. Keys can be integer IDs, or global names |
55 | */ |
56 | private $cache = []; |
57 | |
58 | /** |
59 | * @var ExistingFilter[][][] Cache of all active filters in each group. This is not related to |
60 | * the individual cache, and is replicated in WAN cache. The structure is |
61 | * [ local|global => [ group => [ ID => filter ] ] ] |
62 | * where the cache for each group has the same format as $this->cache |
63 | * Note that the keys are also in the form 'global-ID' for filters in 'global', although redundant. |
64 | */ |
65 | private $groupCache = [ 'local' => [], 'global' => [] ]; |
66 | |
67 | /** @var HistoryFilter[] */ |
68 | private $historyCache = []; |
69 | |
70 | /** @var int[] */ |
71 | private $firstVersionCache = []; |
72 | |
73 | /** @var int[] */ |
74 | private $lastVersionCache = []; |
75 | |
76 | /** |
77 | * @var int[][] [ filter => [ historyID => [ prev, next ] ] ] |
78 | * @phan-var array<int,array<int,array{prev?:int,next?:int}>> |
79 | */ |
80 | private $closestVersionsCache = []; |
81 | |
82 | /** @var ILoadBalancer */ |
83 | private $loadBalancer; |
84 | |
85 | /** @var WANObjectCache */ |
86 | private $wanCache; |
87 | |
88 | /** @var CentralDBManager */ |
89 | private $centralDBManager; |
90 | |
91 | /** |
92 | * @param ILoadBalancer $loadBalancer |
93 | * @param WANObjectCache $cache |
94 | * @param CentralDBManager $centralDBManager |
95 | */ |
96 | public function __construct( |
97 | ILoadBalancer $loadBalancer, |
98 | WANObjectCache $cache, |
99 | CentralDBManager $centralDBManager |
100 | ) { |
101 | $this->loadBalancer = $loadBalancer; |
102 | $this->wanCache = $cache; |
103 | $this->centralDBManager = $centralDBManager; |
104 | } |
105 | |
106 | /** |
107 | * @param int $filterID |
108 | * @param bool $global |
109 | * @param int $flags One of the self::READ_* constants |
110 | * @return ExistingFilter |
111 | * @throws FilterNotFoundException if the filter doesn't exist |
112 | * @throws CentralDBNotAvailableException |
113 | */ |
114 | public function getFilter( int $filterID, bool $global, int $flags = self::READ_NORMAL ): ExistingFilter { |
115 | $cacheKey = $this->getCacheKey( $filterID, $global ); |
116 | if ( $flags !== self::READ_NORMAL || !isset( $this->cache[$cacheKey] ) ) { |
117 | [ $dbIndex, $dbOptions ] = DBAccessObjectUtils::getDBOptions( $flags ); |
118 | $dbr = $this->getDBConnection( $dbIndex, $global ); |
119 | |
120 | $row = $dbr->selectRow( |
121 | 'abuse_filter', |
122 | self::ALL_ABUSE_FILTER_FIELDS, |
123 | [ 'af_id' => $filterID ], |
124 | __METHOD__, |
125 | $dbOptions |
126 | ); |
127 | if ( !$row ) { |
128 | throw new FilterNotFoundException( $filterID, $global ); |
129 | } |
130 | $fname = __METHOD__; |
131 | $getActionsCB = function () use ( $dbr, $fname, $row ): array { |
132 | return $this->getActionsFromDB( $dbr, $fname, $row->af_id ); |
133 | }; |
134 | $this->cache[$cacheKey] = $this->filterFromRow( $row, $getActionsCB ); |
135 | } |
136 | |
137 | return $this->cache[$cacheKey]; |
138 | } |
139 | |
140 | /** |
141 | * Get all filters that are active (and not deleted) and in the given group |
142 | * @param string $group |
143 | * @param bool $global |
144 | * @param int $flags |
145 | * @return ExistingFilter[] |
146 | * @throws CentralDBNotAvailableException |
147 | */ |
148 | public function getAllActiveFiltersInGroup( string $group, bool $global, int $flags = self::READ_NORMAL ): array { |
149 | $domainKey = $global ? 'global' : 'local'; |
150 | if ( $flags !== self::READ_NORMAL || !isset( $this->groupCache[$domainKey][$group] ) ) { |
151 | if ( $global ) { |
152 | $globalRulesKey = $this->getGlobalRulesKey( $group ); |
153 | $ret = $this->wanCache->getWithSetCallback( |
154 | $globalRulesKey, |
155 | WANObjectCache::TTL_WEEK, |
156 | function () use ( $group, $global, $flags ) { |
157 | return $this->getAllActiveFiltersInGroupFromDB( $group, $global, $flags ); |
158 | }, |
159 | [ |
160 | 'checkKeys' => [ $globalRulesKey ], |
161 | 'lockTSE' => 300, |
162 | 'version' => 3 |
163 | ] |
164 | ); |
165 | } else { |
166 | $ret = $this->getAllActiveFiltersInGroupFromDB( $group, $global, $flags ); |
167 | } |
168 | |
169 | $this->groupCache[$domainKey][$group] = []; |
170 | foreach ( $ret as $key => $filter ) { |
171 | $this->groupCache[$domainKey][$group][$key] = $filter; |
172 | $this->cache[$key] = $filter; |
173 | } |
174 | } |
175 | return $this->groupCache[$domainKey][$group]; |
176 | } |
177 | |
178 | /** |
179 | * @param string $group |
180 | * @param bool $global |
181 | * @param int $flags |
182 | * @return ExistingFilter[] |
183 | */ |
184 | private function getAllActiveFiltersInGroupFromDB( string $group, bool $global, int $flags ): array { |
185 | [ $dbIndex, $dbOptions ] = DBAccessObjectUtils::getDBOptions( $flags ); |
186 | $dbr = $this->getDBConnection( $dbIndex, $global ); |
187 | |
188 | $where = [ |
189 | 'af_enabled' => 1, |
190 | 'af_deleted' => 0, |
191 | 'af_group' => $group, |
192 | ]; |
193 | if ( $global ) { |
194 | $where['af_global'] = 1; |
195 | } |
196 | |
197 | // Note, excluding individually cached filter now wouldn't help much, so take it as |
198 | // an occasion to refresh the cache later |
199 | $rows = $dbr->select( |
200 | 'abuse_filter', |
201 | self::ALL_ABUSE_FILTER_FIELDS, |
202 | $where, |
203 | __METHOD__, |
204 | $dbOptions |
205 | ); |
206 | |
207 | $fname = __METHOD__; |
208 | $ret = []; |
209 | foreach ( $rows as $row ) { |
210 | $filterKey = $this->getCacheKey( $row->af_id, $global ); |
211 | $getActionsCB = function () use ( $dbr, $fname, $row ): array { |
212 | return $this->getActionsFromDB( $dbr, $fname, $row->af_id ); |
213 | }; |
214 | $ret[$filterKey] = $this->filterFromRow( |
215 | $row, |
216 | // Don't pass a closure if global, as this is going to be serialized when caching |
217 | $global ? $getActionsCB() : $getActionsCB |
218 | ); |
219 | } |
220 | return $ret; |
221 | } |
222 | |
223 | /** |
224 | * @param int $dbIndex |
225 | * @param bool $global |
226 | * @return IDatabase |
227 | * @throws CentralDBNotAvailableException |
228 | */ |
229 | private function getDBConnection( int $dbIndex, bool $global ): IDatabase { |
230 | if ( $global ) { |
231 | return $this->centralDBManager->getConnection( $dbIndex ); |
232 | } else { |
233 | return $this->loadBalancer->getConnectionRef( $dbIndex ); |
234 | } |
235 | } |
236 | |
237 | /** |
238 | * @param IDatabase $db |
239 | * @param string $fname |
240 | * @param int $id |
241 | * @return array |
242 | */ |
243 | private function getActionsFromDB( IDatabase $db, string $fname, int $id ): array { |
244 | $res = $db->select( |
245 | 'abuse_filter_action', |
246 | [ 'afa_consequence', 'afa_parameters' ], |
247 | [ 'afa_filter' => $id ], |
248 | $fname |
249 | ); |
250 | |
251 | $actions = []; |
252 | foreach ( $res as $actionRow ) { |
253 | $actions[$actionRow->afa_consequence] = $actionRow->afa_parameters !== '' |
254 | ? explode( "\n", $actionRow->afa_parameters ) |
255 | : []; |
256 | } |
257 | return $actions; |
258 | } |
259 | |
260 | /** |
261 | * Get an old version of the given (local) filter, with its actions |
262 | * |
263 | * @param int $version Unique identifier of the version |
264 | * @param int $flags |
265 | * @return HistoryFilter |
266 | * @throws FilterVersionNotFoundException if the version doesn't exist |
267 | */ |
268 | public function getFilterVersion( |
269 | int $version, |
270 | int $flags = self::READ_NORMAL |
271 | ): HistoryFilter { |
272 | if ( $flags !== self::READ_NORMAL || !isset( $this->historyCache[$version] ) ) { |
273 | [ $dbIndex, $dbOptions ] = DBAccessObjectUtils::getDBOptions( $flags ); |
274 | $dbr = $this->loadBalancer->getConnectionRef( $dbIndex ); |
275 | |
276 | $row = $dbr->selectRow( |
277 | 'abuse_filter_history', |
278 | '*', |
279 | [ 'afh_id' => $version ], |
280 | __METHOD__, |
281 | $dbOptions |
282 | ); |
283 | if ( !$row ) { |
284 | throw new FilterVersionNotFoundException( $version ); |
285 | } |
286 | $this->historyCache[$version] = $this->filterFromHistoryRow( $row ); |
287 | } |
288 | |
289 | return $this->historyCache[$version]; |
290 | } |
291 | |
292 | /** |
293 | * @param int $filterID |
294 | * @return HistoryFilter |
295 | * @throws FilterNotFoundException If the filter doesn't exist |
296 | */ |
297 | public function getLastHistoryVersion( int $filterID ): HistoryFilter { |
298 | if ( !isset( $this->lastVersionCache[$filterID] ) ) { |
299 | $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); |
300 | $row = $dbr->selectRow( |
301 | 'abuse_filter_history', |
302 | '*', |
303 | [ 'afh_filter' => $filterID ], |
304 | __METHOD__, |
305 | [ 'ORDER BY' => 'afh_id DESC' ] |
306 | ); |
307 | if ( !$row ) { |
308 | throw new FilterNotFoundException( $filterID, false ); |
309 | } |
310 | $filterObj = $this->filterFromHistoryRow( $row ); |
311 | $this->lastVersionCache[$filterID] = $filterObj->getHistoryID(); |
312 | $this->historyCache[$filterObj->getHistoryID()] = $filterObj; |
313 | } |
314 | return $this->historyCache[ $this->lastVersionCache[$filterID] ]; |
315 | } |
316 | |
317 | /** |
318 | * @param int $historyID |
319 | * @param int $filterID |
320 | * @param string $direction self::DIR_PREV or self::DIR_NEXT |
321 | * @return HistoryFilter |
322 | * @throws ClosestFilterVersionNotFoundException |
323 | */ |
324 | public function getClosestVersion( int $historyID, int $filterID, string $direction ): HistoryFilter { |
325 | if ( !isset( $this->closestVersionsCache[$filterID][$historyID][$direction] ) ) { |
326 | $comparison = $direction === self::DIR_PREV ? '<' : '>'; |
327 | $order = $direction === self::DIR_PREV ? 'DESC' : 'ASC'; |
328 | $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); |
329 | $row = $dbr->selectRow( |
330 | 'abuse_filter_history', |
331 | '*', |
332 | [ |
333 | 'afh_filter' => $filterID, |
334 | "afh_id $comparison" . $dbr->addQuotes( $historyID ), |
335 | ], |
336 | __METHOD__, |
337 | [ 'ORDER BY' => "afh_timestamp $order" ] |
338 | ); |
339 | if ( !$row ) { |
340 | throw new ClosestFilterVersionNotFoundException( $filterID, $historyID ); |
341 | } |
342 | $filterObj = $this->filterFromHistoryRow( $row ); |
343 | $this->closestVersionsCache[$filterID][$historyID][$direction] = $filterObj->getHistoryID(); |
344 | $this->historyCache[$filterObj->getHistoryID()] = $filterObj; |
345 | } |
346 | $histID = $this->closestVersionsCache[$filterID][$historyID][$direction]; |
347 | return $this->historyCache[$histID]; |
348 | } |
349 | |
350 | /** |
351 | * Get the history ID of the first change to a given filter |
352 | * |
353 | * @param int $filterID |
354 | * @return int |
355 | * @throws FilterNotFoundException |
356 | */ |
357 | public function getFirstFilterVersionID( int $filterID ): int { |
358 | if ( !isset( $this->firstVersionCache[$filterID] ) ) { |
359 | $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); |
360 | $historyID = $dbr->selectField( |
361 | 'abuse_filter_history', |
362 | 'MIN(afh_id)', |
363 | [ 'afh_filter' => $filterID ], |
364 | __METHOD__ |
365 | ); |
366 | if ( $historyID === false ) { |
367 | throw new FilterNotFoundException( $filterID, false ); |
368 | } |
369 | $this->firstVersionCache[$filterID] = (int)$historyID; |
370 | } |
371 | |
372 | return $this->firstVersionCache[$filterID]; |
373 | } |
374 | |
375 | /** |
376 | * Resets the internal cache of Filter objects |
377 | */ |
378 | public function clearLocalCache(): void { |
379 | $this->cache = []; |
380 | $this->groupCache = [ 'local' => [], 'global' => [] ]; |
381 | $this->historyCache = []; |
382 | $this->firstVersionCache = []; |
383 | $this->lastVersionCache = []; |
384 | $this->closestVersionsCache = []; |
385 | } |
386 | |
387 | /** |
388 | * Purge the shared cache of global filters in the given group. |
389 | * @note This doesn't purge the local cache |
390 | * @param string $group |
391 | */ |
392 | public function purgeGroupWANCache( string $group ): void { |
393 | $this->wanCache->touchCheckKey( $this->getGlobalRulesKey( $group ) ); |
394 | } |
395 | |
396 | /** |
397 | * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) |
398 | * @return string |
399 | */ |
400 | private function getGlobalRulesKey( string $group ): string { |
401 | if ( !$this->centralDBManager->filterIsCentral() ) { |
402 | return $this->wanCache->makeGlobalKey( |
403 | 'abusefilter', |
404 | 'rules', |
405 | $this->centralDBManager->getCentralDBName(), |
406 | $group |
407 | ); |
408 | } |
409 | |
410 | return $this->wanCache->makeKey( 'abusefilter', 'rules', $group ); |
411 | } |
412 | |
413 | /** |
414 | * Note: this is private because no external caller should access DB rows directly. |
415 | * @param stdClass $row |
416 | * @return HistoryFilter |
417 | */ |
418 | private function filterFromHistoryRow( stdClass $row ): HistoryFilter { |
419 | $actionsRaw = unserialize( $row->afh_actions ); |
420 | $actions = is_array( $actionsRaw ) ? $actionsRaw : []; |
421 | $flags = $row->afh_flags ? explode( ',', $row->afh_flags ) : []; |
422 | return new HistoryFilter( |
423 | new Specs( |
424 | trim( $row->afh_pattern ), |
425 | $row->afh_comments, |
426 | // FIXME: Make the DB field NOT NULL (T263324) |
427 | (string)$row->afh_public_comments, |
428 | array_keys( $actions ), |
429 | // FIXME Make the field NOT NULL and add default (T263324) |
430 | $row->afh_group ?? 'default' |
431 | ), |
432 | new Flags( |
433 | in_array( 'enabled', $flags, true ), |
434 | in_array( 'deleted', $flags, true ), |
435 | in_array( 'hidden', $flags, true ), |
436 | in_array( 'global', $flags, true ) |
437 | ), |
438 | $actions, |
439 | new LastEditInfo( |
440 | (int)$row->afh_user, |
441 | $row->afh_user_text, |
442 | $row->afh_timestamp |
443 | ), |
444 | (int)$row->afh_filter, |
445 | $row->afh_id |
446 | ); |
447 | } |
448 | |
449 | /** |
450 | * Note: this is private because no external caller should access DB rows directly. |
451 | * @param stdClass $row |
452 | * @param array[]|callable $actions |
453 | * @return ExistingFilter |
454 | */ |
455 | private function filterFromRow( stdClass $row, $actions ): ExistingFilter { |
456 | return new ExistingFilter( |
457 | new Specs( |
458 | trim( $row->af_pattern ), |
459 | // FIXME: Make the DB fields for these NOT NULL (T263324) |
460 | (string)$row->af_comments, |
461 | (string)$row->af_public_comments, |
462 | $row->af_actions !== '' ? explode( ',', $row->af_actions ) : [], |
463 | $row->af_group |
464 | ), |
465 | new Flags( |
466 | (bool)$row->af_enabled, |
467 | (bool)$row->af_deleted, |
468 | (bool)$row->af_hidden, |
469 | (bool)$row->af_global |
470 | ), |
471 | $actions, |
472 | new LastEditInfo( |
473 | (int)$row->af_user, |
474 | $row->af_user_text, |
475 | $row->af_timestamp |
476 | ), |
477 | (int)$row->af_id, |
478 | isset( $row->af_hit_count ) ? (int)$row->af_hit_count : null, |
479 | isset( $row->af_throttled ) ? (bool)$row->af_throttled : null |
480 | ); |
481 | } |
482 | |
483 | /** |
484 | * @param int $filterID |
485 | * @param bool $global |
486 | * @return string |
487 | */ |
488 | private function getCacheKey( int $filterID, bool $global ): string { |
489 | return GlobalNameUtils::buildGlobalName( $filterID, $global ); |
490 | } |
491 | } |