Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.53% covered (success)
99.53%
214 / 215
93.75% covered (success)
93.75%
15 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterLookup
99.53% covered (success)
99.53%
214 / 215
93.75% covered (success)
93.75%
15 / 16
47
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFilter
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 getAllActiveFiltersInGroup
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 getAllActiveFiltersInGroupFromDB
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
 getDBConnection
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getActionsFromDB
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 getFilterVersion
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 getLastHistoryVersion
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getClosestVersion
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 getFirstFilterVersionID
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 clearLocalCache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 purgeGroupWANCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGlobalRulesKey
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 filterFromHistoryRow
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 filterFromRow
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use DBAccessObjectUtils;
6use IDBAccessObject;
7use MediaWiki\Extension\AbuseFilter\Filter\ClosestFilterVersionNotFoundException;
8use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
9use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
10use MediaWiki\Extension\AbuseFilter\Filter\FilterVersionNotFoundException;
11use MediaWiki\Extension\AbuseFilter\Filter\Flags;
12use MediaWiki\Extension\AbuseFilter\Filter\HistoryFilter;
13use MediaWiki\Extension\AbuseFilter\Filter\LastEditInfo;
14use MediaWiki\Extension\AbuseFilter\Filter\Specs;
15use stdClass;
16use WANObjectCache;
17use Wikimedia\Rdbms\IDatabase;
18use Wikimedia\Rdbms\ILoadBalancer;
19
20/**
21 * This class provides read access to the filters stored in the database.
22 *
23 * @todo Cache exceptions
24 */
25class 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}