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