diff --git a/i18n/en.json b/i18n/en.json index a6ae35495..142b1d223 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -159,6 +159,7 @@ "abusefilter-list-options-search-like": "Plain query", "abusefilter-list-options-search-rlike": "Regular expression", "abusefilter-list-options-search-irlike": "Case-insensitive regular expression", + "abusefilter-list-invalid-searchmode": "The specified search mode is not valid.", "abusefilter-list-regexerror": "An error has occurred while searching: Regular expression syntax error.", "abusefilter-list-options-submit": "Update", "abusefilter-tools-text": "Here are some tools which may be useful in formulating and debugging abuse filters.", diff --git a/i18n/qqq.json b/i18n/qqq.json index b029c94d0..2f02e47be 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -193,6 +193,7 @@ "abusefilter-list-options-search-like": "Radio button label in filter form.", "abusefilter-list-options-search-rlike": "Radio button label in filter form. See [[w:en:regular expression]]", "abusefilter-list-options-search-irlike": "Radio button label in filter form. See [[w:en:regular expression]]", + "abusefilter-list-invalid-searchmode": "Error message text.", "abusefilter-list-regexerror": "Error message text.", "abusefilter-list-options-submit": "Submit button text in filter form to update a filtered list.\n{{Identical|Update}}", "abusefilter-tools-text": "Introduction test for abuse filter tools.", diff --git a/includes/Views/AbuseFilterViewList.php b/includes/Views/AbuseFilterViewList.php index e8376cfc0..4653e9f6c 100644 --- a/includes/Views/AbuseFilterViewList.php +++ b/includes/Views/AbuseFilterViewList.php @@ -54,7 +54,7 @@ class AbuseFilterViewList extends AbuseFilterView { $scope === 'global' ); if ( $searchEnabled ) { - $querypattern = $request->getVal( 'querypattern' ); + $querypattern = $request->getVal( 'querypattern', '' ); $searchmode = $request->getVal( 'searchoption', 'LIKE' ); } else { $querypattern = ''; @@ -84,51 +84,31 @@ class AbuseFilterViewList extends AbuseFilterView { $conds['af_global'] = 1; } - $dbr = wfGetDB( DB_REPLICA ); - if ( $querypattern !== '' ) { - if ( $searchmode !== 'LIKE' ) { - if ( !StringUtils::isValidPCRERegex( "/$querypattern/" ) ) { - $out->addHTML( - Xml::tags( - 'p', - null, - Html::errorBox( $this->msg( 'abusefilter-list-regexerror' )->parse() ) - ) - ); - $this->showList( - [ 'af_deleted' => 0 ], - compact( - 'deleted', - 'furtherOptions', - 'querypattern', - 'searchmode', - 'scope', - 'searchEnabled' - ) - ); - return; - } - if ( $searchmode === 'RLIKE' ) { - $conds[] = 'af_pattern RLIKE ' . - $dbr->addQuotes( $querypattern ); - } else { - $conds[] = 'LOWER( CAST( af_pattern AS char ) ) RLIKE ' . - strtolower( $dbr->addQuotes( $querypattern ) ); - } - } else { - // Build like query escaping tokens and encapsulating in % to search everywhere - $conds[] = 'LOWER( CAST( af_pattern AS char ) ) ' . - $dbr->buildLike( - $dbr->anyString(), - strtolower( $querypattern ), - $dbr->anyString() - ); + // Check the search pattern. Filtering the results is done in AbuseFilterPager + $error = null; + if ( !in_array( $searchmode, [ 'LIKE', 'RLIKE', 'IRLIKE' ] ) ) { + $error = 'abusefilter-list-invalid-searchmode'; + } elseif ( $searchmode !== 'LIKE' && !StringUtils::isValidPCRERegex( "/$querypattern/" ) ) { + $error = 'abusefilter-list-regexerror'; + } + + if ( $error !== null ) { + $out->addHTML( + Xml::tags( + 'p', + null, + Html::errorBox( $this->msg( $error )->escaped() ) + ) + ); + + // Reset the conditions in case of error + $conds = [ 'af_deleted' => 0 ]; + $querypattern = ''; } } $this->showList( - $conds, compact( 'deleted', 'furtherOptions', @@ -136,15 +116,16 @@ class AbuseFilterViewList extends AbuseFilterView { 'searchmode', 'scope', 'searchEnabled' - ) + ), + $conds ); } /** - * @param array $conds * @param array $optarray + * @param array $conds */ - public function showList( $conds = [ 'af_deleted' => 0 ], $optarray = [] ) { + private function showList( array $optarray, array $conds = [ 'af_deleted' => 0 ] ) { $config = $this->getConfig(); $this->getOutput()->addHTML( Xml::tags( 'h2', null, $this->msg( 'abusefilter-list' )->parse() ) @@ -172,7 +153,8 @@ class AbuseFilterViewList extends AbuseFilterView { $this, $conds, $this->linkRenderer, - [ $querypattern, $searchmode ] + $querypattern, + $searchmode ); } diff --git a/includes/pagers/AbuseFilterPager.php b/includes/pagers/AbuseFilterPager.php index ba6dae1f7..c34e3b80c 100644 --- a/includes/pagers/AbuseFilterPager.php +++ b/includes/pagers/AbuseFilterPager.php @@ -21,23 +21,33 @@ class AbuseFilterPager extends TablePager { */ public $mConds; /** - * @var string[] Info used for searching patterns. The first element is the specified pattern, - * the second is the search mode (LIKE, RLIKE or IRLIKE) + * @var string The pattern being searched */ - public $mQuery; + private $mSearchPattern; + /** + * @var string The pattern search mode (LIKE, RLIKE or IRLIKE) + */ + private $mSearchMode; /** * @param AbuseFilterViewList $page * @param array $conds * @param LinkRenderer $linkRenderer - * @param array $query + * @param string $searchPattern Empty string if no pattern was specified + * @param string $searchMode */ - public function __construct( AbuseFilterViewList $page, $conds, LinkRenderer $linkRenderer, - $query ) { + public function __construct( + AbuseFilterViewList $page, + $conds, + LinkRenderer $linkRenderer, + string $searchPattern, + string $searchMode + ) { $this->mPage = $page; $this->mConds = $conds; $this->linkRenderer = $linkRenderer; - $this->mQuery = $query; + $this->mSearchPattern = $searchPattern; + $this->mSearchMode = $searchMode; parent::__construct( $this->mPage->getContext() ); } @@ -68,6 +78,61 @@ class AbuseFilterPager extends TablePager { ]; } + /** + * @inheritDoc + * This is the same as the parent implementation if no search pattern was specified. + * Otherwise, it does a query with no limit and then slices the results à la ContribsPager. + */ + public function reallyDoQuery( $offset, $limit, $order ) { + if ( !strlen( $this->mSearchPattern ) ) { + return parent::reallyDoQuery( $offset, $limit, $order ); + } + + list( $tables, $fields, $conds, $fname, $options, $join_conds ) = + $this->buildQueryInfo( $offset, $limit, $order ); + + unset( $options['LIMIT'] ); + $res = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds ); + + $filtered = []; + foreach ( $res as $row ) { + if ( $this->matchesPattern( $row->af_pattern ) ) { + $filtered[ $row->af_id ] = $row; + } + } + + // sort results and enforce limit like ContribsPager + if ( $order === self::QUERY_ASCENDING ) { + ksort( $filtered ); + } else { + krsort( $filtered ); + } + $filtered = array_slice( $filtered, 0, $limit ); + $filtered = array_values( $filtered ); + return new FakeResultWrapper( $filtered ); + } + + /** + * Check whether $subject matches the given $pattern. + * + * @param string $subject + * @return bool + * @throws LogicException + */ + private function matchesPattern( $subject ) { + $pattern = $this->mSearchPattern; + switch ( $this->mSearchMode ) { + case 'RLIKE': + return (bool)preg_match( "/$pattern/u", $subject ); + case 'IRLIKE': + return (bool)preg_match( "/$pattern/ui", $subject ); + case 'LIKE': + return mb_stripos( $subject, $pattern ) !== false; + default: + throw new LogicException( "Unknown search type {$this->mSearchMode}" ); + } + } + /** * @see Pager::getFieldNames() * @return array @@ -93,7 +158,7 @@ class AbuseFilterPager extends TablePager { $headers['af_hit_count'] = 'abusefilter-list-hitcount'; } - if ( AbuseFilter::canViewPrivate( $user ) && !empty( $this->mQuery[0] ) ) { + if ( AbuseFilter::canViewPrivate( $user ) && $this->mSearchPattern !== '' ) { $headers['af_pattern'] = 'abusefilter-list-pattern'; } @@ -125,60 +190,7 @@ class AbuseFilterPager extends TablePager { $lang->formatNum( intval( $value ) ) ); case 'af_pattern': - if ( $this->mQuery[1] === 'LIKE' ) { - $position = mb_stripos( $row->af_pattern, $this->mQuery[0] ); - if ( $position === false ) { - // This may happen due to problems with character encoding - // which aren't easy to solve - return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) ); - } - $length = mb_strlen( $this->mQuery[0] ); - } else { - $regex = '/' . $this->mQuery[0] . '/u'; - if ( $this->mQuery[1] === 'IRLIKE' ) { - $regex .= 'i'; - } - - $matches = []; - Wikimedia\suppressWarnings(); - $check = preg_match( - $regex, - $row->af_pattern, - $matches - ); - Wikimedia\restoreWarnings(); - // This may happen in case of catastrophic backtracking - if ( $check === false ) { - return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) ); - } - - $length = mb_strlen( $matches[0] ); - $position = mb_strpos( $row->af_pattern, $matches[0] ); - } - - $remaining = 50 - $length; - if ( $remaining <= 0 ) { - // Truncate the filter pattern and only show the first 50 characters of the match - $pattern = '' . - htmlspecialchars( mb_substr( $row->af_pattern, $position, 50 ) ) . - ''; - } else { - // Center the snippet on the matched string - $minoffset = max( $position - round( $remaining / 2 ), 0 ); - $pattern = mb_substr( $row->af_pattern, $minoffset, 50 ); - $pattern = - htmlspecialchars( mb_substr( $pattern, 0, $position - $minoffset ) ) . - '' . - htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length ) ) . - '' . - htmlspecialchars( mb_substr( - $pattern, - $position - $minoffset + $length, - $remaining - ( $position - $minoffset + $length ) - ) - ); - } - return $pattern; + return $this->getHighlightedPattern( $row ); case 'af_public_comments': return $this->linkRenderer->makeLink( SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->af_id ) ), @@ -257,7 +269,7 @@ class AbuseFilterPager extends TablePager { ) )->params( wfEscapeWikiText( $row->af_user_text ) - )->parse(); + )->parse(); case 'af_group': return AbuseFilter::nameGroup( $value ); default: @@ -265,6 +277,65 @@ class AbuseFilterPager extends TablePager { } } + /** + * Get the filter pattern with elements surrounding the searched pattern + * + * @param stdClass $row + * @return string + */ + private function getHighlightedPattern( stdClass $row ) { + $maxLen = 50; + if ( $this->mSearchMode === 'LIKE' ) { + $position = mb_stripos( $row->af_pattern, $this->mSearchPattern ); + $length = mb_strlen( $this->mSearchPattern ); + } else { + $regex = '/' . $this->mSearchPattern . '/u'; + if ( $this->mSearchMode === 'IRLIKE' ) { + $regex .= 'i'; + } + + $matches = []; + Wikimedia\suppressWarnings(); + $check = preg_match( + $regex, + $row->af_pattern, + $matches + ); + Wikimedia\restoreWarnings(); + // This may happen in case of catastrophic backtracking, or regexps matching + // the empty string. + if ( $check === false || strlen( $matches[0] ) === 0 ) { + return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) ); + } + + $length = mb_strlen( $matches[0] ); + $position = mb_strpos( $row->af_pattern, $matches[0] ); + } + + $remaining = $maxLen - $length; + if ( $remaining <= 0 ) { + $pattern = '' . + htmlspecialchars( mb_substr( $row->af_pattern, $position, $maxLen ) ) . + ''; + } else { + // Center the snippet on the matched string + $minoffset = max( $position - round( $remaining / 2 ), 0 ); + $pattern = mb_substr( $row->af_pattern, $minoffset, $maxLen ); + $pattern = + htmlspecialchars( mb_substr( $pattern, 0, $position - $minoffset ) ) . + '' . + htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length ) ) . + '' . + htmlspecialchars( mb_substr( + $pattern, + $position - $minoffset + $length, + $remaining - ( $position - $minoffset + $length ) + ) + ); + } + return $pattern; + } + /** * @return string */ diff --git a/includes/pagers/GlobalAbuseFilterPager.php b/includes/pagers/GlobalAbuseFilterPager.php index 983e912eb..7cf13cf9a 100644 --- a/includes/pagers/GlobalAbuseFilterPager.php +++ b/includes/pagers/GlobalAbuseFilterPager.php @@ -12,7 +12,7 @@ class GlobalAbuseFilterPager extends AbuseFilterPager { * @param LinkRenderer $linkRenderer */ public function __construct( AbuseFilterViewList $page, $conds, LinkRenderer $linkRenderer ) { - parent::__construct( $page, $conds, $linkRenderer, [ '', 'LIKE' ] ); + parent::__construct( $page, $conds, $linkRenderer, '', 'LIKE' ); $this->mDb = wfGetDB( DB_REPLICA, [], $this->getConfig()->get( 'AbuseFilterCentralDB' ) ); }