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