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