Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
73.80% |
169 / 229 |
|
25.00% |
1 / 4 |
CRAP | |
0.00% |
0 / 1 |
| AbuseFilterViewList | |
73.80% |
169 / 229 |
|
25.00% |
1 / 4 |
61.62 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| show | |
74.44% |
67 / 90 |
|
0.00% |
0 / 1 |
31.83 | |||
| showList | |
79.13% |
91 / 115 |
|
0.00% |
0 / 1 |
10.91 | |||
| showStatus | |
40.91% |
9 / 22 |
|
0.00% |
0 / 1 |
4.86 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Extension\AbuseFilter\View; |
| 4 | |
| 5 | use MediaWiki\Cache\LinkBatchFactory; |
| 6 | use MediaWiki\Context\IContextSource; |
| 7 | use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager; |
| 8 | use MediaWiki\Extension\AbuseFilter\CentralDBManager; |
| 9 | use MediaWiki\Extension\AbuseFilter\Filter\Flags; |
| 10 | use MediaWiki\Extension\AbuseFilter\FilterLookup; |
| 11 | use MediaWiki\Extension\AbuseFilter\FilterProfiler; |
| 12 | use MediaWiki\Extension\AbuseFilter\Pager\AbuseFilterPager; |
| 13 | use MediaWiki\Extension\AbuseFilter\Pager\GlobalAbuseFilterPager; |
| 14 | use MediaWiki\Extension\AbuseFilter\SpecsFormatter; |
| 15 | use MediaWiki\Html\Html; |
| 16 | use MediaWiki\HTMLForm\HTMLForm; |
| 17 | use MediaWiki\Linker\LinkRenderer; |
| 18 | use MediaWiki\Parser\ParserOptions; |
| 19 | use OOUI; |
| 20 | use StringUtils; |
| 21 | use Wikimedia\Rdbms\IConnectionProvider; |
| 22 | |
| 23 | /** |
| 24 | * The default view used in Special:AbuseFilter |
| 25 | */ |
| 26 | class 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 | } |