Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.40% covered (warning)
69.40%
161 / 232
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewExamine
69.40% covered (warning)
69.40%
161 / 232
28.57% covered (danger)
28.57%
2 / 7
92.56
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
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
7.14
 showSearch
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
2
 showResults
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 showExaminerForRC
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
10.01
 showExaminerForLogEntry
88.75% covered (warning)
88.75%
71 / 80
0.00% covered (danger)
0.00%
0 / 1
16.36
 showExaminer
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\View;
4
5use LogicException;
6use MediaWiki\Context\IContextSource;
7use MediaWiki\Extension\AbuseFilter\AbuseFilterChangesList;
8use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
9use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
10use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
11use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
12use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
13use MediaWiki\Extension\AbuseFilter\FilterLookup;
14use MediaWiki\Extension\AbuseFilter\Pager\AbuseFilterExaminePager;
15use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
16use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
17use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
18use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
19use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
20use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
21use MediaWiki\Html\Html;
22use MediaWiki\HTMLForm\HTMLForm;
23use MediaWiki\Linker\LinkRenderer;
24use MediaWiki\RecentChanges\RecentChange;
25use MediaWiki\RecentChanges\RecentChangeStore;
26use MediaWiki\Title\Title;
27use OOUI;
28use Wikimedia\Rdbms\LBFactory;
29use Wikimedia\Rdbms\ReadOnlyMode;
30
31class AbuseFilterViewExamine extends AbuseFilterView {
32    /**
33     * @var string The rules of the filter we're examining
34     */
35    private $testFilter;
36
37    public function __construct(
38        private readonly LBFactory $lbFactory,
39        AbuseFilterPermissionManager $afPermManager,
40        private readonly FilterLookup $filterLookup,
41        private readonly EditBoxBuilderFactory $boxBuilderFactory,
42        private readonly VariablesBlobStore $varBlobStore,
43        private readonly VariablesFormatter $variablesFormatter,
44        private readonly VariablesManager $varManager,
45        private readonly VariableGeneratorFactory $varGeneratorFactory,
46        private readonly AbuseLoggerFactory $abuseLoggerFactory,
47        private readonly RecentChangeStore $recentChangeStore,
48        private readonly ReadOnlyMode $readOnlyMode,
49        IContextSource $context,
50        LinkRenderer $linkRenderer,
51        string $basePageName,
52        array $params
53    ) {
54        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
55        $this->variablesFormatter->setMessageLocalizer( $context );
56    }
57
58    /**
59     * Shows the page
60     */
61    public function show() {
62        $out = $this->getOutput();
63        $out->setPageTitleMsg( $this->msg( 'abusefilter-examine' ) );
64        $out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
65        if ( $this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
66            $out->addWikiMsg( 'abusefilter-examine-intro' );
67        } else {
68            $out->addWikiMsg( 'abusefilter-examine-intro-examine-only' );
69        }
70
71        $this->testFilter = $this->getRequest()->getText( 'testfilter' );
72
73        // Check if we've got a subpage
74        if ( count( $this->mParams ) > 1 && is_numeric( $this->mParams[1] ) ) {
75            $this->showExaminerForRC( $this->mParams[1] );
76        } elseif ( count( $this->mParams ) > 2
77            && $this->mParams[1] === 'log'
78            && is_numeric( $this->mParams[2] )
79        ) {
80            $this->showExaminerForLogEntry( $this->mParams[2] );
81        } else {
82            $this->showSearch();
83        }
84    }
85
86    /**
87     * Shows the search form
88     */
89    public function showSearch() {
90        $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
91        $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge );
92        $max = wfTimestampNow();
93        $formDescriptor = [
94            'SearchUser' => [
95                'label-message' => 'abusefilter-test-user',
96                'type' => 'user',
97                'ipallowed' => true,
98            ],
99            'SearchPeriodStart' => [
100                'label-message' => 'abusefilter-test-period-start',
101                'type' => 'datetime',
102                'min' => $min,
103                'max' => $max,
104            ],
105            'SearchPeriodEnd' => [
106                'label-message' => 'abusefilter-test-period-end',
107                'type' => 'datetime',
108                'min' => $min,
109                'max' => $max,
110            ],
111        ];
112        HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
113            ->addHiddenField( 'testfilter', $this->testFilter )
114            ->setWrapperLegendMsg( 'abusefilter-examine-legend' )
115            ->setSubmitTextMsg( 'abusefilter-examine-submit' )
116            ->setSubmitCallback( [ $this, 'showResults' ] )
117            ->showAlways();
118    }
119
120    /**
121     * Show search results, called as submit callback by HTMLForm
122     * @param array $formData
123     * @param HTMLForm $form
124     * @return bool
125     */
126    public function showResults( array $formData, HTMLForm $form ): bool {
127        $changesList = new AbuseFilterChangesList( $this->getContext(), $this->testFilter );
128
129        $dbr = $this->lbFactory->getReplicaDatabase();
130        $conds = $this->buildVisibilityConditions( $dbr, $this->getAuthority() );
131        $conds[] = $this->buildTestConditions( $dbr );
132
133        // Normalise username
134        $userTitle = Title::newFromText( $formData['SearchUser'], NS_USER );
135        $userName = $userTitle ? $userTitle->getText() : '';
136
137        if ( $userName !== '' ) {
138            $rcQuery = RecentChange::getQueryInfo();
139            $conds[$rcQuery['fields']['rc_user_text']] = $userName;
140        }
141
142        $startTS = strtotime( $formData['SearchPeriodStart'] );
143        if ( $startTS ) {
144            $conds[] = $dbr->expr( 'rc_timestamp', '>=', $dbr->timestamp( $startTS ) );
145        }
146        $endTS = strtotime( $formData['SearchPeriodEnd'] );
147        if ( $endTS ) {
148            $conds[] = $dbr->expr( 'rc_timestamp', '<=', $dbr->timestamp( $endTS ) );
149        }
150        $pager = new AbuseFilterExaminePager(
151            $changesList,
152            $this->linkRenderer,
153            $this->recentChangeStore,
154            $dbr,
155            $this->getTitle( 'examine' ),
156            $conds
157        );
158
159        $output = $changesList->beginRecentChangesList()
160            . $pager->getNavigationBar()
161            . $pager->getBody()
162            . $pager->getNavigationBar()
163            . $changesList->endRecentChangesList();
164
165        $form->addPostHtml( $output );
166        return true;
167    }
168
169    /**
170     * @param int $rcid
171     */
172    public function showExaminerForRC( $rcid ) {
173        // Get data
174        $rc = $this->recentChangeStore->getRecentChangeById( $rcid );
175        $out = $this->getOutput();
176        if ( !$rc ) {
177            $out->addWikiMsg( 'abusefilter-examine-notfound' );
178            return;
179        }
180
181        if ( !$this->afPermManager::hasRCEntryAccess( $rc, $this->getAuthority() ) ) {
182            $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' );
183            return;
184        }
185
186        $varGenerator = $this->varGeneratorFactory->newRCGenerator( $rc, $this->getUser() );
187        $vars = $varGenerator->getVars();
188        if ( !$vars ) {
189            $out->addWikiMsg( 'abusefilter-examine-incompatible' );
190            return;
191        }
192
193        // We compute all lazily loaded variables here, because we want to display all variables in ::showExaminer
194        $varsArray = $this->varManager->dumpAllVars( $vars, true );
195
196        // Filter out any protected variables that the user cannot see. Keep any protected variables that the user
197        // can see. This will unconditionally generate log entries when viewing the examiner that contains protected
198        // variables, as there is no way to specify the variables to select in the RecentChanges examiner.
199        $protectedVariableValuesShown = [];
200        foreach ( $this->afPermManager->getProtectedVariables() as $protectedVariable ) {
201            if ( array_key_exists( $protectedVariable, $varsArray ) ) {
202                // Try each variable at a time, as the user may be able to see some but not all of the
203                // protected variables.
204                $canViewProtectedVariable = $this->afPermManager
205                    ->canViewProtectedVariables( $this->getAuthority(), [ $protectedVariable ] )->isGood();
206                if ( !$canViewProtectedVariable ) {
207                    // Remove protected variables the user cannot see because they didn't specifically ask for them.
208                    unset( $varsArray[$protectedVariable] );
209                } else {
210                    // Only log if there was a value set. This is to be consistent with
211                    // self::showExaminerForLogEntry
212                    if ( $varsArray[$protectedVariable] !== null ) {
213                        $protectedVariableValuesShown[] = $protectedVariable;
214                    }
215                }
216            }
217        }
218        $vars = VariableHolder::newFromArray( $varsArray );
219
220        if ( count( $protectedVariableValuesShown ) ) {
221            if ( $this->readOnlyMode->isReadOnly() ) {
222                $out->addWikiMsg( 'readonlytext', $this->readOnlyMode->getReason() );
223                return;
224            }
225
226            $logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
227            $logger->logViewProtectedVariableValue(
228                $this->getUser(),
229                $varsArray['user_name'] ?? $varsArray['account_name'],
230                $protectedVariableValuesShown
231            );
232        }
233
234        $out->addJsConfigVars( [
235            'abuseFilterExamine' => [ 'type' => 'rc', 'id' => $rcid ]
236        ] );
237
238        $this->showExaminer( $vars );
239    }
240
241    /**
242     * @param int $logid
243     */
244    public function showExaminerForLogEntry( $logid ) {
245        // Get data
246        $dbr = $this->lbFactory->getReplicaDatabase();
247        $performer = $this->getAuthority();
248        $out = $this->getOutput();
249
250        $row = $dbr->newSelectQueryBuilder()
251            ->select( [
252                'afl_deleted',
253                'afl_ip_hex',
254                'afl_var_dump',
255                'afl_rev_id',
256                'afl_filter_id',
257                'afl_global'
258            ] )
259            ->from( 'abuse_filter_log' )
260            ->where( [ 'afl_id' => $logid ] )
261            ->caller( __METHOD__ )
262            ->fetchRow();
263
264        if ( !$row ) {
265            $out->addWikiMsg( 'abusefilter-examine-notfound' );
266            return;
267        }
268
269        try {
270            $filter = $this->filterLookup->getFilter( $row->afl_filter_id, $row->afl_global );
271        } catch ( CentralDBNotAvailableException ) {
272            // Conservatively assume that it's hidden and protected and suppressed, like in AbuseLogPager::doFormatRow
273            $filter = MutableFilter::newDefault();
274            $filter->setProtected( true );
275            $filter->setHidden( true );
276            $filter->setSuppressed( true );
277        }
278        if ( !$this->afPermManager->canSeeLogDetailsForFilter( $performer, $filter ) ) {
279            $out->addWikiMsg( 'abusefilter-log-cannot-see-details' );
280            return;
281        }
282
283        $visibility = SpecialAbuseLog::getEntryVisibilityForUser( $row, $performer, $this->afPermManager );
284        if ( $visibility !== SpecialAbuseLog::VISIBILITY_VISIBLE ) {
285            if ( $visibility === SpecialAbuseLog::VISIBILITY_HIDDEN ) {
286                $msg = 'abusefilter-log-details-hidden';
287            } elseif ( $visibility === SpecialAbuseLog::VISIBILITY_HIDDEN_IMPLICIT ) {
288                $msg = 'abusefilter-log-details-hidden-implicit';
289            } elseif ( $visibility === SpecialAbuseLog::VISIBILITY_SUPPRESSED ) {
290                $msg = 'abusefilter-log-details-suppressed';
291            } else {
292                throw new LogicException( "Unexpected visibility $visibility" );
293            }
294            $out->addWikiMsg( $msg );
295            return;
296        }
297
298        $vars = $this->varBlobStore->loadVarDump( $row );
299        $varsArray = $this->varManager->dumpAllVars( $vars, $this->afPermManager->getProtectedVariables() );
300
301        // Check that the user can see the protected variables that are being examined if the filter is protected.
302        $userAuthority = $this->getAuthority();
303        if ( $filter->isProtected() ) {
304            $permStatus = $this->afPermManager->canViewProtectedVariables(
305                $userAuthority, array_keys( $vars->getVars() )
306            );
307            if ( !$permStatus->isGood() ) {
308                if ( $permStatus->getPermission() ) {
309                    $out->addWikiMsg(
310                        $this->msg(
311                            'abusefilter-examine-error-protected-due-to-permission',
312                            $this->msg( "action-{$permStatus->getPermission()}" )->plain()
313                        )
314                    );
315                    return;
316                }
317
318                // Add any messages in the status after a generic error message.
319                $additional = '';
320                foreach ( $permStatus->getMessages() as $message ) {
321                    $additional .= $this->msg( $message )->parseAsBlock();
322                }
323
324                $out->addWikiMsg(
325                    $this->msg( 'abusefilter-examine-error-protected' )->rawParams( $additional )
326                );
327                return;
328            }
329
330            $protectedVariableValuesShown = [];
331            foreach ( $this->afPermManager->getProtectedVariables() as $protectedVariable ) {
332                if ( isset( $varsArray[$protectedVariable] ) ) {
333                    $protectedVariableValuesShown[] = $protectedVariable;
334                }
335            }
336
337            if ( count( $protectedVariableValuesShown ) ) {
338                if ( $this->readOnlyMode->isReadOnly() ) {
339                    $out->addWikiMsg( 'readonlytext', $this->readOnlyMode->getReason() );
340                    return;
341                }
342
343                $logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
344                $logger->logViewProtectedVariableValue(
345                    $userAuthority->getUser(),
346                    $varsArray['user_name'] ?? $varsArray['account_name'],
347                    $protectedVariableValuesShown
348                );
349            }
350        }
351
352        $out->addJsConfigVars( [
353            'abuseFilterExamine' => [ 'type' => 'log', 'id' => $logid ]
354        ] );
355        $this->showExaminer( $vars );
356    }
357
358    public function showExaminer( VariableHolder $vars ) {
359        $output = $this->getOutput();
360        $output->enableOOUI();
361
362        $html = '';
363
364        $output->addModules( 'ext.abuseFilter.examine' );
365        $output->addJsConfigVars( [
366            'wgAbuseFilterVariables' => $this->varManager->dumpAllVars( $vars, true ),
367        ] );
368
369        // Add test bit
370        if ( $this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
371            $boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder(
372                $this,
373                $this->getAuthority(),
374                $output
375            );
376
377            $tester = Html::rawElement( 'h2', [], $this->msg( 'abusefilter-examine-test' )->parse() );
378            $tester .= $boxBuilder->buildEditBox( $this->testFilter, false, false, false );
379            $tester .= $this->buildFilterLoader();
380            $html .= Html::rawElement( 'div', [ 'id' => 'mw-abusefilter-examine-editor' ], $tester );
381            $html .= Html::rawElement( 'p',
382                [],
383                new OOUI\ButtonInputWidget(
384                    [
385                        'label' => $this->msg( 'abusefilter-examine-test-button' )->text(),
386                        'id' => 'mw-abusefilter-examine-test',
387                        'flags' => [ 'primary', 'progressive' ]
388                    ]
389                ) .
390                Html::element( 'div',
391                    [
392                        'id' => 'mw-abusefilter-syntaxresult',
393                        'style' => 'display: none;'
394                    ]
395                )
396            );
397        }
398
399        // Variable dump
400        $html .= Html::rawElement(
401            'h2',
402            [],
403            $this->msg( 'abusefilter-examine-vars' )->parse()
404        );
405        $html .= $this->variablesFormatter->buildVarDumpTable( $vars );
406
407        $output->addHTML( $html );
408    }
409
410}