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