Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.67% covered (success)
91.67%
143 / 156
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConsequencesExecutor
91.67% covered (success)
91.67%
143 / 156
76.92% covered (warning)
76.92%
10 / 13
57.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 executeFilterActions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getActualConsequencesToExecute
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 replaceLegacyParameters
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
10.50
 specializeParameters
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 removeForbiddenConsequences
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 replaceArraysWithConsequences
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 applyConsequenceDisablers
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 deduplicateConsequences
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 removeRedundantConsequences
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 actionsParamsToConsequence
85.71% covered (warning)
85.71%
36 / 42
0.00% covered (danger)
0.00%
0 / 1
11.35
 takeConsequenceAction
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 buildStatus
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Consequences;
4
5use MediaWiki\Block\BlockUser;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\Extension\AbuseFilter\ActionSpecifier;
8use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Block;
9use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Consequence;
10use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\ConsequencesDisablerConsequence;
11use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\HookAborterConsequence;
12use MediaWiki\Extension\AbuseFilter\FilterLookup;
13use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
14use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
15use MediaWiki\Status\Status;
16use MediaWiki\User\UserIdentityUtils;
17use Psr\Log\LoggerInterface;
18
19class ConsequencesExecutor {
20    public const CONSTRUCTOR_OPTIONS = [
21        'AbuseFilterLocallyDisabledGlobalActions',
22        'AbuseFilterBlockDuration',
23        'AbuseFilterAnonBlockDuration',
24        'AbuseFilterBlockAutopromoteDuration',
25    ];
26
27    /** @var ConsequencesLookup */
28    private $consLookup;
29    /** @var ConsequencesFactory */
30    private $consFactory;
31    /** @var ConsequencesRegistry */
32    private $consRegistry;
33    /** @var FilterLookup */
34    private $filterLookup;
35    /** @var LoggerInterface */
36    private $logger;
37    /** @var UserIdentityUtils */
38    private $userIdentityUtils;
39    /** @var ServiceOptions */
40    private $options;
41    /** @var ActionSpecifier */
42    private $specifier;
43    /** @var VariableHolder */
44    private $vars;
45
46    /**
47     * @param ConsequencesLookup $consLookup
48     * @param ConsequencesFactory $consFactory
49     * @param ConsequencesRegistry $consRegistry
50     * @param FilterLookup $filterLookup
51     * @param LoggerInterface $logger
52     * @param UserIdentityUtils $userIdentityUtils
53     * @param ServiceOptions $options
54     * @param ActionSpecifier $specifier
55     * @param VariableHolder $vars
56     */
57    public function __construct(
58        ConsequencesLookup $consLookup,
59        ConsequencesFactory $consFactory,
60        ConsequencesRegistry $consRegistry,
61        FilterLookup $filterLookup,
62        LoggerInterface $logger,
63        UserIdentityUtils $userIdentityUtils,
64        ServiceOptions $options,
65        ActionSpecifier $specifier,
66        VariableHolder $vars
67    ) {
68        $this->consLookup = $consLookup;
69        $this->consFactory = $consFactory;
70        $this->consRegistry = $consRegistry;
71        $this->filterLookup = $filterLookup;
72        $this->logger = $logger;
73        $this->userIdentityUtils = $userIdentityUtils;
74        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
75        $this->options = $options;
76        $this->specifier = $specifier;
77        $this->vars = $vars;
78    }
79
80    /**
81     * Executes a set of actions.
82     *
83     * @param string[] $filters
84     * @return Status returns the operation's status. $status->isOK() will return true if
85     *         there were no actions taken, false otherwise. $status->getValue() will return
86     *         an array listing the actions taken. $status->getErrors() etc. will provide
87     *         the errors and warnings to be shown to the user to explain the actions.
88     */
89    public function executeFilterActions( array $filters ): Status {
90        $actionsToTake = $this->getActualConsequencesToExecute( $filters );
91        $actionsTaken = array_fill_keys( $filters, [] );
92
93        $messages = [];
94        foreach ( $actionsToTake as $filter => $actions ) {
95            foreach ( $actions as $action => $info ) {
96                [ $executed, $newMsg ] = $this->takeConsequenceAction( $info );
97
98                if ( $newMsg !== null ) {
99                    $messages[] = $newMsg;
100                }
101                if ( $executed ) {
102                    $actionsTaken[$filter][] = $action;
103                }
104            }
105        }
106
107        return $this->buildStatus( $actionsTaken, $messages );
108    }
109
110    /**
111     * @param string[] $filters
112     * @return Consequence[][]
113     * @internal
114     */
115    public function getActualConsequencesToExecute( array $filters ): array {
116        $rawConsParamsByFilter = $this->consLookup->getConsequencesForFilters( $filters );
117        $consParamsByFilter = $this->replaceLegacyParameters( $rawConsParamsByFilter );
118        $specializedConsParams = $this->specializeParameters( $consParamsByFilter );
119        $allowedConsParams = $this->removeForbiddenConsequences( $specializedConsParams );
120
121        $consequences = $this->replaceArraysWithConsequences( $allowedConsParams );
122        $actualConsequences = $this->applyConsequenceDisablers( $consequences );
123        $deduplicatedConsequences = $this->deduplicateConsequences( $actualConsequences );
124        return $this->removeRedundantConsequences( $deduplicatedConsequences );
125    }
126
127    /**
128     * Update parameters for all consequences, making sure that they match the currently expected format
129     * (e.g., 'block' didn't use to have expiries).
130     *
131     * @param array[] $consParams
132     * @return array[]
133     */
134    private function replaceLegacyParameters( array $consParams ): array {
135        $registeredBlockDuration = $this->options->get( 'AbuseFilterBlockDuration' );
136        $anonBlockDuration = $this->options->get( 'AbuseFilterAnonBlockDuration' ) ?? $registeredBlockDuration;
137        foreach ( $consParams as $filter => $actions ) {
138            foreach ( $actions as $name => $parameters ) {
139                if ( $name === 'block' && count( $parameters ) !== 3 ) {
140                    // Old type with fixed expiry
141                    $blockTalk = in_array( 'blocktalk', $parameters, true );
142
143                    $consParams[$filter][$name] = [
144                        $blockTalk ? 'blocktalk' : 'noTalkBlockSet',
145                        $anonBlockDuration,
146                        $registeredBlockDuration
147                    ];
148                }
149            }
150        }
151
152        return $consParams;
153    }
154
155    /**
156     * For every consequence, keep only the parameters that are relevant for this specific action being filtered.
157     * For instance, choose between anon expiry and registered expiry for blocks.
158     *
159     * @param array[] $consParams
160     * @return array[]
161     */
162    private function specializeParameters( array $consParams ): array {
163        $user = $this->specifier->getUser();
164        $isNamed = $this->userIdentityUtils->isNamed( $user );
165        foreach ( $consParams as $filter => $actions ) {
166            foreach ( $actions as $name => $parameters ) {
167                if ( $name === 'block' ) {
168                    $consParams[$filter][$name] = [
169                        'expiry' => $isNamed ? $parameters[2] : $parameters[1],
170                        'blocktalk' => $parameters[0] === 'blocktalk'
171                    ];
172                }
173            }
174        }
175
176        return $consParams;
177    }
178
179    /**
180     * Removes any consequence that cannot be executed. For instance, remove locally disabled
181     * consequences for global filters.
182     *
183     * @param array[] $consParams
184     * @return array[]
185     */
186    private function removeForbiddenConsequences( array $consParams ): array {
187        $locallyDisabledActions = $this->options->get( 'AbuseFilterLocallyDisabledGlobalActions' );
188        foreach ( $consParams as $filter => $actions ) {
189            $isGlobalFilter = GlobalNameUtils::splitGlobalName( $filter )[1];
190            if ( $isGlobalFilter ) {
191                $consParams[$filter] = array_diff_key(
192                    $actions,
193                    array_filter( $locallyDisabledActions )
194                );
195            }
196        }
197
198        return $consParams;
199    }
200
201    /**
202     * Converts all consequence specifiers to Consequence objects.
203     *
204     * @param array[] $actionsByFilter
205     * @return Consequence[][]
206     */
207    private function replaceArraysWithConsequences( array $actionsByFilter ): array {
208        $ret = [];
209        foreach ( $actionsByFilter as $filter => $actions ) {
210            $ret[$filter] = [];
211            foreach ( $actions as $name => $parameters ) {
212                $cons = $this->actionsParamsToConsequence( $name, $parameters, $filter );
213                if ( $cons !== null ) {
214                    $ret[$filter][$name] = $cons;
215                }
216            }
217        }
218
219        return $ret;
220    }
221
222    /**
223     * Pre-check any consequences-disabler consequence and remove any further actions prevented by them. Specifically:
224     * - For every filter with "throttle" enabled, remove other actions if the throttle counter hasn't been reached
225     * - For every filter with "warn" enabled, remove other actions if the warning hasn't been shown
226     *
227     * @param Consequence[][] $consequencesByFilter
228     * @return Consequence[][]
229     */
230    private function applyConsequenceDisablers( array $consequencesByFilter ): array {
231        foreach ( $consequencesByFilter as $filter => $actions ) {
232            /** @var ConsequencesDisablerConsequence[] $consequenceDisablers */
233            $consequenceDisablers = array_filter( $actions, static function ( $el ) {
234                return $el instanceof ConsequencesDisablerConsequence;
235            } );
236            '@phan-var ConsequencesDisablerConsequence[] $consequenceDisablers';
237            uasort(
238                $consequenceDisablers,
239                static function ( ConsequencesDisablerConsequence $x, ConsequencesDisablerConsequence $y ) {
240                    return $x->getSort() - $y->getSort();
241                }
242            );
243            foreach ( $consequenceDisablers as $name => $consequence ) {
244                if ( $consequence->shouldDisableOtherConsequences() ) {
245                    $consequencesByFilter[$filter] = [ $name => $consequence ];
246                    continue 2;
247                }
248            }
249        }
250
251        return $consequencesByFilter;
252    }
253
254    /**
255     * Removes duplicated consequences. For instance, this only keeps the longest of all blocks.
256     *
257     * @param Consequence[][] $consByFilter
258     * @return Consequence[][]
259     */
260    private function deduplicateConsequences( array $consByFilter ): array {
261        // Keep track of the longest block
262        $maxBlock = [ 'id' => null, 'expiry' => -1, 'cons' => null ];
263
264        foreach ( $consByFilter as $filter => $actions ) {
265            foreach ( $actions as $name => $cons ) {
266                if ( $name === 'block' ) {
267                    /** @var Block $cons */
268                    '@phan-var Block $cons';
269                    $expiry = $cons->getExpiry();
270                    $parsedExpiry = BlockUser::parseExpiryInput( $expiry );
271                    if (
272                        $maxBlock['expiry'] === -1 ||
273                        $parsedExpiry > BlockUser::parseExpiryInput( $maxBlock['expiry'] )
274                    ) {
275                        $maxBlock = [
276                            'id' => $filter,
277                            'expiry' => $expiry,
278                            'cons' => $cons
279                        ];
280                    }
281                    // We'll re-add it later
282                    unset( $consByFilter[$filter]['block'] );
283                }
284            }
285        }
286
287        if ( $maxBlock['id'] !== null ) {
288            $consByFilter[$maxBlock['id']]['block'] = $maxBlock['cons'];
289        }
290
291        return $consByFilter;
292    }
293
294    /**
295     * Remove redundant consequences, e.g., remove "disallow" if a dangerous action will be executed
296     * TODO: Is this wanted, especially now that we have custom disallow messages?
297     *
298     * @param Consequence[][] $consByFilter
299     * @return Consequence[][]
300     */
301    private function removeRedundantConsequences( array $consByFilter ): array {
302        $dangerousActions = $this->consRegistry->getDangerousActionNames();
303
304        foreach ( $consByFilter as $filter => $actions ) {
305            // Don't show the disallow message if a blocking action is executed
306            if (
307                isset( $actions['disallow'] ) &&
308                array_intersect( array_keys( $actions ), $dangerousActions )
309            ) {
310                unset( $consByFilter[$filter]['disallow'] );
311            }
312        }
313
314        return $consByFilter;
315    }
316
317    /**
318     * @param string $actionName
319     * @param array $rawParams
320     * @param int|string $filter
321     * @return Consequence|null
322     */
323    private function actionsParamsToConsequence( string $actionName, array $rawParams, $filter ): ?Consequence {
324        [ $filterID, $isGlobalFilter ] = GlobalNameUtils::splitGlobalName( $filter );
325        $filterObj = $this->filterLookup->getFilter( $filterID, $isGlobalFilter );
326
327        $baseConsParams = new Parameters(
328            $filterObj,
329            $isGlobalFilter,
330            $this->specifier
331        );
332
333        switch ( $actionName ) {
334            case 'throttle':
335                $throttleId = array_shift( $rawParams );
336                [ $rateCount, $ratePeriod ] = explode( ',', array_shift( $rawParams ) );
337
338                $throttleParams = [
339                    'id' => $throttleId,
340                    'count' => (int)$rateCount,
341                    'period' => (int)$ratePeriod,
342                    'groups' => $rawParams,
343                    'global' => $isGlobalFilter
344                ];
345                return $this->consFactory->newThrottle( $baseConsParams, $throttleParams );
346            case 'warn':
347                return $this->consFactory->newWarn( $baseConsParams, $rawParams[0] ?? 'abusefilter-warning' );
348            case 'disallow':
349                return $this->consFactory->newDisallow( $baseConsParams, $rawParams[0] ?? 'abusefilter-disallowed' );
350            case 'rangeblock':
351                return $this->consFactory->newRangeBlock( $baseConsParams, '1 week' );
352            case 'degroup':
353                return $this->consFactory->newDegroup( $baseConsParams, $this->vars );
354            case 'blockautopromote':
355                $duration = $this->options->get( 'AbuseFilterBlockAutopromoteDuration' ) * 86400;
356                return $this->consFactory->newBlockAutopromote( $baseConsParams, $duration );
357            case 'block':
358                return $this->consFactory->newBlock(
359                    $baseConsParams,
360                    $rawParams['expiry'],
361                    $rawParams['blocktalk']
362                );
363            case 'tag':
364                return $this->consFactory->newTag( $baseConsParams, $rawParams );
365            default:
366                if ( array_key_exists( $actionName, $this->consRegistry->getCustomActions() ) ) {
367                    $callback = $this->consRegistry->getCustomActions()[$actionName];
368                    return $callback( $baseConsParams, $rawParams );
369                } else {
370                    $this->logger->warning( "Unrecognised action $actionName" );
371                    return null;
372                }
373        }
374    }
375
376    /**
377     * @param Consequence $consequence
378     * @return array [ Executed (bool), Message (?array) ] The message is given as an array
379     *   containing the message key followed by any message parameters.
380     * @todo Improve return value
381     */
382    private function takeConsequenceAction( Consequence $consequence ): array {
383        $res = $consequence->execute();
384        if ( $res && $consequence instanceof HookAborterConsequence ) {
385            $message = $consequence->getMessage();
386        }
387
388        return [ $res, $message ?? null ];
389    }
390
391    /**
392     * Constructs a Status object as returned by executeFilterActions() from the list of
393     * actions taken and the corresponding list of messages.
394     *
395     * @param array[] $actionsTaken associative array mapping each filter to the list if
396     *                actions taken because of that filter.
397     * @param array[] $messages a list of arrays, where each array contains a message key
398     *                followed by any message parameters.
399     * @phan-param non-empty-array[] $messages
400     *
401     * @return Status
402     */
403    private function buildStatus( array $actionsTaken, array $messages ): Status {
404        $status = Status::newGood( $actionsTaken );
405
406        foreach ( $messages as $msg ) {
407            $status->fatal( ...$msg );
408        }
409
410        return $status;
411    }
412}