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