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