Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.00% covered (warning)
60.00%
108 / 180
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewTestBatch
60.00% covered (warning)
60.00%
108 / 180
50.00% covered (danger)
50.00%
2 / 4
78.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 show
97.80% covered (success)
97.80%
89 / 91
0.00% covered (danger)
0.00%
0 / 1
4
 doTest
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 1
342
 loadParameters
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\View;
4
5use LogEventsList;
6use LogPage;
7use MediaWiki\Context\IContextSource;
8use MediaWiki\Extension\AbuseFilter\AbuseFilterChangesList;
9use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
10use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
11use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxField;
12use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
13use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
14use MediaWiki\Html\Html;
15use MediaWiki\HTMLForm\HTMLForm;
16use MediaWiki\Linker\LinkRenderer;
17use MediaWiki\Revision\RevisionRecord;
18use MediaWiki\Title\Title;
19use RecentChange;
20use Wikimedia\Rdbms\LBFactory;
21use Wikimedia\Rdbms\SelectQueryBuilder;
22
23class AbuseFilterViewTestBatch extends AbuseFilterView {
24    /**
25     * @var int The limit of changes to test, hard coded for now
26     */
27    private static $mChangeLimit = 100;
28
29    /**
30     * @var LBFactory
31     */
32    private $lbFactory;
33    /**
34     * @var string The text of the rule to test changes against
35     */
36    private $testPattern;
37    /**
38     * @var EditBoxBuilderFactory
39     */
40    private $boxBuilderFactory;
41    /**
42     * @var RuleCheckerFactory
43     */
44    private $ruleCheckerFactory;
45    /**
46     * @var VariableGeneratorFactory
47     */
48    private $varGeneratorFactory;
49
50    /**
51     * @param LBFactory $lbFactory
52     * @param AbuseFilterPermissionManager $afPermManager
53     * @param EditBoxBuilderFactory $boxBuilderFactory
54     * @param RuleCheckerFactory $ruleCheckerFactory
55     * @param VariableGeneratorFactory $varGeneratorFactory
56     * @param IContextSource $context
57     * @param LinkRenderer $linkRenderer
58     * @param string $basePageName
59     * @param array $params
60     */
61    public function __construct(
62        LBFactory $lbFactory,
63        AbuseFilterPermissionManager $afPermManager,
64        EditBoxBuilderFactory $boxBuilderFactory,
65        RuleCheckerFactory $ruleCheckerFactory,
66        VariableGeneratorFactory $varGeneratorFactory,
67        IContextSource $context,
68        LinkRenderer $linkRenderer,
69        string $basePageName,
70        array $params
71    ) {
72        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
73        $this->lbFactory = $lbFactory;
74        $this->boxBuilderFactory = $boxBuilderFactory;
75        $this->ruleCheckerFactory = $ruleCheckerFactory;
76        $this->varGeneratorFactory = $varGeneratorFactory;
77    }
78
79    /**
80     * Shows the page
81     */
82    public function show() {
83        $out = $this->getOutput();
84
85        if ( !$this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
86            // TODO: the message still refers to the old rights
87            $out->addWikiMsg( 'abusefilter-mustviewprivateoredit' );
88            return;
89        }
90
91        $this->loadParameters();
92
93        // Check if a loaded test pattern uses protected variables and if the user has the right
94        // to view protected variables. If they don't and protected variables are present, unset
95        // the test pattern to avoid leaking PII and notify the user.
96        // This is done as early as possible so that a filter with PII the user cannot access is
97        // never loaded.
98        if ( $this->testPattern !== '' ) {
99            $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
100            $usedVars = $ruleChecker->getUsedVars( $this->testPattern );
101            if ( $this->afPermManager->getForbiddenVariables( $this->getAuthority(), $usedVars ) ) {
102                $this->testPattern = '';
103                $out->addHtml(
104                    Html::errorBox( $this->msg( 'abusefilter-test-protectedvarerr' )->parse() )
105                );
106            }
107        }
108
109        $out->setPageTitleMsg( $this->msg( 'abusefilter-test' ) );
110        $out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
111        $out->addWikiMsg( 'abusefilter-test-intro', self::$mChangeLimit );
112        $out->enableOOUI();
113
114        $boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder( $this, $this->getAuthority(), $out );
115
116        $rulesFields = [
117            'rules' => [
118                'section' => 'abusefilter-test-rules-section',
119                'class' => EditBoxField::class,
120                'html' => $boxBuilder->buildEditBox(
121                    $this->testPattern,
122                    true,
123                    true,
124                    false
125                ) . $this->buildFilterLoader()
126            ]
127        ];
128
129        $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
130        $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge );
131        $max = wfTimestampNow();
132
133        $optionsFields = [
134            'TestAction' => [
135                'type' => 'select',
136                'label-message' => 'abusefilter-test-action',
137                'options-messages' => [
138                    'abusefilter-test-search-type-all' => '0',
139                    'abusefilter-test-search-type-edit' => 'edit',
140                    'abusefilter-test-search-type-move' => 'move',
141                    'abusefilter-test-search-type-delete' => 'delete',
142                    'abusefilter-test-search-type-createaccount' => 'createaccount',
143                    'abusefilter-test-search-type-upload' => 'upload'
144                ],
145            ],
146            'TestUser' => [
147                'type' => 'user',
148                'exists' => true,
149                'ipallowed' => true,
150                'required' => false,
151                'label-message' => 'abusefilter-test-user',
152            ],
153            'ExcludeBots' => [
154                'type' => 'check',
155                'label-message' => 'abusefilter-test-nobots',
156            ],
157            'TestPeriodStart' => [
158                'type' => 'datetime',
159                'label-message' => 'abusefilter-test-period-start',
160                'min' => $min,
161                'max' => $max,
162            ],
163            'TestPeriodEnd' => [
164                'type' => 'datetime',
165                'label-message' => 'abusefilter-test-period-end',
166                'min' => $min,
167                'max' => $max,
168            ],
169            'TestPage' => [
170                'type' => 'title',
171                'label-message' => 'abusefilter-test-page',
172                'creatable' => true,
173                'required' => false,
174            ],
175            'ShowNegative' => [
176                'type' => 'check',
177                'label-message' => 'abusefilter-test-shownegative',
178            ],
179        ];
180        array_walk( $optionsFields, static function ( &$el ) {
181            $el['section'] = 'abusefilter-test-options-section';
182        } );
183        $allFields = array_merge( $rulesFields, $optionsFields );
184
185        HTMLForm::factory( 'ooui', $allFields, $this->getContext() )
186            ->setTitle( $this->getTitle( 'test' ) )
187            ->setId( 'wpFilterForm' )
188            ->setWrapperLegendMsg( 'abusefilter-test-legend' )
189            ->setSubmitTextMsg( 'abusefilter-test-submit' )
190            ->setSubmitCallback( [ $this, 'doTest' ] )
191            ->showAlways();
192    }
193
194    /**
195     * Loads the revisions and checks the given syntax against them
196     * @param array $formData
197     * @param HTMLForm $form
198     * @return bool
199     */
200    public function doTest( array $formData, HTMLForm $form ): bool {
201        // Quick syntax check.
202        $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
203
204        if ( !$ruleChecker->checkSyntax( $this->testPattern )->isValid() ) {
205            $form->addPreHtml(
206                Html::errorBox( $this->msg( 'abusefilter-test-syntaxerr' )->parse() )
207            );
208            return true;
209        }
210
211        $dbr = $this->lbFactory->getReplicaDatabase();
212        $rcQuery = RecentChange::getQueryInfo();
213        $conds = [];
214
215        // Normalise username
216        $userTitle = Title::newFromText( $formData['TestUser'], NS_USER );
217        $testUser = $userTitle ? $userTitle->getText() : '';
218        if ( $testUser !== '' ) {
219            $conds[$rcQuery['fields']['rc_user_text']] = $testUser;
220        }
221
222        $startTS = strtotime( $formData['TestPeriodStart'] );
223        if ( $startTS ) {
224            $conds[] = $dbr->expr( 'rc_timestamp', '>=', $dbr->timestamp( $startTS ) );
225        }
226        $endTS = strtotime( $formData['TestPeriodEnd'] );
227        if ( $endTS ) {
228            $conds[] = $dbr->expr( 'rc_timestamp', '<=', $dbr->timestamp( $endTS ) );
229        }
230        if ( $formData['TestPage'] !== '' ) {
231            // The form validates the input for us, so this shouldn't throw.
232            $title = Title::newFromTextThrow( $formData['TestPage'] );
233            $conds['rc_namespace'] = $title->getNamespace();
234            $conds['rc_title'] = $title->getDBkey();
235        }
236
237        if ( $formData['ExcludeBots'] ) {
238            $conds['rc_bot'] = 0;
239        }
240
241        $action = $formData['TestAction'] !== '0' ? $formData['TestAction'] : false;
242        $conds[] = $this->buildTestConditions( $dbr, $action );
243        $conds = array_merge( $conds, $this->buildVisibilityConditions( $dbr, $this->getAuthority() ) );
244
245        $res = $dbr->newSelectQueryBuilder()
246            ->tables( $rcQuery['tables'] )
247            ->fields( $rcQuery['fields'] )
248            ->conds( $conds )
249            ->caller( __METHOD__ )
250            ->limit( self::$mChangeLimit )
251            ->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC )
252            ->joinConds( $rcQuery['joins'] )
253            ->fetchResultSet();
254
255        // Get our ChangesList
256        $changesList = new AbuseFilterChangesList( $this->getContext(), $this->testPattern );
257        // Note, we're initializing some rows that will later be discarded. Hopefully this won't have any overhead.
258        $changesList->initChangesListRows( $res );
259        $output = $changesList->beginRecentChangesList();
260
261        $counter = 1;
262
263        $contextUser = $this->getUser();
264        $ruleChecker->toggleConditionLimit( false );
265        foreach ( $res as $row ) {
266            $rc = RecentChange::newFromRow( $row );
267            if ( !$formData['ShowNegative'] ) {
268                $type = (int)$rc->getAttribute( 'rc_type' );
269                $deletedValue = (int)$rc->getAttribute( 'rc_deleted' );
270                if (
271                    (
272                        $type === RC_LOG &&
273                        !LogEventsList::userCanBitfield(
274                            $deletedValue,
275                            LogPage::SUPPRESSED_ACTION | LogPage::SUPPRESSED_USER,
276                            $contextUser
277                        )
278                    ) || (
279                        $type !== RC_LOG &&
280                        !RevisionRecord::userCanBitfield( $deletedValue, RevisionRecord::SUPPRESSED_ALL, $contextUser )
281                    )
282                ) {
283                    // If the RC is deleted, the user can't see it, and we're only showing matches,
284                    // always skip this row. If ShowNegative is true, we can still show the row
285                    // because we won't tell whether it matches the given filter.
286                    continue;
287                }
288            }
289
290            $varGenerator = $this->varGeneratorFactory->newRCGenerator( $rc, $contextUser );
291            $vars = $varGenerator->getVars();
292
293            if ( !$vars ) {
294                continue;
295            }
296
297            $ruleChecker->setVariables( $vars );
298            $result = $ruleChecker->checkConditions( $this->testPattern )->getResult();
299
300            if ( $result || $formData['ShowNegative'] ) {
301                // Stash result in RC item
302                $rc->filterResult = $result;
303                $rc->counter = $counter++;
304                $output .= $changesList->recentChangesLine( $rc, false );
305            }
306        }
307
308        $output .= $changesList->endRecentChangesList();
309
310        $form->addPostHtml( $output );
311
312        return true;
313    }
314
315    /**
316     * Loads parameters from request
317     */
318    public function loadParameters() {
319        $request = $this->getRequest();
320
321        $this->testPattern = $request->getText( 'wpFilterRules' );
322
323        if ( $this->testPattern === ''
324            && count( $this->mParams ) > 1
325            && is_numeric( $this->mParams[1] )
326        ) {
327            $dbr = $this->lbFactory->getReplicaDatabase();
328            $pattern = $dbr->newSelectQueryBuilder()
329                ->select( 'af_pattern' )
330                ->from( 'abuse_filter' )
331                ->where( [ 'af_id' => intval( $this->mParams[1] ) ] )
332                ->caller( __METHOD__ )
333                ->fetchField();
334            if ( $pattern !== false ) {
335                $this->testPattern = $pattern;
336            }
337        }
338    }
339}