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