Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.08% covered (warning)
89.08%
106 / 119
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterRunner
89.08% covered (warning)
89.08%
106 / 119
55.56% covered (warning)
55.56%
5 / 9
26.88
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 init
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 run
90.20% covered (success)
90.20%
46 / 51
0.00% covered (danger)
0.00%
0 / 1
8.06
 runForStash
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
3.08
 checkAllFiltersInternal
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 checkAllFilters
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 checkFilter
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 profileExecution
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 updateEmergencyCache
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use InvalidArgumentException;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\Deferred\DeferredUpdates;
8use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
9use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory;
10use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
11use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
12use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
13use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
14use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer;
15use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
16use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
17use MediaWiki\Status\Status;
18use MediaWiki\Title\Title;
19use MediaWiki\User\User;
20use Psr\Log\LoggerInterface;
21
22/**
23 * This class contains the logic for executing abuse filters and their actions. The entry points are
24 * run() and runForStash(). Note that run() can only be executed once on a given instance.
25 * @internal Not stable yet
26 */
27class FilterRunner {
28    public const CONSTRUCTOR_OPTIONS = [
29        'AbuseFilterValidGroups',
30        'AbuseFilterCentralDB',
31        'AbuseFilterIsCentral',
32        'AbuseFilterConditionLimit',
33    ];
34
35    /**
36     * @var FilterEvaluator
37     */
38    private $ruleChecker;
39
40    /**
41     * @var string The action we're filtering
42     */
43    private $action;
44
45    /**
46     * @throws InvalidArgumentException If $group is invalid or the 'action' variable is unset
47     */
48    public function __construct(
49        private readonly AbuseFilterHookRunner $hookRunner,
50        private readonly FilterProfiler $filterProfiler,
51        private readonly ChangeTagger $changeTagger,
52        private readonly FilterLookup $filterLookup,
53        private readonly RuleCheckerFactory $ruleCheckerFactory,
54        private readonly ConsequencesExecutorFactory $consExecutorFactory,
55        private readonly AbuseLoggerFactory $abuseLoggerFactory,
56        private readonly VariablesManager $varManager,
57        private readonly EmergencyCache $emergencyCache,
58        private readonly array $watchers,
59        private readonly EditStashCache $stashCache,
60        private readonly LoggerInterface $logger,
61        private readonly ServiceOptions $options,
62        private readonly User $user,
63        private readonly Title $title,
64        private VariableHolder $vars,
65        private readonly string $group
66    ) {
67        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
68        if ( !in_array( $group, $options->get( 'AbuseFilterValidGroups' ), true ) ) {
69            throw new InvalidArgumentException( "Group $group is not a valid group" );
70        }
71
72        if ( !$vars->varIsSet( 'action' ) ) {
73            throw new InvalidArgumentException( "The 'action' variable is not set." );
74        }
75
76        $this->action = $vars->getComputedVariable( 'action' )->toString();
77    }
78
79    /**
80     * Inits variables and parser right before running
81     */
82    private function init() {
83        // Add vars from extensions
84        $this->hookRunner->onAbuseFilterAlterVariables(
85            $this->vars,
86            $this->title,
87            $this->user
88        );
89        $this->ruleChecker = $this->ruleCheckerFactory->newRuleChecker( $this->vars );
90    }
91
92    /**
93     * The main entry point of this class. This method runs all filters and takes their consequences.
94     *
95     * @param bool $allowStash Whether we are allowed to check the cache to see if there's a cached
96     *  result of a previous execution for the same edit.
97     * @return Status Good if no action has been taken, a fatal otherwise.
98     */
99    public function run( $allowStash = true ): Status {
100        $this->init();
101
102        $skipReasons = [];
103        $shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
104            $this->vars, $this->title, $this->user, $skipReasons
105        );
106        if ( !$shouldFilter ) {
107            $this->logger->info(
108                'Skipping action {action}. Reasons provided: {reasons}',
109                [ 'action' => $this->action, 'reasons' => implode( ', ', $skipReasons ) ]
110            );
111            return Status::newGood();
112        }
113
114        $useStash = $allowStash && $this->action === 'edit';
115
116        $runnerData = null;
117        if ( $useStash ) {
118            $cacheData = $this->stashCache->seek( $this->vars );
119            if ( $cacheData !== false ) {
120                // Use cached vars (T176291) and profiling data (T191430)
121                $this->vars = VariableHolder::newFromArray( $cacheData['vars'] );
122                $runnerData = RunnerData::fromArray( $cacheData['data'] );
123            }
124        }
125
126        $runnerData ??= $this->checkAllFiltersInternal();
127
128        DeferredUpdates::addCallableUpdate( function () use ( $runnerData ) {
129            $this->profileExecution( $runnerData );
130            $this->updateEmergencyCache( $runnerData->getMatchesMap() );
131        } );
132
133        // TODO: inject the action specifier to avoid this
134        $accountname = $this->varManager->getVar(
135            $this->vars,
136            'accountname',
137            VariablesManager::GET_BC
138        )->toNative();
139        $spec = new ActionSpecifier(
140            $this->action,
141            $this->title,
142            $this->user,
143            $this->user->getRequest()->getIP(),
144            $accountname
145        );
146
147        // Tag the action if the condition limit was hit
148        if ( $runnerData->getTotalConditions() > $this->options->get( 'AbuseFilterConditionLimit' ) ) {
149            $this->changeTagger->addConditionsLimitTag( $spec );
150        }
151
152        $matchedFilters = $runnerData->getMatchedFilters();
153
154        if ( count( $matchedFilters ) === 0 ) {
155            return Status::newGood();
156        }
157
158        $executor = $this->consExecutorFactory->newExecutor( $spec, $this->vars );
159        $status = $executor->executeFilterActions( $matchedFilters );
160        $actionsTaken = $status->getValue();
161
162        // Note, it's important that we create an AbuseLogger now, after all lazy-loaded variables
163        // requested by active filters have been computed
164        $abuseLogger = $this->abuseLoggerFactory->newLogger( $this->title, $this->user, $this->vars );
165        [
166            'local' => $loggedLocalFilters,
167            'global' => $loggedGlobalFilters
168        ] = $abuseLogger->addLogEntries( $actionsTaken );
169
170        foreach ( $this->watchers as $watcher ) {
171            $watcher->run( $loggedLocalFilters, $loggedGlobalFilters, $this->group );
172        }
173
174        return $status;
175    }
176
177    /**
178     * Similar to run(), but runs in "stash" mode, which means filters are executed, no actions are
179     *  taken, and the result is saved in cache to be later reused. This can only be used for edits,
180     *  and not doing so will throw.
181     *
182     * @throws InvalidArgumentException
183     * @return Status Always a good status, since we're only saving data.
184     */
185    public function runForStash(): Status {
186        if ( $this->action !== 'edit' ) {
187            throw new InvalidArgumentException(
188                __METHOD__ . " can only be called for edits, called for action {$this->action}."
189            );
190        }
191
192        $this->init();
193
194        $skipReasons = [];
195        $shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
196            $this->vars, $this->title, $this->user, $skipReasons
197        );
198        if ( !$shouldFilter ) {
199            // Don't log it yet
200            return Status::newGood();
201        }
202
203        // XXX: We need a copy here because the cache key is computed
204        // from the variables, but some variables can be loaded lazily
205        // which would store the data with a key distinct from that
206        // computed by seek() in ::run().
207        // TODO: Find better way to generate the cache key.
208        $origVars = clone $this->vars;
209
210        $runnerData = $this->checkAllFiltersInternal();
211        // Save the filter stash result and do nothing further
212        $cacheData = [
213            'vars' => $this->varManager->dumpAllVars( $this->vars ),
214            'data' => $runnerData->toArray(),
215        ];
216
217        $this->stashCache->store( $origVars, $cacheData );
218
219        return Status::newGood();
220    }
221
222    /**
223     * Run all filters and return information about matches and profiling
224     */
225    private function checkAllFiltersInternal(): RunnerData {
226        // Ensure there's no extra time leftover
227        LazyVariableComputer::$profilingExtraTime = 0;
228
229        $data = new RunnerData();
230
231        foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, false ) as $filter ) {
232            [ $status, $timeTaken ] = $this->checkFilter( $filter );
233            $data->record( $filter->getID(), false, $status, $timeTaken );
234        }
235
236        if ( $this->options->get( 'AbuseFilterCentralDB' ) && !$this->options->get( 'AbuseFilterIsCentral' ) ) {
237            foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, true ) as $filter ) {
238                [ $status, $timeTaken ] = $this->checkFilter( $filter, true );
239                $data->record( $filter->getID(), true, $status, $timeTaken );
240            }
241        }
242
243        return $data;
244    }
245
246    /**
247     * Returns an associative array of filters which were tripped
248     *
249     * @internal BC method
250     * @return bool[] Map of (filter ID => bool)
251     */
252    public function checkAllFilters(): array {
253        $this->init();
254        return $this->checkAllFiltersInternal()->getMatchesMap();
255    }
256
257    /**
258     * Check the conditions of a single filter, and profile it
259     *
260     * @param ExistingFilter $filter
261     * @param bool $global
262     * @return array [ status, time taken ]
263     * @phan-return array{0:\MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerStatus,1:float}
264     */
265    private function checkFilter( ExistingFilter $filter, bool $global = false ): array {
266        $filterName = GlobalNameUtils::buildGlobalName( $filter->getID(), $global );
267
268        $startTime = microtime( true );
269        $origExtraTime = LazyVariableComputer::$profilingExtraTime;
270
271        $status = $this->ruleChecker->checkConditions( $filter->getRules(), $filterName );
272
273        $actualExtra = LazyVariableComputer::$profilingExtraTime - $origExtraTime;
274        $timeTaken = 1000 * ( microtime( true ) - $startTime - $actualExtra );
275
276        return [ $status, $timeTaken ];
277    }
278
279    private function profileExecution( RunnerData $data ) {
280        $allFilters = $data->getAllFilters();
281        $matchedFilters = $data->getMatchedFilters();
282        $this->filterProfiler->recordRuntimeProfilingResult(
283            count( $allFilters ),
284            $data->getTotalConditions(),
285            $data->getTotalRuntime()
286        );
287        $this->filterProfiler->recordPerFilterProfiling( $this->title, $data->getProfilingData() );
288        $this->filterProfiler->recordStats(
289            $this->group,
290            $data->getTotalConditions(),
291            $data->getTotalRuntime(),
292            (bool)$matchedFilters
293        );
294    }
295
296    /**
297     * @param bool[] $matches
298     */
299    private function updateEmergencyCache( array $matches ): void {
300        $filters = $this->emergencyCache->getFiltersToCheckInGroup( $this->group );
301        foreach ( $filters as $filter ) {
302            if ( array_key_exists( "$filter", $matches ) ) {
303                $this->emergencyCache->incrementForFilter( $filter, $matches["$filter"] );
304            }
305        }
306    }
307}