Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.95% covered (warning)
73.95%
176 / 238
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewList
73.95% covered (warning)
73.95%
176 / 238
25.00% covered (danger)
25.00%
1 / 4
70.72
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 show
74.49% covered (warning)
74.49%
73 / 98
0.00% covered (danger)
0.00%
0 / 1
39.10
 showList
79.31% covered (warning)
79.31%
92 / 116
0.00% covered (danger)
0.00%
0 / 1
10.89
 showStatus
40.91% covered (danger)
40.91%
9 / 22
0.00% covered (danger)
0.00%
0 / 1
4.86
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\View;
4
5use MediaWiki\Context\IContextSource;
6use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
7use MediaWiki\Extension\AbuseFilter\CentralDBManager;
8use MediaWiki\Extension\AbuseFilter\Filter\Flags;
9use MediaWiki\Extension\AbuseFilter\FilterLookup;
10use MediaWiki\Extension\AbuseFilter\FilterProfiler;
11use MediaWiki\Extension\AbuseFilter\Pager\AbuseFilterPager;
12use MediaWiki\Extension\AbuseFilter\Pager\GlobalAbuseFilterPager;
13use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
14use MediaWiki\Html\Html;
15use MediaWiki\HTMLForm\HTMLForm;
16use MediaWiki\Linker\LinkRenderer;
17use MediaWiki\Page\LinkBatchFactory;
18use MediaWiki\Parser\ParserOptions;
19use OOUI;
20use Wikimedia\Rdbms\IConnectionProvider;
21use Wikimedia\StringUtils\StringUtils;
22
23/**
24 * The default view used in Special:AbuseFilter
25 */
26class AbuseFilterViewList extends AbuseFilterView {
27
28    public function __construct(
29        private readonly LinkBatchFactory $linkBatchFactory,
30        private readonly IConnectionProvider $dbProvider,
31        AbuseFilterPermissionManager $afPermManager,
32        private readonly FilterProfiler $filterProfiler,
33        private readonly SpecsFormatter $specsFormatter,
34        private readonly CentralDBManager $centralDBManager,
35        private readonly FilterLookup $filterLookup,
36        IContextSource $context,
37        LinkRenderer $linkRenderer,
38        string $basePageName,
39        array $params
40    ) {
41        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
42        $this->specsFormatter->setMessageLocalizer( $context );
43    }
44
45    /**
46     * Shows the page
47     */
48    public function show() {
49        $out = $this->getOutput();
50        $request = $this->getRequest();
51        $config = $this->getConfig();
52        $performer = $this->getAuthority();
53
54        $out->addWikiMsg( 'abusefilter-intro' );
55        $this->showStatus();
56
57        // New filter button
58        if ( $this->afPermManager->canEdit( $performer ) ) {
59            $out->enableOOUI();
60            $buttons = new OOUI\HorizontalLayout( [
61                'items' => [
62                    new OOUI\ButtonWidget( [
63                        'label' => $this->msg( 'abusefilter-new' )->text(),
64                        'href' => $this->getTitle( 'new' )->getFullURL(),
65                        'flags' => [ 'primary', 'progressive' ],
66                    ] ),
67                    new OOUI\ButtonWidget( [
68                        'label' => $this->msg( 'abusefilter-import-button' )->text(),
69                        'href' => $this->getTitle( 'import' )->getFullURL(),
70                        'flags' => [ 'primary', 'progressive' ],
71                    ] )
72                ]
73            ] );
74            $out->addHTML( (string)$buttons );
75        }
76
77        $conds = [];
78        $deleted = $request->getVal( 'deletedfilters' );
79        $furtherOptions = $request->getArray( 'furtheroptions' ) ?? [];
80        // Backward compatibility with old links
81        if ( $request->getBool( 'hidedisabled' ) ) {
82            $furtherOptions[] = 'hidedisabled';
83        }
84        if ( $request->getBool( 'hideprivate' ) ) {
85            $furtherOptions[] = 'hideprivate';
86        }
87        if ( $request->getBool( 'hidesuppressed' ) ) {
88            $furtherOptions[] = 'hidesuppressed';
89        }
90        $defaultscope = 'all';
91        if ( $config->get( 'AbuseFilterCentralDB' ) !== null
92                && !$config->get( 'AbuseFilterIsCentral' ) ) {
93            // Show on remote wikis as default only local filters
94            $defaultscope = 'local';
95        }
96        $scope = $request->getVal( 'rulescope', $defaultscope );
97
98        $searchEnabled = $this->afPermManager->canViewPrivateFilters( $performer ) && !(
99            $config->get( 'AbuseFilterCentralDB' ) !== null &&
100            !$config->get( 'AbuseFilterIsCentral' ) &&
101            $scope === 'global' );
102
103        if ( $searchEnabled ) {
104            $querypattern = $request->getVal( 'querypattern', '' );
105            $searchmode = $request->getVal( 'searchoption', null );
106            if ( $querypattern === '' ) {
107                // Not specified or empty, that would error out
108                $querypattern = $searchmode = null;
109            }
110        } else {
111            $querypattern = null;
112            $searchmode = null;
113        }
114
115        if ( $deleted === 'show' ) {
116            // Nothing
117        } elseif ( $deleted === 'only' ) {
118            $conds['af_deleted'] = 1;
119        } else {
120            // hide, or anything else.
121            $conds['af_deleted'] = 0;
122            $deleted = 'hide';
123        }
124        if ( in_array( 'hidedisabled', $furtherOptions ) ) {
125            $conds['af_deleted'] = 0;
126            $conds['af_enabled'] = 1;
127        }
128
129        $hidePrivate = in_array( 'hideprivate', $furtherOptions );
130        $hideSuppressed = in_array( 'hidesuppressed', $furtherOptions );
131
132        // Don't show private filters if they've been requested to be hidden
133        if ( $hidePrivate ) {
134            $dbr = $this->dbProvider->getReplicaDatabase();
135            $conds[] = $dbr->bitAnd( 'af_hidden', Flags::FILTER_HIDDEN ) . ' = 0';
136        }
137
138        // Don't show suppressed filters if they've been requested to be hidden
139        // or if we're searching with a query pattern and the user cannot view supressed filters
140        // (this would leak the rules of the filter if permitted).
141        if (
142            ( $querypattern !== null && !$this->afPermManager->canViewSuppressed( $performer ) )
143            || $hideSuppressed
144        ) {
145            $dbr = $this->dbProvider->getReplicaDatabase();
146            $conds[] = $dbr->bitAnd( 'af_hidden', Flags::FILTER_SUPPRESSED ) . ' = 0';
147        }
148
149        if ( $scope === 'local' ) {
150            $conds['af_global'] = 0;
151        } elseif ( $scope === 'global' ) {
152            $conds['af_global'] = 1;
153        }
154
155        if ( $searchmode !== null ) {
156            // Check the search pattern. Filtering the results is done in AbuseFilterPager
157            $error = null;
158            if ( !in_array( $searchmode, [ 'LIKE', 'RLIKE', 'IRLIKE' ] ) ) {
159                $error = 'abusefilter-list-invalid-searchmode';
160            } elseif ( $searchmode !== 'LIKE' && !StringUtils::isValidPCRERegex( "/$querypattern/" ) ) {
161                // @phan-suppress-previous-line SecurityCheck-ReDoS Yes, I know...
162                $error = 'abusefilter-list-regexerror';
163            }
164
165            if ( $error !== null ) {
166                $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
167                $out->addHTML(
168                    Html::rawElement(
169                        'p',
170                        [],
171                        Html::errorBox( $this->msg( $error )->escaped() )
172                    )
173                );
174
175                // Reset the conditions in case of error
176                $conds = [ 'af_deleted' => 0 ];
177                $searchmode = $querypattern = null;
178            }
179
180            // Viewers with the right to view private filters have access to the search
181            // function, which can query against protected filters and potentially expose PII.
182            // Remove protected filters from the query if the user doesn't have the right to search
183            // against them. This allows protected filters to be visible in the general list of
184            // filters at all other times.
185            // Filters with protected variables that have additional restrictions cannot be excluded using SQL
186            // but will be excluded in the AbuseFilterPager.
187            if ( !$this->afPermManager->canViewProtectedVariables( $performer, [] )->isGood() ) {
188                $dbr = $this->dbProvider->getReplicaDatabase();
189                $conds[] = $dbr->bitAnd( 'af_hidden', Flags::FILTER_USES_PROTECTED_VARS ) . ' = 0';
190            }
191        }
192
193        $this->showList(
194            [
195                'deleted' => $deleted,
196                'furtherOptions' => $furtherOptions,
197                'querypattern' => $querypattern,
198                'searchmode' => $searchmode,
199                'scope' => $scope,
200            ],
201            $conds
202        );
203    }
204
205    /**
206     * @param array $optarray
207     * @param array $conds
208     */
209    private function showList( array $optarray, array $conds = [ 'af_deleted' => 0 ] ) {
210        $performer = $this->getAuthority();
211        $config = $this->getConfig();
212        $centralDB = $config->get( 'AbuseFilterCentralDB' );
213        $dbIsCentral = $config->get( 'AbuseFilterIsCentral' );
214        $this->getOutput()->addHTML(
215            Html::rawElement( 'h2', [], $this->msg( 'abusefilter-list' )->parse() )
216        );
217
218        $deleted = $optarray['deleted'];
219        $furtherOptions = $optarray['furtherOptions'];
220        $scope = $optarray['scope'];
221        $querypattern = $optarray['querypattern'];
222        $searchmode = $optarray['searchmode'];
223
224        if ( $centralDB !== null && !$dbIsCentral && $scope === 'global' ) {
225            // TODO: remove the circular dependency
226            $pager = new GlobalAbuseFilterPager(
227                $this,
228                $this->linkRenderer,
229                $this->afPermManager,
230                $this->specsFormatter,
231                $this->centralDBManager,
232                $this->filterLookup,
233                $conds
234            );
235        } else {
236            $pager = new AbuseFilterPager(
237                $this,
238                $this->linkRenderer,
239                $this->linkBatchFactory,
240                $this->afPermManager,
241                $this->specsFormatter,
242                $this->filterLookup,
243                $conds,
244                $querypattern,
245                $searchmode
246            );
247        }
248
249        // Options form
250        $formDescriptor = [];
251
252        if ( $centralDB !== null ) {
253            $optionsMsg = [
254                'abusefilter-list-options-scope-local' => 'local',
255                'abusefilter-list-options-scope-global' => 'global',
256            ];
257            if ( $dbIsCentral ) {
258                // For central wiki: add third scope option
259                $optionsMsg['abusefilter-list-options-scope-all'] = 'all';
260            }
261            $formDescriptor['rulescope'] = [
262                'name' => 'rulescope',
263                'type' => 'radio',
264                'flatlist' => true,
265                'label-message' => 'abusefilter-list-options-scope',
266                'options-messages' => $optionsMsg,
267                'default' => $scope,
268            ];
269        }
270
271        $formDescriptor['deletedfilters'] = [
272            'name' => 'deletedfilters',
273            'type' => 'radio',
274            'flatlist' => true,
275            'label-message' => 'abusefilter-list-options-deleted',
276            'options-messages' => [
277                'abusefilter-list-options-deleted-show' => 'show',
278                'abusefilter-list-options-deleted-hide' => 'hide',
279                'abusefilter-list-options-deleted-only' => 'only',
280            ],
281            'default' => $deleted,
282        ];
283
284        $formDescriptor['furtheroptions'] = [
285            'name' => 'furtheroptions',
286            'type' => 'multiselect',
287            'label-message' => 'abusefilter-list-options-further-options',
288            'flatlist' => true,
289            'options' => [
290                $this->msg( 'abusefilter-list-options-hidesuppressed' )->parse() => 'hidesuppressed',
291                $this->msg( 'abusefilter-list-options-hideprivate' )->parse() => 'hideprivate',
292                $this->msg( 'abusefilter-list-options-hidedisabled' )->parse() => 'hidedisabled',
293            ],
294            'default' => $furtherOptions
295        ];
296
297        if ( $this->afPermManager->canViewPrivateFilters( $performer ) ) {
298            $globalEnabled = $centralDB !== null && !$dbIsCentral;
299            $formDescriptor['querypattern'] = [
300                'name' => 'querypattern',
301                'type' => 'text',
302                'hide-if' => $globalEnabled ? [ '===', 'rulescope', 'global' ] : [],
303                'label-message' => 'abusefilter-list-options-searchfield',
304                'placeholder' => $this->msg( 'abusefilter-list-options-searchpattern' )->text(),
305                'default' => $querypattern
306            ];
307
308            $formDescriptor['searchoption'] = [
309                'name' => 'searchoption',
310                'type' => 'radio',
311                'flatlist' => true,
312                'label-message' => 'abusefilter-list-options-searchoptions',
313                'hide-if' => $globalEnabled ?
314                    [ 'OR', [ '===', 'querypattern', '' ], $formDescriptor['querypattern']['hide-if'] ] :
315                    [ '===', 'querypattern', '' ],
316                'options-messages' => [
317                    'abusefilter-list-options-search-like' => 'LIKE',
318                    'abusefilter-list-options-search-rlike' => 'RLIKE',
319                    'abusefilter-list-options-search-irlike' => 'IRLIKE',
320                ],
321                'default' => $searchmode
322            ];
323        }
324
325        $formDescriptor['limit'] = [
326            'name' => 'limit',
327            'type' => 'select',
328            'label-message' => 'abusefilter-list-limit',
329            'options' => $pager->getLimitSelectList(),
330            'default' => $pager->getLimit(),
331        ];
332
333        HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
334            ->setTitle( $this->getTitle() )
335            ->setCollapsibleOptions( true )
336            ->setWrapperLegendMsg( 'abusefilter-list-options' )
337            ->setSubmitTextMsg( 'abusefilter-list-options-submit' )
338            ->setMethod( 'get' )
339            ->prepareForm()
340            ->displayForm( false );
341
342        $this->getOutput()->addParserOutputContent(
343            $pager->getFullOutput(),
344            ParserOptions::newFromContext( $this->getContext() )
345        );
346    }
347
348    /**
349     * Generates a summary of filter activity using the internal statistics.
350     */
351    public function showStatus() {
352        $totalCount = 0;
353        $matchCount = 0;
354        $overflowCount = 0;
355        foreach ( $this->getConfig()->get( 'AbuseFilterValidGroups' ) as $group ) {
356            $profile = $this->filterProfiler->getGroupProfile( $group );
357            $totalCount += $profile[ 'total' ];
358            $overflowCount += $profile[ 'overflow' ];
359            $matchCount += $profile[ 'matches' ];
360        }
361
362        if ( $totalCount > 0 ) {
363            $overflowPercent = round( 100 * $overflowCount / $totalCount, 2 );
364            $matchPercent = round( 100 * $matchCount / $totalCount, 2 );
365
366            $status = $this->msg( 'abusefilter-status' )
367                ->numParams(
368                    $totalCount,
369                    $overflowCount,
370                    $overflowPercent,
371                    $this->getConfig()->get( 'AbuseFilterConditionLimit' ),
372                    $matchCount,
373                    $matchPercent
374                )->parse();
375
376            $status = Html::rawElement( 'p', [ 'class' => 'mw-abusefilter-status' ], $status );
377            $this->getOutput()->addHTML( $status );
378        }
379    }
380}