Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.53% covered (success)
97.53%
237 / 243
73.68% covered (warning)
73.68%
14 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterLookup
97.53% covered (success)
97.53%
237 / 243
73.68% covered (warning)
73.68%
14 / 19
59
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
 getPrivacyLevelFromFlags
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 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 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 RuntimeException;
14use stdClass;
15use Wikimedia\ObjectCache\WANObjectCache;
16use Wikimedia\Rdbms\IDBAccessObject;
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->newSelectQueryBuilder()
229            ->select( [ 'afa_consequence', 'afa_parameters' ] )
230            ->from( 'abuse_filter_action' )
231            ->where( [ 'afa_filter' => $id ] )
232            ->caller( $fname )
233            ->fetchResultSet();
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->newSelectQueryBuilder()
334                ->select( 'MIN(afh_id)' )
335                ->from( 'abuse_filter_history' )
336                ->where( [ 'afh_filter' => $filterID ] )
337                ->caller( __METHOD__ )
338                ->fetchField();
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     * @param array $flags
388     * @return int
389     */
390    private function getPrivacyLevelFromFlags( $flags ): int {
391        $hidden = in_array( 'hidden', $flags, true ) ?
392            Flags::FILTER_HIDDEN :
393            0;
394        $protected = in_array( 'protected', $flags, true ) ?
395            Flags::FILTER_USES_PROTECTED_VARS :
396            0;
397        return $hidden | $protected;
398    }
399
400    /**
401     * Note: this is private because no external caller should access DB rows directly.
402     * @param stdClass $row
403     * @return HistoryFilter
404     */
405    private function filterFromHistoryRow( stdClass $row ): HistoryFilter {
406        $actionsRaw = unserialize( $row->afh_actions );
407        $actions = is_array( $actionsRaw ) ? $actionsRaw : [];
408        $flags = $row->afh_flags ? explode( ',', $row->afh_flags ) : [];
409        return new HistoryFilter(
410            new Specs(
411                trim( $row->afh_pattern ),
412                $row->afh_comments,
413                // FIXME: Make the DB field NOT NULL (T263324)
414                (string)$row->afh_public_comments,
415                array_keys( $actions ),
416                // FIXME Make the field NOT NULL and add default (T263324)
417                $row->afh_group ?? 'default'
418            ),
419            new Flags(
420                in_array( 'enabled', $flags, true ),
421                in_array( 'deleted', $flags, true ),
422                $this->getPrivacyLevelFromFlags( $flags ),
423                in_array( 'global', $flags, true )
424            ),
425            $actions,
426            new LastEditInfo(
427                (int)$row->afh_user,
428                $row->afh_user_text,
429                $row->afh_timestamp
430            ),
431            (int)$row->afh_filter,
432            $row->afh_id
433        );
434    }
435
436    /**
437     * Note: this is private because no external caller should access DB rows directly.
438     * @param stdClass $row
439     * @param array[]|callable $actions
440     * @return ExistingFilter
441     */
442    private function filterFromRow( stdClass $row, $actions ): ExistingFilter {
443        return new ExistingFilter(
444            new Specs(
445                trim( $row->af_pattern ),
446                // FIXME: Make the DB fields for these NOT NULL (T263324)
447                (string)$row->af_comments,
448                (string)$row->af_public_comments,
449                $row->af_actions !== '' ? explode( ',', $row->af_actions ) : [],
450                $row->af_group
451            ),
452            new Flags(
453                (bool)$row->af_enabled,
454                (bool)$row->af_deleted,
455                (int)$row->af_hidden,
456                (bool)$row->af_global
457            ),
458            $actions,
459            new LastEditInfo(
460                (int)$row->af_user,
461                $row->af_user_text,
462                $row->af_timestamp
463            ),
464            (int)$row->af_id,
465            isset( $row->af_hit_count ) ? (int)$row->af_hit_count : null,
466            isset( $row->af_throttled ) ? (bool)$row->af_throttled : null
467        );
468    }
469
470    private function getAbuseFilterQueryBuilder( IReadableDatabase $dbr ): SelectQueryBuilder {
471        return $dbr->newSelectQueryBuilder()
472            ->select( [
473                'af_id',
474                'af_pattern',
475                'af_timestamp',
476                'af_enabled',
477                'af_comments',
478                'af_public_comments',
479                'af_hidden',
480                'af_hit_count',
481                'af_throttled',
482                'af_deleted',
483                'af_actions',
484                'af_global',
485                'af_group',
486                'af_user' => 'actor_af_user.actor_user',
487                'af_user_text' => 'actor_af_user.actor_name',
488                'af_actor' => 'af_actor'
489            ] )
490            ->from( 'abuse_filter' )
491            ->join( 'actor', 'actor_af_user', 'actor_af_user.actor_id = af_actor' );
492    }
493
494    private function getAbuseFilterHistoryQueryBuilder( IReadableDatabase $dbr ): SelectQueryBuilder {
495        return $dbr->newSelectQueryBuilder()
496            ->select( [
497                'afh_id',
498                'afh_pattern',
499                'afh_timestamp',
500                'afh_filter',
501                'afh_comments',
502                'afh_public_comments',
503                'afh_flags',
504                'afh_actions',
505                'afh_group',
506                'afh_user' => 'actor_afh_user.actor_user',
507                'afh_user_text' => 'actor_afh_user.actor_name',
508                'afh_actor' => 'afh_actor'
509            ] )
510            ->from( 'abuse_filter_history' )
511            ->join( 'actor', 'actor_afh_user', 'actor_afh_user.actor_id = afh_actor' );
512    }
513
514    /**
515     * @param int $filterID
516     * @param bool $global
517     * @return string
518     */
519    private function getCacheKey( int $filterID, bool $global ): string {
520        return GlobalNameUtils::buildGlobalName( $filterID, $global );
521    }
522
523    /**
524     * "Hides" local filters when testing global ones, so that we can use the
525     * local database pretending it's not local.
526     * @codeCoverageIgnore
527     */
528    public function hideLocalFiltersForTesting(): void {
529        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
530            throw new RuntimeException( 'Can only be called in tests' );
531        }
532        $this->localFiltersHiddenForTest = true;
533    }
534}