diff options
author | Brian Evans <grknight@gentoo.org> | 2019-04-11 11:08:13 -0400 |
---|---|---|
committer | Brian Evans <grknight@gentoo.org> | 2019-04-11 11:08:13 -0400 |
commit | e6f63b37820d165b55e4c9bf262b3d6d92e28c67 (patch) | |
tree | df5db2f24d45da64a8e3d90104cc5021dff3bf9c /AbuseFilter/includes | |
parent | Drop Flow extension (diff) | |
download | extensions-e6f63b37820d165b55e4c9bf262b3d6d92e28c67.tar.gz extensions-e6f63b37820d165b55e4c9bf262b3d6d92e28c67.tar.bz2 extensions-e6f63b37820d165b55e4c9bf262b3d6d92e28c67.zip |
Upgrade AbuseFilter to 1.32
Signed-off-by: Brian Evans <grknight@gentoo.org>
Diffstat (limited to 'AbuseFilter/includes')
42 files changed, 5304 insertions, 2824 deletions
diff --git a/AbuseFilter/includes/AFComputedVariable.php b/AbuseFilter/includes/AFComputedVariable.php index 45d635bb..15847598 100644 --- a/AbuseFilter/includes/AFComputedVariable.php +++ b/AbuseFilter/includes/AFComputedVariable.php @@ -1,15 +1,19 @@ <?php +use Wikimedia\Rdbms\Database; +use MediaWiki\MediaWikiServices; +use MediaWiki\Logger\LoggerFactory; + class AFComputedVariable { public $mMethod, $mParameters; public static $userCache = []; public static $articleCache = []; /** - * @param $method - * @param $parameters + * @param string $method + * @param array $parameters */ - function __construct( $method, $parameters ) { + public function __construct( $method, $parameters ) { $this->mMethod = $method; $this->mParameters = $parameters; } @@ -19,11 +23,11 @@ class AFComputedVariable { * * * @param string $wikitext - * @param WikiPage $article + * @param Article $article * * @return object */ - function parseNonEditWikitext( $wikitext, $article ) { + public function parseNonEditWikitext( $wikitext, $article ) { static $cache = []; $cacheKey = md5( $wikitext ) . ':' . $article->getTitle()->getPrefixedText(); @@ -47,10 +51,10 @@ class AFComputedVariable { * in case a user name is given as argument. Nowadays user objects are passed * directly but many old log entries rely on this. * - * @param $user string|User + * @param string|User $user * @return User */ - static function getUserObject( $user ) { + public static function getUserObject( $user ) { if ( $user instanceof User ) { $username = $user->getName(); } else { @@ -59,7 +63,8 @@ class AFComputedVariable { return self::$userCache[$username]; } - wfDebug( "Couldn't find user $username in cache\n" ); + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->debug( "Couldn't find user $username in cache" ); } if ( count( self::$userCache ) > 1000 ) { @@ -67,7 +72,7 @@ class AFComputedVariable { } if ( $user instanceof User ) { - $userCache[$username] = $user; + self::$userCache[$username] = $user; return $user; } @@ -86,11 +91,11 @@ class AFComputedVariable { } /** - * @param $namespace - * @param $title Title + * @param int $namespace + * @param string $title * @return Article */ - static function articleFromTitle( $namespace, $title ) { + public static function articleFromTitle( $namespace, $title ) { if ( isset( self::$articleCache["$namespace:$title"] ) ) { return self::$articleCache["$namespace:$title"]; } @@ -99,7 +104,8 @@ class AFComputedVariable { self::$articleCache = []; } - wfDebug( "Creating article object for $namespace:$title in cache\n" ); + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->debug( "Creating article object for $namespace:$title in cache" ); // TODO: use WikiPage instead! $t = Title::makeTitle( $namespace, $title ); @@ -109,11 +115,11 @@ class AFComputedVariable { } /** - * @param WikiPage $article + * @param Article $article * @return array */ - static function getLinksFromDB( $article ) { - // Stolen from ConfirmEdit + public static function getLinksFromDB( $article ) { + // Stolen from ConfirmEdit, SimpleCaptcha::getLinksFromTracker $id = $article->getId(); if ( !$id ) { return []; @@ -134,12 +140,12 @@ class AFComputedVariable { } /** - * @param $vars AbuseFilterVariableHolder + * @param AbuseFilterVariableHolder $vars * @return AFPData|array|int|mixed|null|string * @throws MWException * @throws AFPException */ - function compute( $vars ) { + public function compute( $vars ) { $parameters = $this->mParameters; $result = null; @@ -151,6 +157,8 @@ class AFComputedVariable { switch ( $this->mMethod ) { case 'diff': + // Currently unused. Kept for backwards compatibility since it remains + // as mMethod for old variables. A fallthrough would instead change old results. $text1Var = $parameters['oldtext-var']; $text2Var = $parameters['newtext-var']; $text1 = $vars->getVar( $text1Var )->toString(); @@ -159,6 +167,21 @@ class AFComputedVariable { $format = new UnifiedDiffFormatter(); $result = $format->format( $diffs ); break; + case 'diff-array': + // Introduced with T74329 to uniform the diff to MW's standard one. + // The difference with 'diff' method is noticeable when one of the + // $text is empty: it'll be treated as **really** empty, instead of + // an empty string. + $text1Var = $parameters['oldtext-var']; + $text2Var = $parameters['newtext-var']; + $text1 = $vars->getVar( $text1Var )->toString(); + $text2 = $vars->getVar( $text2Var )->toString(); + $text1 = $text1 === '' ? [] : explode( "\n", $text1 ); + $text2 = $text2 === '' ? [] : explode( "\n", $text2 ); + $diffs = new Diff( $text1, $text2 ); + $format = new UnifiedDiffFormatter(); + $result = $format->format( $diffs ); + break; case 'diff-split': $diff = $vars->getVar( $parameters['diff-var'] )->toString(); $line_prefix = $parameters['line-prefix']; @@ -186,7 +209,6 @@ class AFComputedVariable { if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { $textVar = $parameters['text-var']; - // XXX: Use prepareContentForEdit. But we need a Content object for that. $new_text = $vars->getVar( $textVar )->toString(); $content = ContentHandler::makeContent( $new_text, $article->getTitle() ); $editInfo = $article->prepareContentForEdit( $content ); @@ -203,11 +225,12 @@ class AFComputedVariable { $parameters['title'] ); + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); if ( $vars->getVar( 'context' )->toString() == 'filter' ) { $links = $this->getLinksFromDB( $article ); - wfDebug( "AbuseFilter: loading old links from DB\n" ); + $logger->debug( 'Loading old links from DB' ); } elseif ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { - wfDebug( "AbuseFilter: loading old links from Parser\n" ); + $logger->debug( 'Loading old links from Parser' ); $textVar = $parameters['text-var']; $wikitext = $vars->getVar( $textVar )->toString(); @@ -351,6 +374,18 @@ class AFComputedVariable { $registration = $obj->getRegistration(); $result = wfTimestamp( TS_UNIX, $asOf ) - wfTimestampOrNull( TS_UNIX, $registration ); break; + case 'page-age': + $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] ); + + $firstRevisionTime = $title->getEarliestRevTime(); + if ( !$firstRevisionTime ) { + $result = 0; + break; + } + + $asOf = $parameters['asof']; + $result = wfTimestamp( TS_UNIX, $asOf ) - wfTimestampOrNull( TS_UNIX, $firstRevisionTime ); + break; case 'user-groups': // Deprecated but needed by old log entries $user = $parameters['user']; @@ -362,10 +397,16 @@ class AFComputedVariable { $result = strlen( $s ); break; case 'subtract': + // Currently unused, kept for backwards compatibility for old filters. $v1 = $vars->getVar( $parameters['val1-var'] )->toFloat(); $v2 = $vars->getVar( $parameters['val2-var'] )->toFloat(); $result = $v1 - $v2; break; + case 'subtract-int': + $v1 = $vars->getVar( $parameters['val1-var'] )->toInt(); + $v2 = $vars->getVar( $parameters['val2-var'] )->toInt(); + $result = $v1 - $v2; + break; case 'revision-text-by-id': $rev = Revision::newFromId( $parameters['revid'] ); $result = AbuseFilter::revisionToString( $rev ); @@ -390,33 +431,36 @@ class AFComputedVariable { /** * @param Title $title - * @return string[] List of the last 10 (unique) authors from $title + * @return string[] Usernames of the last 10 (unique) authors from $title */ public static function getLastPageAuthors( Title $title ) { if ( !$title->exists() ) { return []; } - $cache = ObjectCache::getMainWANInstance(); + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + $fname = __METHOD__; return $cache->getWithSetCallback( $cache->makeKey( 'last-10-authors', 'revision', $title->getLatestRevID() ), $cache::TTL_MINUTE, - function ( $oldValue, &$ttl, array &$setOpts ) use ( $title ) { + function ( $oldValue, &$ttl, array &$setOpts ) use ( $title, $fname ) { $dbr = wfGetDB( DB_REPLICA ); $setOpts += Database::getCacheSetOptions( $dbr ); // Get the last 100 edit authors with a trivial query (avoid T116557) + $revQuery = Revision::getQueryInfo(); $revAuthors = $dbr->selectFieldValues( - 'revision', - 'rev_user_text', + $revQuery['tables'], + $revQuery['fields']['rev_user_text'], [ 'rev_page' => $title->getArticleID() ], - __METHOD__, + $fname, // Some pages have < 10 authors but many revisions (e.g. bot pages) [ 'ORDER BY' => 'rev_timestamp DESC', 'LIMIT' => 100, // Force index per T116557 - 'USE INDEX' => 'page_timestamp', - ] + 'USE INDEX' => [ 'revision' => 'page_timestamp' ], + ], + $revQuery['joins'] ); // Get the last 10 distinct authors within this set of edits $users = []; diff --git a/AbuseFilter/includes/AbuseFilter.class.php b/AbuseFilter/includes/AbuseFilter.php index 3ce5cdb4..2566c25e 100644 --- a/AbuseFilter/includes/AbuseFilter.class.php +++ b/AbuseFilter/includes/AbuseFilter.php @@ -1,7 +1,10 @@ <?php +use MediaWiki\Linker\LinkRenderer; use MediaWiki\Logger\LoggerFactory; +use MediaWiki\Session\SessionManager; use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IDatabase; /** * This class contains most of the business logic of AbuseFilter. It consists of mostly @@ -18,7 +21,8 @@ class AbuseFilter { public static $condCount = 0; /** @var array Map of (action ID => string[]) */ - public static $tagsToSet = []; // FIXME: avoid global state here + // FIXME: avoid global state here + public static $tagsToSet = []; public static $history_mappings = [ 'af_pattern' => 'afh_pattern', @@ -42,7 +46,9 @@ class AbuseFilter { ], 'op-comparison' => [ '==' => 'equal', + '===' => 'equal-strict', '!=' => 'notequal', + '!==' => 'notequal-strict', '<' => 'lt', '>' => 'gt', '<=' => 'lte', @@ -69,20 +75,26 @@ class AbuseFilter { 'lcase(string)' => 'lcase', 'ucase(string)' => 'ucase', 'ccnorm(string)' => 'ccnorm', + 'ccnorm_contains_any(haystack,needle1,needle2,..)' => 'ccnorm-contains-any', + 'ccnorm_contains_all(haystack,needle1,needle2,..)' => 'ccnorm-contains-all', 'rmdoubles(string)' => 'rmdoubles', 'specialratio(string)' => 'specialratio', 'norm(string)' => 'norm', 'count(needle,haystack)' => 'count', 'rcount(needle,haystack)' => 'rcount', + 'get_matches(needle,haystack)' => 'get_matches', 'rmwhitespace(text)' => 'rmwhitespace', 'rmspecials(text)' => 'rmspecials', 'ip_in_range(ip, range)' => 'ip_in_range', - 'contains_any(haystack,needle1,needle2,needle3)' => 'contains-any', + 'contains_any(haystack,needle1,needle2,...)' => 'contains-any', + 'contains_all(haystack,needle1,needle2,...)' => 'contains-all', + 'equals_to_any(haystack,needle1,needle2,...)' => 'equals-to-any', 'substr(subject, offset, length)' => 'substr', 'strpos(haystack, needle)' => 'strpos', 'str_replace(subject, search, replace)' => 'str_replace', 'rescape(string)' => 'rescape', 'set_var(var,value)' => 'set_var', + 'sanitize(string)' => 'sanitize', ], 'vars' => [ 'timestamp' => 'timestamp', @@ -97,19 +109,21 @@ class AbuseFilter { 'old_content_model' => 'old-content-model', 'removed_lines' => 'removedlines', 'summary' => 'summary', - 'article_articleid' => 'article-id', - 'article_namespace' => 'article-ns', - 'article_text' => 'article-text', - 'article_prefixedtext' => 'article-prefixedtext', - // 'article_views' => 'article-views', # May not be enabled, defined in getBuilderValues() - 'moved_from_articleid' => 'movedfrom-id', + 'page_id' => 'page-id', + 'page_namespace' => 'page-ns', + 'page_title' => 'page-title', + 'page_prefixedtitle' => 'page-prefixedtitle', + 'page_age' => 'page-age', + 'moved_from_id' => 'movedfrom-id', 'moved_from_namespace' => 'movedfrom-ns', - 'moved_from_text' => 'movedfrom-text', - 'moved_from_prefixedtext' => 'movedfrom-prefixedtext', - 'moved_to_articleid' => 'movedto-id', + 'moved_from_title' => 'movedfrom-title', + 'moved_from_prefixedtitle' => 'movedfrom-prefixedtitle', + 'moved_from_age' => 'movedfrom-age', + 'moved_to_id' => 'movedto-id', 'moved_to_namespace' => 'movedto-ns', - 'moved_to_text' => 'movedto-text', - 'moved_to_prefixedtext' => 'movedto-prefixedtext', + 'moved_to_title' => 'movedto-title', + 'moved_to_prefixedtitle' => 'movedto-prefixedtitle', + 'moved_to_age' => 'movedto-age', 'user_editcount' => 'user-editcount', 'user_age' => 'user-age', 'user_name' => 'user-name', @@ -127,14 +141,24 @@ class AbuseFilter { 'added_lines_pst' => 'addedlines-pst', 'new_text' => 'new-text-stripped', 'new_html' => 'new-html', - 'article_restrictions_edit' => 'restrictions-edit', - 'article_restrictions_move' => 'restrictions-move', - 'article_restrictions_create' => 'restrictions-create', - 'article_restrictions_upload' => 'restrictions-upload', - 'article_recent_contributors' => 'recent-contributors', - 'article_first_contributor' => 'first-contributor', - // 'old_text' => 'old-text-stripped', # Disabled, performance - // 'old_html' => 'old-html', # Disabled, performance + 'page_restrictions_edit' => 'restrictions-edit', + 'page_restrictions_move' => 'restrictions-move', + 'page_restrictions_create' => 'restrictions-create', + 'page_restrictions_upload' => 'restrictions-upload', + 'page_recent_contributors' => 'recent-contributors', + 'page_first_contributor' => 'first-contributor', + 'moved_from_restrictions_edit' => 'movedfrom-restrictions-edit', + 'moved_from_restrictions_move' => 'movedfrom-restrictions-move', + 'moved_from_restrictions_create' => 'movedfrom-restrictions-create', + 'moved_from_restrictions_upload' => 'movedfrom-restrictions-upload', + 'moved_from_recent_contributors' => 'movedfrom-recent-contributors', + 'moved_from_first_contributor' => 'movedfrom-first-contributor', + 'moved_to_restrictions_edit' => 'movedto-restrictions-edit', + 'moved_to_restrictions_move' => 'movedto-restrictions-move', + 'moved_to_restrictions_create' => 'movedto-restrictions-create', + 'moved_to_restrictions_upload' => 'movedto-restrictions-upload', + 'moved_to_recent_contributors' => 'movedto-recent-contributors', + 'moved_to_first_contributor' => 'movedto-first-contributor', 'old_links' => 'old-links', 'minor_edit' => 'minor-edit', 'file_sha1' => 'file-sha1', @@ -147,13 +171,43 @@ class AbuseFilter { ], ]; + /** @var array Old vars which aren't in use anymore */ + public static $disabledVars = [ + 'old_text' => 'old-text-stripped', + 'old_html' => 'old-html' + ]; + + public static $deprecatedVars = [ + 'article_text' => 'page_title', + 'article_prefixedtext' => 'page_prefixedtitle', + 'article_namespace' => 'page_namespace', + 'article_articleid' => 'page_id', + 'article_restrictions_edit' => 'page_restrictions_edit', + 'article_restrictions_move' => 'page_restrictions_move', + 'article_restrictions_create' => 'page_restrictions_create', + 'article_restrictions_upload' => 'page_restrictions_upload', + 'article_recent_contributors' => 'page_recent_contributors', + 'article_first_contributor' => 'page_first_contributor', + 'moved_from_text' => 'moved_from_title', + 'moved_from_prefixedtext' => 'moved_from_prefixedtitle', + 'moved_from_articleid' => 'moved_from_id', + 'moved_to_text' => 'moved_to_title', + 'moved_to_prefixedtext' => 'moved_to_prefixedtitle', + 'moved_to_articleid' => 'moved_to_id', + ]; + public static $editboxName = null; /** * @param IContextSource $context * @param string $pageType + * @param LinkRenderer $linkRenderer */ - public static function addNavigationLinks( IContextSource $context, $pageType ) { + public static function addNavigationLinks( + IContextSource $context, + $pageType, + LinkRenderer $linkRenderer + ) { $linkDefs = [ 'home' => 'Special:AbuseFilter', 'recentchanges' => 'Special:AbuseFilter/history', @@ -161,21 +215,25 @@ class AbuseFilter { 'log' => 'Special:AbuseLog', ]; - if ( $context->getUser()->isAllowed( 'abusefilter-modify' ) ) { + if ( $context->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) { $linkDefs = array_merge( $linkDefs, [ 'test' => 'Special:AbuseFilter/test', - 'tools' => 'Special:AbuseFilter/tools', - 'import' => 'Special:AbuseFilter/import', + 'tools' => 'Special:AbuseFilter/tools' + ] ); + } + + if ( $context->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $linkDefs = array_merge( $linkDefs, [ + 'import' => 'Special:AbuseFilter/import' ] ); } - // Save some translator work + // Re-use the message $msgOverrides = [ 'recentchanges' => 'abusefilter-filter-log', ]; $links = []; - $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); foreach ( $linkDefs as $name => $page ) { // Give grep a chance to find the usages: @@ -197,7 +255,9 @@ class AbuseFilter { } } - $linkStr = $context->msg( 'parentheses', $context->getLanguage()->pipeList( $links ) )->text(); + $linkStr = $context->msg( 'parentheses' ) + ->rawParams( $context->getLanguage()->pipeList( $links ) ) + ->text(); $linkStr = $context->msg( 'abusefilter-topnav' )->parse() . " $linkStr"; $linkStr = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-navigation' ], $linkStr ); @@ -206,7 +266,6 @@ class AbuseFilter { } /** - * @static * @param User $user * @return AbuseFilterVariableHolder */ @@ -267,16 +326,30 @@ class AbuseFilter { } $realValues = self::$builderValues; - global $wgDisableCounters; - if ( !$wgDisableCounters ) { - $realValues['vars']['article_views'] = 'article-views'; - } + Hooks::run( 'AbuseFilter-builder', [ &$realValues ] ); return $realValues; } /** + * @return array + */ + public static function getDeprecatedVariables() { + static $deprecatedVars = null; + + if ( $deprecatedVars ) { + return $deprecatedVars; + } + + $deprecatedVars = self::$deprecatedVars; + + Hooks::run( 'AbuseFilter-deprecatedVariables', [ &$deprecatedVars ] ); + + return $deprecatedVars; + } + + /** * @param string $filter * @return bool */ @@ -294,7 +367,7 @@ class AbuseFilter { } if ( $filter === 'new' ) { return false; - }; + } $hidden = $dbr->selectField( 'abuse_filter', 'af_hidden', @@ -319,8 +392,10 @@ class AbuseFilter { } } + /** + * For use in batch scripts and the like + */ public static function disableConditionLimit() { - // For use in batch scripts and the like self::$condLimitEnabled = false; } @@ -336,21 +411,11 @@ class AbuseFilter { return $vars; } - $vars->setVar( $prefix . '_ARTICLEID', $title->getArticleID() ); + $vars->setVar( $prefix . '_ID', $title->getArticleID() ); $vars->setVar( $prefix . '_NAMESPACE', $title->getNamespace() ); - $vars->setVar( $prefix . '_TEXT', $title->getText() ); - $vars->setVar( $prefix . '_PREFIXEDTEXT', $title->getPrefixedText() ); - - global $wgDisableCounters; - if ( !$wgDisableCounters && !$title->isSpecialPage() ) { - // Support: HitCounters extension - // XXX: This should be part of the extension (T159069) - if ( method_exists( 'HitCounters\HitCounters', 'getCount' ) ) { - $vars->setVar( $prefix . '_VIEWS', HitCounters\HitCounters::getCount( $title ) ); - } - } + $vars->setVar( $prefix . '_TITLE', $title->getText() ); + $vars->setVar( $prefix . '_PREFIXEDTITLE', $title->getPrefixedText() ); - // Use restrictions. global $wgRestrictionTypes; foreach ( $wgRestrictionTypes as $action ) { $vars->setLazyLoadVar( "{$prefix}_restrictions_$action", 'get-page-restrictions', @@ -367,6 +432,13 @@ class AbuseFilter { 'namespace' => $title->getNamespace() ] ); + $vars->setLazyLoadVar( "{$prefix}_age", 'page-age', + [ + 'title' => $title->getText(), + 'namespace' => $title->getNamespace(), + 'asof' => wfTimestampNow() + ] ); + $vars->setLazyLoadVar( "{$prefix}_first_contributor", 'load-first-author', [ 'title' => $title->getText(), @@ -379,8 +451,9 @@ class AbuseFilter { } /** - * @param $filter - * @return mixed + * @param string $filter + * @return true|array True when successful, otherwise a two-element array with exception message + * and character position of the syntax error */ public static function checkSyntax( $filter ) { global $wgAbuseFilterParserClass; @@ -392,11 +465,10 @@ class AbuseFilter { } /** - * @param $expr - * @param array $vars + * @param string $expr * @return string */ - public static function evaluateExpression( $expr, $vars = [] ) { + public static function evaluateExpression( $expr ) { global $wgAbuseFilterParserClass; if ( self::checkSyntax( $expr ) !== true ) { @@ -404,7 +476,7 @@ class AbuseFilter { } /** @var $parser AbuseFilterParser */ - $parser = new $wgAbuseFilterParserClass( $vars ); + $parser = new $wgAbuseFilterParserClass; return $parser->evaluateExpression( $expr ); } @@ -432,10 +504,10 @@ class AbuseFilter { try { $result = $parser->parse( $conds, self::$condCount ); } catch ( Exception $excep ) { - // Sigh. $result = false; - wfDebugLog( 'AbuseFilter', 'AbuseFilter parser error: ' . $excep->getMessage() . "\n" ); + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->debug( 'AbuseFilter parser error: ' . $excep->getMessage() ); if ( !$ignoreError ) { throw $excep; @@ -450,19 +522,36 @@ class AbuseFilter { * * @param AbuseFilterVariableHolder $vars * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @param Title|null $title + * @param string $mode 'execute' for edits and logs, 'stash' for cached matches * * @return bool[] Map of (integer filter ID => bool) */ - public static function checkAllFilters( $vars, $group = 'default' ) { + public static function checkAllFilters( + $vars, + $group = 'default', + Title $title = null, + $mode = 'execute' + ) { global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; + global $wgAbuseFilterConditionLimit; - // Fetch from the database. + // Ensure that we start fresh, see T193374 + self::$condCount = 0; + + // Fetch filters to check from the database. $filter_matched = []; $dbr = wfGetDB( DB_REPLICA ); + $fields = [ + 'af_id', + 'af_pattern', + 'af_public_comments', + 'af_timestamp' + ]; $res = $dbr->select( 'abuse_filter', - '*', + $fields, [ 'af_enabled' => 1, 'af_deleted' => 0, @@ -472,7 +561,7 @@ class AbuseFilter { ); foreach ( $res as $row ) { - $filter_matched[$row->af_id] = self::checkFilter( $row, $vars, true ); + $filter_matched[$row->af_id] = self::checkFilter( $row, $vars, $title, '', $mode ); } if ( $wgAbuseFilterCentralDB && !$wgAbuseFilterIsCentral ) { @@ -483,16 +572,17 @@ class AbuseFilter { $res = ObjectCache::getMainWANInstance()->getWithSetCallback( $globalRulesKey, WANObjectCache::TTL_INDEFINITE, - function () use ( $group, $fname ) { + function () use ( $group, $fname, $fields ) { global $wgAbuseFilterCentralDB; - $fdb = wfGetLB( $wgAbuseFilterCentralDB )->getConnectionRef( + $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + $fdb = $lbFactory->getMainLB( $wgAbuseFilterCentralDB )->getConnectionRef( DB_REPLICA, [], $wgAbuseFilterCentralDB ); return iterator_to_array( $fdb->select( 'abuse_filter', - '*', + $fields, [ 'af_enabled' => 1, 'af_deleted' => 0, @@ -510,45 +600,61 @@ class AbuseFilter { foreach ( $res as $row ) { $filter_matched['global-' . $row->af_id] = - self::checkFilter( $row, $vars, true, 'global-' ); + self::checkFilter( $row, $vars, $title, 'global-', $mode ); } } - // Update statistics, and disable filters which are over-blocking. - self::recordStats( $filter_matched, $group ); + if ( $title instanceof Title && self::$condCount > $wgAbuseFilterConditionLimit ) { + $actionID = implode( '-', [ + $title->getPrefixedText(), + $vars->getVar( 'user_name' )->toString(), + $vars->getVar( 'action' )->toString() + ] ); + self::bufferTagsToSetByAction( [ $actionID => [ 'abusefilter-condition-limit' ] ] ); + } + + if ( $mode === 'execute' ) { + // Update statistics, and disable filters which are over-blocking. + self::recordStats( $filter_matched, $group ); + } return $filter_matched; } /** - * @static * @param stdClass $row * @param AbuseFilterVariableHolder $vars - * @param bool $profile + * @param Title|null $title * @param string $prefix + * @param string $mode 'execute' for edits and logs, 'stash' for cached matches * @return bool */ - public static function checkFilter( $row, $vars, $profile = false, $prefix = '' ) { - global $wgAbuseFilterProfile; + public static function checkFilter( + $row, + $vars, + Title $title = null, + $prefix = '', + $mode = 'execute' + ) { + global $wgAbuseFilterProfile, $wgAbuseFilterRuntimeProfile, + $wgAbuseFilterSlowFilterRuntimeLimit; $filterID = $prefix . $row->af_id; - $startConds = $startTime = null; - if ( $profile && $wgAbuseFilterProfile ) { - $startConds = self::$condCount; - $startTime = microtime( true ); - } + // Record data to be used if profiling is enabled and mode is 'execute' + $startConds = self::$condCount; + $startTime = microtime( true ); // Store the row somewhere convenient self::$filterCache[$filterID] = $row; - // Check conditions... $pattern = trim( $row->af_pattern ); if ( self::checkConditions( $pattern, $vars, - true /* ignore errors */ + // Ignore errors + true ) ) { // Record match. @@ -558,19 +664,51 @@ class AbuseFilter { $result = false; } - if ( $profile && $wgAbuseFilterProfile ) { - $endTime = microtime( true ); - $endConds = self::$condCount; + $timeTaken = microtime( true ) - $startTime; + $condsUsed = self::$condCount - $startConds; - $timeTaken = $endTime - $startTime; - $condsUsed = $endConds - $startConds; + if ( $wgAbuseFilterProfile && $mode === 'execute' ) { self::recordProfilingResult( $row->af_id, $timeTaken, $condsUsed ); } + $runtime = $timeTaken * 1000; + if ( $mode === 'execute' && $wgAbuseFilterRuntimeProfile && + $runtime > $wgAbuseFilterSlowFilterRuntimeLimit ) { + self::recordSlowFilter( $filterID, $runtime, $condsUsed, $result, $title ); + } + return $result; } /** + * Logs slow filter's runtime data for later analysis + * + * @param string $filterId + * @param float $runtime + * @param int $totalConditions + * @param bool $matched + * @param Title|null $title + */ + private static function recordSlowFilter( + $filterId, $runtime, $totalConditions, $matched, Title $title = null + ) { + $title = $title ? $title->getPrefixedText() : ''; + + $logger = LoggerFactory::getInstance( 'AbuseFilterSlow' ); + $logger->info( + 'Edit filter {filter_id} on {wiki} is taking longer than expected', + [ + 'wiki' => wfWikiID(), + 'filter_id' => $filterId, + 'title' => $title, + 'runtime' => $runtime, + 'matched' => $matched, + 'total_conditions' => $totalConditions + ] + ); + } + + /** * @param int $filter */ public static function resetFilterProfile( $filter ) { @@ -631,8 +769,10 @@ class AbuseFilter { return [ 0, 0 ]; } - $timeProfile = ( $curTotal / $curCount ) * 1000; // 1000 ms in a sec - $timeProfile = round( $timeProfile, 2 ); // Return in ms, rounded to 2dp + // 1000 ms in a sec + $timeProfile = ( $curTotal / $curCount ) * 1000; + // Return in ms, rounded to 2dp + $timeProfile = round( $timeProfile, 2 ); $condProfile = ( $curConds / $curCount ); $condProfile = round( $condProfile, 0 ); @@ -648,7 +788,7 @@ class AbuseFilter { * @return string|bool */ public static function decodeGlobalName( $filter ) { - if ( strpos( $filter, 'global-' ) == 0 ) { + if ( strpos( $filter, 'global-' ) === 0 ) { return substr( $filter, strlen( 'global-' ) ); } @@ -692,7 +832,7 @@ class AbuseFilter { } /** - * @param DatabaseBase $dbr + * @param IDatabase $dbr * @param string[] $filters * @param string $prefix * @return array[] @@ -718,14 +858,13 @@ class AbuseFilter { if ( $row->af_throttled && !empty( $wgAbuseFilterRestrictions[$row->afa_consequence] ) ) { - # Don't do the action + // Don't do the action } elseif ( $row->afa_filter != $row->af_id ) { - // We probably got a NULL, as it's a LEFT JOIN. - // Don't add it. + // We probably got a NULL, as it's a LEFT JOIN. Don't add it. } else { $actionsByFilter[$prefix . $row->afa_filter][$row->afa_consequence] = [ 'action' => $row->afa_consequence, - 'parameters' => explode( "\n", $row->afa_parameters ) + 'parameters' => array_filter( explode( "\n", $row->afa_parameters ) ) ]; } } @@ -734,34 +873,36 @@ class AbuseFilter { } /** - * Executes a list of actions. + * Executes a set of actions. * * @param string[] $filters * @param Title $title * @param AbuseFilterVariableHolder $vars + * @param User $user * @return Status returns the operation's status. $status->isOK() will return true if * there were no actions taken, false otherwise. $status->getValue() will return * an array listing the actions taken. $status->getErrors() etc. will provide * the errors and warnings to be shown to the user to explain the actions. */ - public static function executeFilterActions( $filters, $title, $vars ) { + public static function executeFilterActions( $filters, $title, $vars, User $user ) { global $wgMainCacheType; $actionsByFilter = self::getConsequencesForFilters( $filters ); $actionsTaken = array_fill_keys( $filters, [] ); $messages = []; + // Accumulator to track max block to issue + $maxExpiry = -1; - global $wgOut, $wgAbuseFilterDisallowGlobalLocalBlocks, $wgAbuseFilterRestrictions; + global $wgAbuseFilterDisallowGlobalLocalBlocks, $wgAbuseFilterRestrictions, + $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration; foreach ( $actionsByFilter as $filter => $actions ) { // Special-case handling for warnings. - $parsed_public_comments = $wgOut->parseInline( - self::getFilter( $filter )->af_public_comments - ); + $filter_public_comments = self::getFilter( $filter )->af_public_comments; $global_filter = self::decodeGlobalName( $filter ) !== false; - // If the filter is throttled and throttling is available via object + // If the filter has "throttle" enabled and throttling is available via object // caching, check to see if the user has hit the throttle. if ( !empty( $actions['throttle'] ) && $wgMainCacheType !== CACHE_NONE ) { $parameters = $actions['throttle']['parameters']; @@ -789,42 +930,82 @@ class AbuseFilter { if ( !empty( $actions['warn'] ) ) { $parameters = $actions['warn']['parameters']; - $warnKey = 'abusefilter-warned-' . md5( $title->getPrefixedText() ) . '-' . $filter; + $action = $vars->getVar( 'action' )->toString(); + // Generate a unique key to determine whether the user has already been warned. + // We'll warn again if one of these changes: session, page, triggered filter or action + $warnKey = 'abusefilter-warned-' . md5( $title->getPrefixedText() ) . + '-' . $filter . '-' . $action; // Make sure the session is started prior to using it - if ( session_id() === '' ) { - wfSetupSession(); - } + $session = SessionManager::getGlobalSession(); + $session->persist(); - if ( !isset( $_SESSION[$warnKey] ) || !$_SESSION[$warnKey] ) { - $_SESSION[$warnKey] = true; + if ( !isset( $session[$warnKey] ) || !$session[$warnKey] ) { + $session[$warnKey] = true; // Threaten them a little bit - if ( !empty( $parameters[0] ) && strlen( $parameters[0] ) ) { + if ( isset( $parameters[0] ) ) { $msg = $parameters[0]; } else { $msg = 'abusefilter-warning'; } - $messages[] = [ $msg, $parsed_public_comments, $filter ]; + $messages[] = [ $msg, $filter_public_comments, $filter ]; $actionsTaken[$filter][] = 'warn'; - continue; // Don't do anything else. + // Don't do anything else. + continue; } else { // We already warned them - $_SESSION[$warnKey] = false; + $session[$warnKey] = false; } unset( $actions['warn'] ); } - // prevent double warnings + // Prevent double warnings if ( count( array_intersect_key( $actions, array_filter( $wgAbuseFilterRestrictions ) ) ) > 0 && !empty( $actions['disallow'] ) ) { unset( $actions['disallow'] ); } + // Find out the max expiry to issue the longest triggered block. + // Need to check here since methods like user->getBlock() aren't available + if ( !empty( $actions['block'] ) ) { + $parameters = $actions['block']['parameters']; + + if ( count( $parameters ) === 3 ) { + // New type of filters with custom block + if ( $user->isAnon() ) { + $expiry = $parameters[1]; + } else { + $expiry = $parameters[2]; + } + } else { + // Old type with fixed expiry + if ( $user->isAnon() && $wgAbuseFilterAnonBlockDuration !== null ) { + // The user isn't logged in and the anon block duration + // doesn't default to $wgAbuseFilterBlockDuration. + $expiry = $wgAbuseFilterAnonBlockDuration; + } else { + $expiry = $wgAbuseFilterBlockDuration; + } + } + + $currentExpiry = SpecialBlock::parseExpiryInput( $expiry ); + if ( $currentExpiry > SpecialBlock::parseExpiryInput( $maxExpiry ) ) { + // Save the parameters to issue the block with + $maxExpiry = $expiry; + $blockValues = [ + self::getFilter( $filter )->af_public_comments, + $filter, + is_array( $parameters ) && in_array( 'blocktalk', $parameters ) + ]; + } + unset( $actions['block'] ); + } + // Do the rest of the actions foreach ( $actions as $action => $info ) { $newMsg = self::takeConsequenceAction( @@ -833,7 +1014,8 @@ class AbuseFilter { $title, $vars, self::getFilter( $filter )->af_public_comments, - $filter + $filter, + $user ); if ( $newMsg !== null ) { @@ -843,6 +1025,30 @@ class AbuseFilter { } } + // Since every filter has been analysed, we now know what the + // longest block duration is, so we can issue the block if + // maxExpiry has been changed. + if ( $maxExpiry !== -1 ) { + self::doAbuseFilterBlock( + [ + 'desc' => $blockValues[0], + 'number' => $blockValues[1] + ], + $user->getName(), + $maxExpiry, + true, + $blockValues[2] + ); + $message = [ + 'abusefilter-blocked-display', + $blockValues[0], + $blockValues[1] + ]; + // Manually add the message. If we're here, there is one. + $messages[] = $message; + $actionsTaken[ $blockValues[1] ][] = 'block'; + } + return self::buildStatus( $actionsTaken, $messages ); } @@ -852,7 +1058,7 @@ class AbuseFilter { * * @param array[] $actionsTaken associative array mapping each filter to the list if * actions taken because of that filter. - * @param array[] $messages a list if arrays, where each array contains a message key + * @param array[] $messages a list of arrays, where each array contains a message key * followed by any message parameters. * * @return Status @@ -861,7 +1067,7 @@ class AbuseFilter { $status = Status::newGood( $actionsTaken ); foreach ( $messages as $msg ) { - call_user_func_array( [ $status, 'fatal' ], $msg ); + $status->fatal( ...$msg ); } return $status; @@ -871,27 +1077,14 @@ class AbuseFilter { * @param AbuseFilterVariableHolder $vars * @param Title $title * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) - * @param User $user The user performing the action; defaults to $wgUser + * @param User $user The user performing the action * @param string $mode Use 'execute' to run filters and log or 'stash' to only cache matches * @return Status */ public static function filterAction( - $vars, $title, $group = 'default', $user = null, $mode = 'execute' + AbuseFilterVariableHolder $vars, Title $title, $group, User $user, $mode = 'execute' ) { - global $wgUser, $wgTitle, $wgRequest, $wgAbuseFilterRuntimeProfile; - - $context = RequestContext::getMain(); - $oldContextTitle = $context->getTitle(); - - $oldWgTitle = $wgTitle; - - if ( !$wgTitle ) { - $wgTitle = SpecialPage::getTitleFor( 'AbuseFilter' ); - } - - if ( !$user ) { - $user = $wgUser; - } + global $wgRequest, $wgAbuseFilterRuntimeProfile, $wgAbuseFilterLogIP; $logger = LoggerFactory::getInstance( 'StashEdit' ); $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); @@ -927,7 +1120,7 @@ class AbuseFilter { $statsd->increment( 'abusefilter.check-stash.hit' ); } } else { - $filter_matched = self::checkAllFilters( $vars, $group ); + $filter_matched = self::checkAllFilters( $vars, $group, $title, $mode ); if ( $isForEdit && $mode !== 'stash' ) { $logger->info( __METHOD__ . ": cache miss for '$title' (key $stashKey)." ); $statsd->increment( 'abusefilter.check-stash.miss' ); @@ -969,11 +1162,11 @@ class AbuseFilter { if ( count( $matched_filters ) == 0 ) { $status = Status::newGood(); } else { - $status = self::executeFilterActions( $matched_filters, $title, $vars ); + $status = self::executeFilterActions( $matched_filters, $title, $vars, $user ); $actions_taken = $status->getValue(); $action = $vars->getVar( 'ACTION' )->toString(); - // If $wgUser isn't safe to load (e.g. a failure during + // If $user isn't safe to load (e.g. a failure during // AbortAutoAccount), create a dummy anonymous user instead. $user = $user->isSafeToLoad() ? $user : new User; @@ -981,10 +1174,12 @@ class AbuseFilter { $log_template = [ 'afl_user' => $user->getId(), 'afl_user_text' => $user->getName(), - 'afl_timestamp' => wfGetDB( DB_REPLICA )->timestamp( wfTimestampNow() ), + 'afl_timestamp' => wfGetDB( DB_REPLICA )->timestamp(), 'afl_namespace' => $title->getNamespace(), 'afl_title' => $title->getDBkey(), - 'afl_ip' => $wgRequest->getIP() + 'afl_action' => $action, + // DB field is not null, so nothing + 'afl_ip' => ( $wgAbuseFilterLogIP ) ? $wgRequest->getIP() : "" ]; // Hack to avoid revealing IPs of people creating accounts @@ -992,19 +1187,7 @@ class AbuseFilter { $log_template['afl_user_text'] = $vars->getVar( 'accountname' )->toString(); } - self::addLogEntries( $actions_taken, $log_template, $action, $vars, $group ); - } - - // Bug 53498: If we screwed around with $wgTitle, reset it so the title - // is correctly picked up from the request later. Do the same for the - // main RequestContext, because that might have picked up the bogus - // title from $wgTitle. - if ( $wgTitle !== $oldWgTitle ) { - $wgTitle = $oldWgTitle; - } - - if ( $context->getTitle() !== $oldContextTitle && $oldContextTitle instanceof Title ) { - $context->setTitle( $oldContextTitle ); + self::addLogEntries( $actions_taken, $log_template, $vars, $group ); } return $status; @@ -1022,18 +1205,31 @@ class AbuseFilter { if ( $globalIndex ) { // Global wiki filter if ( !$wgAbuseFilterCentralDB ) { - return null; // not enabled + return null; } $id = $globalIndex; - $lb = wfGetLB( $wgAbuseFilterCentralDB ); + $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + $lb = $lbFactory->getMainLB( $wgAbuseFilterCentralDB ); $dbr = $lb->getConnectionRef( DB_REPLICA, [], $wgAbuseFilterCentralDB ); } else { // Local wiki filter $dbr = wfGetDB( DB_REPLICA ); } - $row = $dbr->selectRow( 'abuse_filter', '*', [ 'af_id' => $id ], __METHOD__ ); + $fields = [ + 'af_id', + 'af_pattern', + 'af_public_comments', + 'af_timestamp' + ]; + + $row = $dbr->selectRow( + 'abuse_filter', + $fields, + [ 'af_id' => $id ], + __METHOD__ + ); self::$filterCache[$id] = $row ?: null; } @@ -1052,11 +1248,17 @@ class AbuseFilter { ) { $inputVars = $vars->exportNonLazyVars(); // Exclude noisy fields that have superficial changes - unset( $inputVars['old_html'] ); - unset( $inputVars['new_html'] ); - unset( $inputVars['user_age'] ); - unset( $inputVars['timestamp'] ); - unset( $inputVars['_VIEWS'] ); + $excludedVars = [ + 'old_html' => true, + 'new_html' => true, + 'user_age' => true, + 'timestamp' => true, + 'page_age' => true, + 'moved_from_age' => true, + 'moved_to_age' => true + ]; + + $inputVars = array_diff_key( $inputVars, $excludedVars ); ksort( $inputVars ); $hash = md5( serialize( $inputVars ) ); @@ -1072,14 +1274,10 @@ class AbuseFilter { /** * @param array[] $actions_taken * @param array $log_template - * @param string $action * @param AbuseFilterVariableHolder $vars * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) - * @return mixed */ - public static function addLogEntries( $actions_taken, $log_template, $action, - $vars, $group = 'default' - ) { + public static function addLogEntries( $actions_taken, $log_template, $vars, $group = 'default' ) { $dbw = wfGetDB( DB_MASTER ); $central_log_template = [ @@ -1095,17 +1293,11 @@ class AbuseFilter { $globalIndex = self::decodeGlobalName( $filter ); $thisLog = $log_template; $thisLog['afl_filter'] = $filter; - $thisLog['afl_action'] = $action; $thisLog['afl_actions'] = implode( ',', $actions ); // Don't log if we were only throttling. if ( $thisLog['afl_actions'] != 'throttle' ) { $log_rows[] = $thisLog; - - if ( !$globalIndex ) { - $logged_local_filters[] = $filter; - } - // Global logging if ( $globalIndex ) { $title = Title::makeTitle( $thisLog['afl_namespace'], $thisLog['afl_title'] ); @@ -1116,6 +1308,8 @@ class AbuseFilter { $central_log_rows[] = $centralLog; $logged_global_filters[] = $globalIndex; + } else { + $logged_local_filters[] = $filter; } } } @@ -1126,7 +1320,8 @@ class AbuseFilter { // Only store the var dump if we're actually going to add log rows. $var_dump = self::storeVarDump( $vars ); - $var_dump = "stored-text:$var_dump"; // To distinguish from stuff stored directly + // To distinguish from stuff stored directly + $var_dump = "stored-text:$var_dump"; $stash = ObjectCache::getMainStashInstance(); @@ -1147,7 +1342,6 @@ class AbuseFilter { $user = User::newFromId( $data['afl_user'] ); $user->setName( $data['afl_user_text'] ); $entry->setPerformer( $user ); - // Set action target $entry->setTarget( Title::makeTitle( $data['afl_namespace'], $data['afl_title'] ) ); // Additional info $entry->setParameters( [ @@ -1159,7 +1353,7 @@ class AbuseFilter { // Send data to CheckUser if installed and we // aren't already sending a notification to recentchanges - if ( is_callable( 'CheckUserHooks::updateCheckUserData' ) + if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) && strpos( $wgAbuseFilterNotifications, 'rc' ) === false ) { $rc = $entry->getRecentChange(); @@ -1205,7 +1399,7 @@ class AbuseFilter { foreach ( $central_log_rows as $row ) { $fdb->insert( 'abuse_filter_log', $row, __METHOD__ ); - $global_log_ids[] = $dbw->insertId(); + $global_log_ids[] = $fdb->insertId(); } $fdb->onTransactionPreCommitOrIdle( @@ -1254,7 +1448,7 @@ class AbuseFilter { } } - // Store to ES if applicable + // Store to ExternalStore if applicable global $wgDefaultExternalStore, $wgAbuseFilterCentralDB; if ( $wgDefaultExternalStore ) { if ( $global ) { @@ -1292,12 +1486,12 @@ class AbuseFilter { * Retrieve a var dump from External Storage or the text table * Some of this code is stolen from Revision::loadText et al * - * @param $stored_dump + * @param string $stored_dump * - * @return object|AbuseFilterVariableHolder|bool + * @return array|object|AbuseFilterVariableHolder|bool */ public static function loadVarDump( $stored_dump ) { - // Back-compat + // Backward compatibility if ( substr( $stored_dump, 0, strlen( 'stored-text:' ) ) !== 'stored-text:' ) { $data = unserialize( $stored_dump ); if ( is_array( $data ) ) { @@ -1346,6 +1540,10 @@ class AbuseFilter { foreach ( $vars as $key => $value ) { $obj->setVar( $key, $value ); } + // If old variable names are used, make sure to keep them + if ( count( array_intersect_key( self::getDeprecatedVariables(), $obj->mVars ) ) !== 0 ) { + $obj->mVarsVersion = 1; + } } return $obj; @@ -1358,25 +1556,21 @@ class AbuseFilter { * @param AbuseFilterVariableHolder $vars * @param string $rule_desc * @param int|string $rule_number + * @param User $user * * @return array|null a message describing the action that was taken, * or null if no action was taken. The message is given as an array * containing the message key followed by any message parameters. - * - * @note: Returning the message as an array instead of a Message object is - * needed for compatibility with MW 1.20: we will be constructing a - * Status object from these messages, and before 1.21, Status did - * not accept Message objects to be added directly. */ public static function takeConsequenceAction( $action, $parameters, $title, - $vars, $rule_desc, $rule_number ) { + $vars, $rule_desc, $rule_number, User $user ) { global $wgAbuseFilterCustomActionsHandlers, $wgRequest; $message = null; switch ( $action ) { case 'disallow': - if ( strlen( $parameters[0] ) ) { + if ( isset( $parameters[0] ) ) { $message = [ $parameters[0], $rule_desc, $rule_number ]; } else { // Generic message. @@ -1387,40 +1581,22 @@ class AbuseFilter { ]; } break; + case 'rangeblock': + global $wgAbuseFilterRangeBlockSize, $wgBlockCIDRLimit; - case 'block': - global $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration, $wgUser; - if ( $wgUser->isAnon() && $wgAbuseFilterAnonBlockDuration !== null ) { - // The user isn't logged in and the anon block duration - // doesn't default to $wgAbuseFilterBlockDuration. - $expiry = $wgAbuseFilterAnonBlockDuration; + $ip = $wgRequest->getIP(); + if ( IP::isIPv6( $ip ) ) { + $CIDRsize = max( $wgAbuseFilterRangeBlockSize['IPv6'], $wgBlockCIDRLimit['IPv6'] ); } else { - $expiry = $wgAbuseFilterBlockDuration; + $CIDRsize = max( $wgAbuseFilterRangeBlockSize['IPv4'], $wgBlockCIDRLimit['IPv4'] ); } - - self::doAbuseFilterBlock( - [ - 'desc' => $rule_desc, - 'number' => $rule_number - ], - $wgUser->getName(), - $expiry, - true - ); - - $message = [ - 'abusefilter-blocked-display', - $rule_desc, - $rule_number - ]; - break; - case 'rangeblock': + $blockCIDR = $ip . '/' . $CIDRsize; self::doAbuseFilterBlock( [ 'desc' => $rule_desc, 'number' => $rule_number ], - IP::sanitizeRange( $wgRequest->getIP() . '/16' ), + IP::sanitizeRange( $blockCIDR ), '1 week', false ); @@ -1432,13 +1608,15 @@ class AbuseFilter { ]; break; case 'degroup': - global $wgUser; - if ( !$wgUser->isAnon() ) { - // Remove all groups from the user. Ouch. - $groups = $wgUser->getGroups(); + if ( !$user->isAnon() ) { + // Remove all groups from the user. + $groups = $user->getGroups(); + // Make sure that the stored var dump contains user groups, since we may + // need them if reverting this degroup via Special:AbuseFilter/revert + $vars->setVar( 'user_groups', $groups ); foreach ( $groups as $group ) { - $wgUser->removeGroup( $group ); + $user->removeGroup( $group ); } $message = [ @@ -1452,31 +1630,30 @@ class AbuseFilter { break; } - // Log it. - $log = new LogPage( 'rights' ); - - $log->addEntry( 'rights', - $wgUser->getUserPage(), + $logEntry = new ManualLogEntry( 'rights', 'rights' ); + $logEntry->setPerformer( self::getFilterUser() ); + $logEntry->setTarget( $user->getUserPage() ); + $logEntry->setComment( wfMessage( 'abusefilter-degroupreason', $rule_desc, $rule_number - )->inContentLanguage()->text(), - [ - implode( ', ', $groups ), - '' - ], - self::getFilterUser() + )->inContentLanguage()->text() ); + $logEntry->setParameters( [ + '4::oldgroups' => $groups, + '5::newgroups' => [] + ] ); + $logEntry->publish( $logEntry->insert() ); } break; case 'blockautopromote': - global $wgUser; - if ( !$wgUser->isAnon() ) { - $blockPeriod = (int)mt_rand( 3 * 86400, 7 * 86400 ); // Block for 3-7 days. + if ( !$user->isAnon() ) { + // Block for 3-7 days. + $blockPeriod = (int)mt_rand( 3 * 86400, 7 * 86400 ); ObjectCache::getMainStashInstance()->set( - self::autoPromoteBlockKey( $wgUser ), true, $blockPeriod + self::autoPromoteBlockKey( $user ), true, $blockPeriod ); $message = [ @@ -1487,16 +1664,17 @@ class AbuseFilter { } break; + case 'block': + // Do nothing, handled at the end of executeFilterActions. Here for completeness. + break; case 'flag': // Do nothing. Here for completeness. break; case 'tag': // Mark with a tag on recentchanges. - global $wgUser; - $actionID = implode( '-', [ - $title->getPrefixedText(), $wgUser->getName(), + $title->getPrefixedText(), $user->getName(), $vars->getVar( 'ACTION' )->toString() ] ); @@ -1520,7 +1698,8 @@ class AbuseFilter { $message = [ $msg ]; } } else { - wfDebugLog( 'AbuseFilter', "Unrecognised action $action" ); + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->debug( "Unrecognised action $action" ); } } @@ -1531,11 +1710,14 @@ class AbuseFilter { * @param array[] $tagsByAction Map of (integer => string[]) */ private static function bufferTagsToSetByAction( array $tagsByAction ) { - foreach ( $tagsByAction as $actionID => $tags ) { - if ( !isset( self::$tagsToSet[$actionID] ) ) { - self::$tagsToSet[$actionID] = $tags; - } else { - self::$tagsToSet[$actionID] = array_merge( self::$tagsToSet[$actionID], $tags ); + global $wgAbuseFilterActions; + if ( isset( $wgAbuseFilterActions['tag'] ) && $wgAbuseFilterActions['tag'] ) { + foreach ( $tagsByAction as $actionID => $tags ) { + if ( !isset( self::$tagsToSet[$actionID] ) ) { + self::$tagsToSet[$actionID] = $tags; + } else { + self::$tagsToSet[$actionID] = array_merge( self::$tagsToSet[$actionID], $tags ); + } } } } @@ -1546,8 +1728,15 @@ class AbuseFilter { * @param string $target * @param string $expiry * @param bool $isAutoBlock + * @param bool $preventEditOwnUserTalk */ - protected static function doAbuseFilterBlock( array $rule, $target, $expiry, $isAutoBlock ) { + protected static function doAbuseFilterBlock( + array $rule, + $target, + $expiry, + $isAutoBlock, + $preventEditOwnUserTalk = false + ) { $filterUser = self::getFilterUser(); $reason = wfMessage( 'abusefilter-blockreason', @@ -1561,7 +1750,7 @@ class AbuseFilter { $block->isHardblock( false ); $block->isAutoblocking( $isAutoBlock ); $block->prevents( 'createaccount', true ); - $block->prevents( 'editownusertalk', false ); + $block->prevents( 'editownusertalk', $preventEditOwnUserTalk ); $block->mExpiry = SpecialBlock::parseExpiryInput( $expiry ); $success = $block->insert(); @@ -1577,6 +1766,9 @@ class AbuseFilter { // Conditionally added same as SpecialBlock $flags[] = 'noautoblock'; } + if ( $preventEditOwnUserTalk === true ) { + $flags[] = 'nousertalk'; + } $logParams['6::flags'] = implode( ',', $flags ); $logEntry = new ManualLogEntry( 'block', 'block' ); @@ -1591,8 +1783,8 @@ class AbuseFilter { } /** - * @param $throttleId - * @param $types + * @param string $throttleId + * @param string $types * @param Title $title * @param string $rateCount * @param string $ratePeriod @@ -1606,27 +1798,30 @@ class AbuseFilter { $key = self::throttleKey( $throttleId, $types, $title, $global ); $count = intval( $stash->get( $key ) ); - wfDebugLog( 'AbuseFilter', "Got value $count for throttle key $key\n" ); + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->debug( "Got value $count for throttle key $key" ); if ( $count > 0 ) { $stash->incr( $key ); $count++; - wfDebugLog( 'AbuseFilter', "Incremented throttle key $key" ); + $logger->debug( "Incremented throttle key $key" ); } else { - wfDebugLog( 'AbuseFilter', "Added throttle key $key with value 1" ); + $logger->debug( "Added throttle key $key with value 1" ); $stash->add( $key, 1, $ratePeriod ); $count = 1; } if ( $count > $rateCount ) { - wfDebugLog( 'AbuseFilter', "Throttle $key hit value $count -- maximum is $rateCount." ); + $logger->debug( "Throttle $key hit value $count -- maximum is $rateCount." ); - return true; // THROTTLED + // THROTTLED + return true; } - wfDebugLog( 'AbuseFilter', "Throttle $key not hit!" ); + $logger->debug( "Throttle $key not hit!" ); - return false; // NOT THROTTLED + // NOT THROTTLED + return false; } /** @@ -1669,7 +1864,7 @@ class AbuseFilter { } /** - * @param $throttleId + * @param string $throttleId * @param string $type * @param Title $title * @param bool $global @@ -1732,14 +1927,13 @@ class AbuseFilter { * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) */ public static function recordStats( $filters, $group = 'default' ) { - global $wgAbuseFilterConditionLimit; + global $wgAbuseFilterConditionLimit, $wgAbuseFilterProfileActionsCap; $stash = ObjectCache::getMainStashInstance(); // Figure out if we've triggered overflows and blocks. $overflow_triggered = ( self::$condCount > $wgAbuseFilterConditionLimit ); - // Store some keys... $overflow_key = self::filterLimitReachedKey(); $total_key = self::filterUsedKey( $group ); @@ -1747,7 +1941,7 @@ class AbuseFilter { $storage_period = self::$statsStoragePeriod; - if ( !$total || $total > 10000 ) { + if ( !$total || $total > $wgAbuseFilterProfileActionsCap ) { // This is for if the total doesn't exist, or has gone past 10,000. // Recreate all the keys at the same time, so they expire together. $stash->set( $total_key, 0, $storage_period ); @@ -1759,7 +1953,6 @@ class AbuseFilter { $stash->set( self::filterMatchesKey(), 0, $storage_period ); } - // Increment total $stash->incr( $total_key ); // Increment overflow counter, if our condition limit overflowed @@ -1795,7 +1988,7 @@ class AbuseFilter { $stash = ObjectCache::getMainStashInstance(); foreach ( $filters as $filter ) { - // determine emergency disable values for this action + // Determine emergency disable values for this action $emergencyDisableThreshold = self::getEmergencyValue( $wgAbuseFilterEmergencyDisableThreshold, $group ); $filterEmergencyDisableCount = @@ -1848,7 +2041,7 @@ class AbuseFilter { * @return mixed */ public static function getEmergencyValue( array $emergencyValue, $group ) { - return isset( $emergencyValue[$group] ) ? $emergencyValue[$group] : $emergencyValue['default']; + return $emergencyValue[$group] ?? $emergencyValue['default']; } /** @@ -1881,6 +2074,19 @@ class AbuseFilter { $username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text(); $user = User::newSystemUser( $username, [ 'steal' => true ] ); + if ( !$user ) { + // User name is invalid. Don't throw because this is a system message, easy + // to change and make wrong either by mistake or intentionally to break the site. + wfWarn( + 'The AbuseFilter user\'s name is invalid. Please change it in ' . + 'MediaWiki:abusefilter-blocker' + ); + // Use the default name to avoid breaking other stuff. This should have no harm, + // aside from blocks temporarily attributed to another user. + $defaultName = wfMessage( 'abusefilter-blocker' )->inLanguage( 'en' )->text(); + $user = User::newSystemUser( $defaultName, [ 'steal' => true ] ); + } + // Promote user to 'sysop' so it doesn't look // like an unprivileged account is blocking users if ( !in_array( 'sysop', $user->getGroups() ) ) { @@ -1891,87 +2097,474 @@ class AbuseFilter { } /** - * @param string $rules - * @param string $textName - * @param bool $addResultDiv + * Extract values for syntax highlight + * * @param bool $canEdit + * @return array + */ + public static function getAceConfig( $canEdit ) { + $values = self::getBuilderValues(); + $deprecatedVars = self::getDeprecatedVariables(); + + $builderVariables = implode( '|', array_keys( $values['vars'] ) ); + $builderFunctions = implode( '|', array_keys( AbuseFilterParser::$mFunctions ) ); + // AbuseFilterTokenizer::$keywords also includes constants (true, false and null), + // but Ace redefines these constants afterwards so this will not be an issue + $builderKeywords = implode( '|', AbuseFilterTokenizer::$keywords ); + // Extract operators from tokenizer like we do in AbuseFilterParserTest + $operators = implode( '|', array_map( function ( $op ) { + return preg_quote( $op, '/' ); + }, AbuseFilterTokenizer::$operators ) ); + $deprecatedVariables = implode( '|', array_keys( $deprecatedVars ) ); + $disabledVariables = implode( '|', array_keys( self::$disabledVars ) ); + + return [ + 'variables' => $builderVariables, + 'functions' => $builderFunctions, + 'keywords' => $builderKeywords, + 'operators' => $operators, + 'deprecated' => $deprecatedVariables, + 'disabled' => $disabledVariables, + 'aceReadOnly' => !$canEdit + ]; + } + + /** + * Build input and button for loading a filter + * * @return string */ - static function buildEditBox( $rules, $textName = 'wpFilterRules', $addResultDiv = true, - $canEdit = true ) { - global $wgOut; - - $textareaAttrib = [ 'dir' => 'ltr' ]; # Rules are in English - if ( !$canEdit ) { - $textareaAttrib['readonly'] = 'readonly'; - } - - global $wgUser; - $noTestAttrib = []; - if ( !$wgUser->isAllowed( 'abusefilter-modify' ) ) { - $noTestAttrib['disabled'] = 'disabled'; - $addResultDiv = false; - } - - $rules = rtrim( $rules ) . "\n"; - $rules = Xml::textarea( $textName, $rules, 40, 15, $textareaAttrib ); - - if ( $canEdit ) { - $dropDown = self::getBuilderValues(); - // Generate builder drop-down - $builder = ''; - - $builder .= Xml::option( wfMessage( 'abusefilter-edit-builder-select' )->text() ); - - foreach ( $dropDown as $group => $values ) { - // Give grep a chance to find the usages: - // abusefilter-edit-builder-group-op-arithmetic, abusefilter-edit-builder-group-op-comparison, - // abusefilter-edit-builder-group-op-bool, abusefilter-edit-builder-group-misc, - // abusefilter-edit-builder-group-funcs, abusefilter-edit-builder-group-vars - $builder .= - Xml::openElement( - 'optgroup', - [ 'label' => wfMessage( "abusefilter-edit-builder-group-$group" )->text() ] - ) . "\n"; - - foreach ( $values as $content => $name ) { - $builder .= - Xml::option( - wfMessage( "abusefilter-edit-builder-$group-$name" )->text(), - $content - ) . "\n"; + public static function buildFilterLoader() { + $loadText = + new OOUI\TextInputWidget( + [ + 'type' => 'number', + 'name' => 'wpInsertFilter', + 'id' => 'mw-abusefilter-load-filter' + ] + ); + $loadButton = + new OOUI\ButtonWidget( + [ + 'label' => wfMessage( 'abusefilter-test-load' )->text(), + 'id' => 'mw-abusefilter-load' + ] + ); + $loadGroup = + new OOUI\ActionFieldLayout( + $loadText, + $loadButton, + [ + 'label' => wfMessage( 'abusefilter-test-load-filter' )->text() + ] + ); + // CSS class for reducing default input field width + $loadDiv = + Xml::tags( + 'div', + [ 'class' => 'mw-abusefilter-load-filter-id' ], + $loadGroup + ); + return $loadDiv; + } + + /** + * Check whether a filter is allowed to use a tag + * + * @param string $tag Tag name + * @return Status + */ + public static function isAllowedTag( $tag ) { + $tagNameStatus = ChangeTags::isTagNameValid( $tag ); + + if ( !$tagNameStatus->isGood() ) { + return $tagNameStatus; + } + + $finalStatus = Status::newGood(); + + $canAddStatus = + ChangeTags::canAddTagsAccompanyingChange( + [ $tag ] + ); + + if ( $canAddStatus->isGood() ) { + return $finalStatus; + } + + if ( $tag === 'abusefilter-condition-limit' ) { + $finalStatus->fatal( 'abusefilter-tag-reserved' ); + return $finalStatus; + } + + $alreadyDefinedTags = []; + AbuseFilterHooks::onListDefinedTags( $alreadyDefinedTags ); + + if ( in_array( $tag, $alreadyDefinedTags, true ) ) { + return $finalStatus; + } + + $canCreateTagStatus = ChangeTags::canCreateTag( $tag ); + if ( $canCreateTagStatus->isGood() ) { + return $finalStatus; + } + + $finalStatus->fatal( 'abusefilter-edit-bad-tags' ); + return $finalStatus; + } + + /** + * Validate throttle parameters + * + * @param array $params Throttle parameters + * @return null|string Null on success, a string with the error message on failure + */ + public static function checkThrottleParameters( $params ) { + $throttleRate = explode( ',', $params[1] ); + $throttleCount = $throttleRate[0]; + $throttlePeriod = $throttleRate[1]; + $throttleGroups = array_slice( $params, 2 ); + $validGroups = [ + 'ip', + 'user', + 'range', + 'creationdate', + 'editcount', + 'site', + 'page' + ]; + + $error = null; + if ( preg_match( '/^[1-9][0-9]*$/', $throttleCount ) === 0 ) { + $error = 'abusefilter-edit-invalid-throttlecount'; + } elseif ( preg_match( '/^[1-9][0-9]*$/', $throttlePeriod ) === 0 ) { + $error = 'abusefilter-edit-invalid-throttleperiod'; + } elseif ( !$throttleGroups ) { + $error = 'abusefilter-edit-empty-throttlegroups'; + } else { + $valid = true; + // Groups should be unique in three ways: no direct duplicates like 'user' and 'user', + // no duplicated subgroups, not even shuffled ('ip,user' and 'user,ip') and no duplicates + // within subgroups ('user,ip,user') + $uniqueGroups = []; + $uniqueSubGroups = true; + // Every group should be valid, and subgroups should have valid groups inside + foreach ( $throttleGroups as $group ) { + if ( strpos( $group, ',' ) !== false ) { + $subGroups = explode( ',', $group ); + if ( $subGroups !== array_unique( $subGroups ) ) { + $uniqueSubGroups = false; + break; + } + foreach ( $subGroups as $subGroup ) { + if ( !in_array( $subGroup, $validGroups ) ) { + $valid = false; + break 2; + } + } + sort( $subGroups ); + $uniqueGroups[] = implode( ',', $subGroups ); + } else { + if ( !in_array( $group, $validGroups ) ) { + $valid = false; + break; + } + $uniqueGroups[] = $group; + } + } + + if ( !$valid ) { + $error = 'abusefilter-edit-invalid-throttlegroups'; + } elseif ( !$uniqueSubGroups || $uniqueGroups !== array_unique( $uniqueGroups ) ) { + $error = 'abusefilter-edit-duplicated-throttlegroups'; + } + } + + return $error; + } + + /** + * Checks whether user input for the filter editing form is valid and if so saves the filter + * + * @param AbuseFilterViewEdit $page + * @param int|string $filter + * @param WebRequest $request + * @param stdClass $newRow + * @param array $actions + * @return Status + */ + public static function saveFilter( $page, $filter, $request, $newRow, $actions ) { + $validationStatus = Status::newGood(); + + // Check the syntax + $syntaxerr = self::checkSyntax( $request->getVal( 'wpFilterRules' ) ); + if ( $syntaxerr !== true ) { + $validationStatus->error( 'abusefilter-edit-badsyntax', $syntaxerr[0] ); + return $validationStatus; + } + // Check for missing required fields (title and pattern) + $missing = []; + if ( !$request->getVal( 'wpFilterRules' ) || + trim( $request->getVal( 'wpFilterRules' ) ) === '' ) { + $missing[] = wfMessage( 'abusefilter-edit-field-conditions' )->escaped(); + } + if ( !$request->getVal( 'wpFilterDescription' ) ) { + $missing[] = wfMessage( 'abusefilter-edit-field-description' )->escaped(); + } + if ( count( $missing ) !== 0 ) { + $missing = $page->getLanguage()->commaList( $missing ); + $validationStatus->error( 'abusefilter-edit-missingfields', $missing ); + return $validationStatus; + } + + // Don't allow setting as deleted an active filter + if ( $request->getCheck( 'wpFilterEnabled' ) == true && + $request->getCheck( 'wpFilterDeleted' ) == true ) { + $validationStatus->error( 'abusefilter-edit-deleting-enabled' ); + return $validationStatus; + } + + // If we've activated the 'tag' option, check the arguments for validity. + if ( !empty( $actions['tag'] ) ) { + foreach ( $actions['tag']['parameters'] as $tag ) { + $status = self::isAllowedTag( $tag ); + + if ( !$status->isGood() ) { + $err = $status->getErrors(); + $msg = $err[0]['message']; + $validationStatus->error( $msg ); + return $validationStatus; } + } + } - $builder .= Xml::closeElement( 'optgroup' ) . "\n"; + // If 'throttle' is selected, check its parameters + if ( !empty( $actions['throttle'] ) ) { + $throttleCheck = self::checkThrottleParameters( $actions['throttle']['parameters'] ); + if ( $throttleCheck !== null ) { + $validationStatus->error( $throttleCheck ); + return $validationStatus; } + } + + $differences = self::compareVersions( + [ $newRow, $actions ], + [ $newRow->mOriginalRow, $newRow->mOriginalActions ] + ); - $rules .= - Xml::tags( - 'select', - [ 'id' => 'wpFilterBuilder', ], - $builder - ) . ' '; + // Don't allow adding a new global rule, or updating a + // rule that is currently global, without permissions. + if ( !$page->canEditFilter( $newRow ) || !$page->canEditFilter( $newRow->mOriginalRow ) ) { + $validationStatus->fatal( 'abusefilter-edit-notallowed-global' ); + return $validationStatus; + } - // Add syntax checking - $rules .= Xml::element( 'input', - [ - 'type' => 'button', - 'value' => wfMessage( 'abusefilter-edit-check' )->text(), - 'id' => 'mw-abusefilter-syntaxcheck' - ] + $noTestAttrib ); + // Don't allow custom messages on global rules + if ( $newRow->af_global == 1 && ( + $request->getVal( 'wpFilterWarnMessage' ) !== 'abusefilter-warning' || + $request->getVal( 'wpFilterDisallowMessage' ) !== 'abusefilter-disallowed' + ) ) { + $validationStatus->fatal( 'abusefilter-edit-notallowed-global-custom-msg' ); + return $validationStatus; + } + + $origActions = $newRow->mOriginalActions; + $wasGlobal = (bool)$newRow->mOriginalRow->af_global; + + unset( $newRow->mOriginalRow ); + unset( $newRow->mOriginalActions ); + + // Check for non-changes + if ( !count( $differences ) ) { + $validationStatus->setResult( true, false ); + return $validationStatus; + } + + // Check for restricted actions + $restrictions = $page->getConfig()->get( 'AbuseFilterRestrictions' ); + if ( count( array_intersect_key( + array_filter( $restrictions ), + array_merge( + array_filter( $actions ), + array_filter( $origActions ) + ) + ) ) + && !$page->getUser()->isAllowed( 'abusefilter-modify-restricted' ) + ) { + $validationStatus->error( 'abusefilter-edit-restricted' ); + return $validationStatus; + } + + // Everything went fine, so let's save the filter + list( $new_id, $history_id ) = + self::doSaveFilter( $newRow, $differences, $filter, $actions, $wasGlobal, $page ); + $validationStatus->setResult( true, [ $new_id, $history_id ] ); + return $validationStatus; + } + + /** + * Saves new filter's info to DB + * + * @param stdClass $newRow + * @param int|string $filter + * @param array $differences + * @param array $actions + * @param bool $wasGlobal + * @param AbuseFilterViewEdit $page + * @return int[] first element is new ID, second is history ID + */ + private static function doSaveFilter( + $newRow, + $differences, + $filter, + $actions, + $wasGlobal, + $page + ) { + $user = $page->getUser(); + $dbw = wfGetDB( DB_MASTER ); + + // Convert from object to array + $newRow = get_object_vars( $newRow ); + + // Set last modifier. + $newRow['af_timestamp'] = $dbw->timestamp(); + $newRow['af_user'] = $user->getId(); + $newRow['af_user_text'] = $user->getName(); + + $dbw->startAtomic( __METHOD__ ); + + // Insert MAIN row. + if ( $filter == 'new' ) { + $new_id = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' ); + $is_new = true; + } else { + $new_id = $filter; + $is_new = false; + } + + // Reset throttled marker, if we're re-enabling it. + $newRow['af_throttled'] = $newRow['af_throttled'] && !$newRow['af_enabled']; + $newRow['af_id'] = $new_id; + + // T67807: integer 1's & 0's might be better understood than booleans + $newRow['af_enabled'] = (int)$newRow['af_enabled']; + $newRow['af_hidden'] = (int)$newRow['af_hidden']; + $newRow['af_throttled'] = (int)$newRow['af_throttled']; + $newRow['af_deleted'] = (int)$newRow['af_deleted']; + $newRow['af_global'] = (int)$newRow['af_global']; + + $dbw->replace( 'abuse_filter', [ 'af_id' ], $newRow, __METHOD__ ); + + if ( $is_new ) { + $new_id = $dbw->insertId(); + } + + // Actions + $availableActions = $page->getConfig()->get( 'AbuseFilterActions' ); + $actionsRows = []; + foreach ( array_filter( $availableActions ) as $action => $_ ) { + // Check if it's set + $enabled = isset( $actions[$action] ) && (bool)$actions[$action]; + + if ( $enabled ) { + $parameters = $actions[$action]['parameters']; + if ( $action === 'throttle' && $parameters[0] === 'new' ) { + // FIXME: Do we really need to keep the filter ID inside throttle parameters? + // We'd save space, keep things simpler and avoid this hack. Note: if removing + // it, a maintenance script will be necessary to clean up the table. + $parameters[0] = $new_id; + } + + $thisRow = [ + 'afa_filter' => $new_id, + 'afa_consequence' => $action, + 'afa_parameters' => implode( "\n", $parameters ) + ]; + $actionsRows[] = $thisRow; + } + } + + // Create a history row + $afh_row = []; + + foreach ( self::$history_mappings as $af_col => $afh_col ) { + $afh_row[$afh_col] = $newRow[$af_col]; + } + + // Actions + $displayActions = []; + foreach ( $actions as $action ) { + $displayActions[$action['action']] = $action['parameters']; + } + $afh_row['afh_actions'] = serialize( $displayActions ); + + $afh_row['afh_changed_fields'] = implode( ',', $differences ); + + // Flags + $flags = []; + if ( $newRow['af_hidden'] ) { + $flags[] = 'hidden'; + } + if ( $newRow['af_enabled'] ) { + $flags[] = 'enabled'; + } + if ( $newRow['af_deleted'] ) { + $flags[] = 'deleted'; + } + if ( $newRow['af_global'] ) { + $flags[] = 'global'; + } + + $afh_row['afh_flags'] = implode( ',', $flags ); + + $afh_row['afh_filter'] = $new_id; + $afh_row['afh_id'] = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' ); + + // Do the update + $dbw->insert( 'abuse_filter_history', $afh_row, __METHOD__ ); + $history_id = $dbw->insertId(); + if ( $filter != 'new' ) { + $dbw->delete( + 'abuse_filter_action', + [ 'afa_filter' => $filter ], + __METHOD__ + ); } + $dbw->insert( 'abuse_filter_action', $actionsRows, __METHOD__ ); - if ( $addResultDiv ) { - $rules .= Xml::element( 'div', - [ 'id' => 'mw-abusefilter-syntaxresult', 'style' => 'display: none;' ], - ' ' ); + $dbw->endAtomic( __METHOD__ ); + + // Invalidate cache if this was a global rule + if ( $wasGlobal || $newRow['af_global'] ) { + $group = 'default'; + if ( isset( $newRow['af_group'] ) && $newRow['af_group'] != '' ) { + $group = $newRow['af_group']; + } + + $globalRulesKey = self::getGlobalRulesKey( $group ); + ObjectCache::getMainWANInstance()->touchCheckKey( $globalRulesKey ); } - // Add script - $wgOut->addModules( 'ext.abuseFilter.edit' ); - self::$editboxName = $textName; + // Logging + $subtype = $filter === 'new' ? 'create' : 'modify'; + $logEntry = new ManualLogEntry( 'abusefilter', $subtype ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $page->getTitle( $new_id ) ); + $logEntry->setParameters( [ + 'historyId' => $history_id, + 'newId' => $new_id + ] ); + $logid = $logEntry->insert(); + $logEntry->publish( $logid ); + + // Purge the tag list cache so the fetchAllTags hook applies tag changes + if ( isset( $actions['tag'] ) ) { + AbuseFilterHooks::purgeTagCache(); + } - return $rules; + self::resetFilterProfile( $new_id ); + return [ $new_id, $history_id ]; } /** @@ -1983,7 +2576,7 @@ class AbuseFilter { * * @return array */ - static function compareVersions( $version_1, $version_2 ) { + public static function compareVersions( $version_1, $version_2 ) { $compareFields = [ 'af_public_comments', 'af_pattern', @@ -2010,9 +2603,11 @@ class AbuseFilter { if ( !isset( $actions1[$action] ) && !isset( $actions2[$action] ) ) { // They're both unset } elseif ( isset( $actions1[$action] ) && isset( $actions2[$action] ) ) { - // They're both set. + // They're both set. Double check needed, e.g. per T180194 if ( array_diff( $actions1[$action]['parameters'], - $actions2[$action]['parameters'] ) ) { + $actions2[$action]['parameters'] ) || + array_diff( $actions2[$action]['parameters'], + $actions1[$action]['parameters'] ) ) { // Different parameters $differences[] = 'actions'; } @@ -2029,28 +2624,28 @@ class AbuseFilter { * @param stdClass $row * @return array */ - static function translateFromHistory( $row ) { - # Translate into an abuse_filter row with some black magic. - # This is ever so slightly evil! + public static function translateFromHistory( $row ) { + // Manually translate into an abuse_filter row. $af_row = new stdClass; foreach ( self::$history_mappings as $af_col => $afh_col ) { $af_row->$af_col = $row->$afh_col; } - # Process flags - + // Process flags $af_row->af_deleted = 0; $af_row->af_hidden = 0; $af_row->af_enabled = 0; - $flags = explode( ',', $row->afh_flags ); - foreach ( $flags as $flag ) { - $col_name = "af_$flag"; - $af_row->$col_name = 1; + if ( $row->afh_flags !== '' ) { + $flags = explode( ',', $row->afh_flags ); + foreach ( $flags as $flag ) { + $col_name = "af_$flag"; + $af_row->$col_name = 1; + } } - # Process actions + // Process actions $actions_raw = unserialize( $row->afh_actions ); $actions_output = []; if ( is_array( $actions_raw ) ) { @@ -2069,13 +2664,15 @@ class AbuseFilter { * @param string $action * @return string */ - static function getActionDisplay( $action ) { + public static function getActionDisplay( $action ) { // Give grep a chance to find the usages: // abusefilter-action-tag, abusefilter-action-throttle, abusefilter-action-warn, // abusefilter-action-blockautopromote, abusefilter-action-block, abusefilter-action-degroup, // abusefilter-action-rangeblock, abusefilter-action-disallow - $display = wfMessage( "abusefilter-action-$action" )->text(); - $display = wfMessage( "abusefilter-action-$action", $display )->isDisabled() ? $action : $display; + $display = wfMessage( "abusefilter-action-$action" )->escaped(); + $display = wfMessage( "abusefilter-action-$action", $display )->isDisabled() + ? htmlspecialchars( $action ) + : $display; return $display; } @@ -2145,18 +2742,11 @@ class AbuseFilter { $vars->addHolders( self::generateUserVars( $user ), - self::generateTitleVars( $title, 'ARTICLE' ) + self::generateTitleVars( $title, 'PAGE' ) ); $vars->setVar( 'ACTION', 'delete' ); - if ( class_exists( CommentStore::class ) ) { - $vars->setVar( 'SUMMARY', CommentStore::newKey( 'rc_comment' ) - // $row comes from RecentChange::selectFields() - ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row )->text - ); - } else { - $vars->setVar( 'SUMMARY', $row->rc_comment ); - } + $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text ); return $vars; } @@ -2178,18 +2768,11 @@ class AbuseFilter { $vars->addHolders( self::generateUserVars( $user ), - self::generateTitleVars( $title, 'ARTICLE' ) + self::generateTitleVars( $title, 'PAGE' ) ); $vars->setVar( 'ACTION', 'edit' ); - if ( class_exists( CommentStore::class ) ) { - $vars->setVar( 'SUMMARY', CommentStore::newKey( 'rc_comment' ) - // $row comes from RecentChange::selectFields() - ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row )->text - ); - } else { - $vars->setVar( 'SUMMARY', $row->rc_comment ); - } + $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text ); $vars->setLazyLoadVar( 'new_wikitext', 'revision-text-by-id', [ 'revid' => $row->rc_this_oldid ] ); @@ -2229,21 +2812,14 @@ class AbuseFilter { self::generateTitleVars( $newTitle, 'MOVED_TO' ) ); - if ( class_exists( CommentStore::class ) ) { - $vars->setVar( 'SUMMARY', CommentStore::newKey( 'rc_comment' ) - // $row comes from RecentChange::selectFields() - ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row )->text - ); - } else { - $vars->setVar( 'SUMMARY', $row->rc_comment ); - } + $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text ); $vars->setVar( 'ACTION', 'move' ); return $vars; } /** - * @param Title $title + * @param Title|null $title * @param Page|null $page * @return AbuseFilterVariableHolder */ @@ -2255,13 +2831,13 @@ class AbuseFilter { $page = WikiPage::factory( $title ); } - $vars->setLazyLoadVar( 'edit_diff', 'diff', + $vars->setLazyLoadVar( 'edit_diff', 'diff-array', [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_wikitext' ] ); - $vars->setLazyLoadVar( 'edit_diff_pst', 'diff', + $vars->setLazyLoadVar( 'edit_diff_pst', 'diff-array', [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_pst' ] ); $vars->setLazyLoadVar( 'new_size', 'length', [ 'length-var' => 'new_wikitext' ] ); $vars->setLazyLoadVar( 'old_size', 'length', [ 'length-var' => 'old_wikitext' ] ); - $vars->setLazyLoadVar( 'edit_delta', 'subtract', + $vars->setLazyLoadVar( 'edit_delta', 'subtract-int', [ 'val1-var' => 'new_size', 'val2-var' => 'old_size' ] ); // Some more specific/useful details about the changes. @@ -2279,8 +2855,6 @@ class AbuseFilter { [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] ); $vars->setLazyLoadVar( 'new_text', 'strip-html', [ 'html-var' => 'new_html' ] ); - $vars->setLazyLoadVar( 'old_text', 'strip-html', - [ 'html-var' => 'old_html' ] ); if ( $title instanceof Title ) { $vars->setLazyLoadVar( 'all_links', 'links-from-wikitext', @@ -2311,12 +2885,6 @@ class AbuseFilter { 'wikitext-var' => 'new_wikitext', 'article' => $page ] ); - $vars->setLazyLoadVar( 'old_html', 'parse-wikitext-nonedit', - [ - 'namespace' => $title->getNamespace(), - 'title' => $title->getText(), - 'wikitext-var' => 'old_wikitext' - ] ); } return $vars; @@ -2358,13 +2926,21 @@ class AbuseFilter { } // Now, build the body of the table. + $deprecatedVars = self::getDeprecatedVariables(); foreach ( $vars as $key => $value ) { $key = strtolower( $key ); + if ( array_key_exists( $key, $deprecatedVars ) ) { + $key = $deprecatedVars[$key]; + } if ( !empty( $variableMessageMappings[$key] ) ) { $mapping = $variableMessageMappings[$key]; $keyDisplay = $context->msg( "abusefilter-edit-builder-vars-$mapping" )->parse() . - ' ' . Xml::element( 'code', null, $context->msg( 'parentheses', $key )->text() ); + ' ' . Xml::element( 'code', null, $context->msg( 'parentheses' )->rawParams( $key )->text() ); + } elseif ( !empty( self::$disabledVars[$key] ) ) { + $mapping = self::$disabledVars[$key]; + $keyDisplay = $context->msg( "abusefilter-edit-builder-vars-$mapping" )->parse() . + ' ' . Xml::element( 'code', null, $context->msg( 'parentheses' )->rawParams( $key )->text() ); } else { $keyDisplay = Xml::element( 'code', null, $key ); } @@ -2393,15 +2969,70 @@ class AbuseFilter { * @param string[] $parameters * @return string */ - static function formatAction( $action, $parameters ) { + public static function formatAction( $action, $parameters ) { /** @var $wgLang Language */ global $wgLang; - if ( count( $parameters ) == 0 ) { + if ( count( $parameters ) === 0 || + ( $action === 'block' && count( $parameters ) !== 3 ) ) { $displayAction = self::getActionDisplay( $action ); } else { - $displayAction = self::getActionDisplay( $action ) . + if ( $action === 'block' ) { + // Needs to be treated separately since the message is more complex + $messages = [ + wfMessage( 'abusefilter-block-anon' )->escaped() . + wfMessage( 'colon-separator' )->escaped() . + $wgLang->translateBlockExpiry( $parameters[1] ), + wfMessage( 'abusefilter-block-user' )->escaped() . + wfMessage( 'colon-separator' )->escaped() . + $wgLang->translateBlockExpiry( $parameters[2] ) + ]; + if ( $parameters[0] === 'blocktalk' ) { + $messages[] = wfMessage( 'abusefilter-block-talk' )->escaped(); + } + $displayAction = $wgLang->commaList( $messages ); + } elseif ( $action === 'throttle' ) { + array_shift( $parameters ); + list( $actions, $time ) = explode( ',', array_shift( $parameters ) ); + + if ( $parameters === [ '' ] ) { + // Having empty groups won't happen for new filters due to validation upon saving, + // but old entries may have it. We'd better not show a broken message. Also, + // the array has an empty string inside because we haven't been passing an empty array + // as the default when retrieving wpFilterThrottleGroups with getArray (when it was + // a CheckboxMultiselect). + $groups = ''; + } else { + // Join comma-separated groups in a commaList with a final "and", and convert to messages. + // Messages used here: abusefilter-throttle-ip, abusefilter-throttle-user, + // abusefilter-throttle-site, abusefilter-throttle-creationdate, abusefilter-throttle-editcount + // abusefilter-throttle-range, abusefilter-throttle-page + foreach ( $parameters as &$val ) { + if ( strpos( $val, ',' ) !== false ) { + $subGroups = explode( ',', $val ); + foreach ( $subGroups as &$group ) { + $msg = wfMessage( "abusefilter-throttle-$group" ); + // We previously accepted literally everything in this field, so old entries + // may have weird stuff. + $group = $msg->exists() ? $msg->text() : $group; + } + unset( $group ); + $val = $wgLang->listToText( $subGroups ); + } else { + $msg = wfMessage( "abusefilter-throttle-$val" ); + $val = $msg->exists() ? $msg->text() : $val; + } + } + unset( $val ); + $groups = $wgLang->semicolonList( $parameters ); + } + $displayAction = self::getActionDisplay( $action ) . wfMessage( 'colon-separator' )->escaped() . - $wgLang->semicolonList( $parameters ); + wfMessage( 'abusefilter-throttle-details' )->params( $actions, $time, $groups )->escaped(); + } else { + $displayAction = self::getActionDisplay( $action ) . + wfMessage( 'colon-separator' )->escaped() . + $wgLang->semicolonList( array_map( 'htmlspecialchars', $parameters ) ); + } } return $displayAction; @@ -2411,13 +3042,13 @@ class AbuseFilter { * @param string $value * @return string */ - static function formatFlags( $value ) { + public static function formatFlags( $value ) { /** @var $wgLang Language */ global $wgLang; $flags = array_filter( explode( ',', $value ) ); $flags_display = []; foreach ( $flags as $flag ) { - $flags_display[] = wfMessage( "abusefilter-history-$flag" )->text(); + $flags_display[] = wfMessage( "abusefilter-history-$flag" )->escaped(); } return $wgLang->commaList( $flags_display ); @@ -2427,7 +3058,7 @@ class AbuseFilter { * @param string $filterID * @return string */ - static function getGlobalFilterDescription( $filterID ) { + public static function getGlobalFilterDescription( $filterID ) { global $wgAbuseFilterCentralDB; if ( !$wgAbuseFilterCentralDB ) { @@ -2457,7 +3088,7 @@ class AbuseFilter { * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) * @return string A name for that filter group, or the input. */ - static function nameGroup( $group ) { + public static function nameGroup( $group ) { // Give grep a chance to find the usages: abusefilter-group-default $msg = "abusefilter-group-$group"; @@ -2474,7 +3105,7 @@ class AbuseFilter { * Note also that if the revision for any reason is not an Revision * the function returns with an empty string. * - * @param Revision $revision a valid revision + * @param Revision|null $revision a valid revision * @param int $audience one of: * Revision::FOR_PUBLIC to be displayed to all users * Revision::FOR_THIS_USER to be displayed to the given user @@ -2482,7 +3113,7 @@ class AbuseFilter { * @return string|null the content of the revision as some kind of string, * or an empty string if it can not be found */ - static function revisionToString( $revision, $audience = Revision::FOR_THIS_USER ) { + public static function revisionToString( $revision, $audience = Revision::FOR_THIS_USER ) { if ( !$revision instanceof Revision ) { return ''; } @@ -2509,7 +3140,7 @@ class AbuseFilter { * * @return string a suitable string representation of the content. */ - static function contentToString( Content $content ) { + public static function contentToString( Content $content ) { $text = null; if ( Hooks::run( 'AbuseFilter-contentToString', [ $content, &$text ] ) ) { @@ -2518,21 +3149,15 @@ class AbuseFilter { : $content->getTextForSearchIndex(); } - if ( is_string( $text ) ) { - // T22310 - // XXX: Is this really needed? Should we rather apply PST? - $text = str_replace( "\r\n", "\n", $text ); - } else { - $text = ''; - } - + // T22310 + $text = TextContent::normalizeLineEndings( (string)$text ); return $text; } - /* + /** * Get the history ID of the first change to a given filter * - * @param int $filterId Filter id + * @param int $filterID Filter id * @return int */ public static function getFirstFilterChange( $filterID ) { diff --git a/AbuseFilter/includes/AbuseFilterChangesList.php b/AbuseFilter/includes/AbuseFilterChangesList.php new file mode 100644 index 00000000..94855f34 --- /dev/null +++ b/AbuseFilter/includes/AbuseFilterChangesList.php @@ -0,0 +1,123 @@ +<?php + +class AbuseFilterChangesList extends OldChangesList { + + /** + * @var string + */ + private $testFilter; + + /** + * @param Skin $skin + * @param string $testFilter + */ + public function __construct( Skin $skin, $testFilter ) { + parent::__construct( $skin ); + $this->testFilter = $testFilter; + } + + /** + * @param string &$s + * @param RecentChange &$rc + * @param string[] &$classes + * @suppress PhanUndeclaredProperty for $rc->filterResult, which isn't a big deal + */ + public function insertExtra( &$s, &$rc, &$classes ) { + if ( (int)$rc->getAttribute( 'rc_deleted' ) !== 0 ) { + $s .= ' ' . $this->msg( 'abusefilter-log-hidden-implicit' )->parse(); + if ( !$this->userCan( $rc, Revision::SUPPRESSED_ALL ) ) { + return; + } + } + + $examineParams = []; + if ( $this->testFilter ) { + $examineParams['testfilter'] = $this->testFilter; + } + + $title = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/' . $rc->mAttribs['rc_id'] ); + $examineLink = $this->linkRenderer->makeLink( + $title, + new HtmlArmor( $this->msg( 'abusefilter-changeslist-examine' )->parse() ), + [], + $examineParams + ); + + $s .= ' ' . $this->msg( 'parentheses' )->rawParams( $examineLink )->escaped(); + + // Add CSS classes for match and not match + if ( isset( $rc->filterResult ) ) { + $class = $rc->filterResult ? + 'mw-abusefilter-changeslist-match' : + 'mw-abusefilter-changeslist-nomatch'; + + $classes[] = $class; + } + } + + /** + * Insert links to user page, user talk page and eventually a blocking link. + * Like the parent, but don't hide details if user can see them. + * + * @param string &$s HTML to update + * @param RecentChange &$rc + */ + public function insertUserRelatedLinks( &$s, &$rc ) { + $links = $this->getLanguage()->getDirMark() . Linker::userLink( $rc->mAttribs['rc_user'], + $rc->mAttribs['rc_user_text'] ) . + Linker::userToolLinks( $rc->mAttribs['rc_user'], $rc->mAttribs['rc_user_text'] ); + + if ( $this->isDeleted( $rc, Revision::DELETED_USER ) ) { + if ( $this->userCan( $rc, Revision::DELETED_USER ) ) { + $s .= ' <span class="history-deleted">' . $links . '</span>'; + } else { + $s .= ' <span class="history-deleted">' . + $this->msg( 'rev-deleted-user' )->escaped() . '</span>'; + } + } else { + $s .= $links; + } + } + + /** + * Insert a formatted comment. Like the parent, but don't hide details if user can see them. + * @param RecentChange $rc + * @return string + */ + public function insertComment( $rc ) { + if ( $this->isDeleted( $rc, Revision::DELETED_COMMENT ) ) { + if ( $this->userCan( $rc, Revision::DELETED_COMMENT ) ) { + return ' <span class="history-deleted">' . + Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() ) . '</span>'; + } else { + return ' <span class="history-deleted">' . + $this->msg( 'rev-deleted-comment' )->escaped() . '</span>'; + } + } else { + return Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() ); + } + } + + /** + * Insert a formatted action. The same as parent, but with a different audience in LogFormatter + * + * @param RecentChange $rc + * @return string + */ + public function insertLogEntry( $rc ) { + $formatter = LogFormatter::newFromRow( $rc->mAttribs ); + $formatter->setContext( $this->getContext() ); + $formatter->setAudience( LogFormatter::FOR_THIS_USER ); + $formatter->setShowUserToolLinks( true ); + $mark = $this->getLanguage()->getDirMark(); + return $formatter->getActionText() . " $mark" . $formatter->getComment(); + } + + /** + * @param string &$s + * @param RecentChange &$rc + */ + public function insertRollback( &$s, &$rc ) { + // Kill rollback links. + } +} diff --git a/AbuseFilter/includes/AbuseFilter.hooks.php b/AbuseFilter/includes/AbuseFilterHooks.php index 9ad8e076..20c07e4a 100644 --- a/AbuseFilter/includes/AbuseFilter.hooks.php +++ b/AbuseFilter/includes/AbuseFilterHooks.php @@ -1,23 +1,24 @@ <?php -use MediaWiki\Auth\AuthManager; +use MediaWiki\Linker\LinkRenderer; use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\Database; class AbuseFilterHooks { const FETCH_ALL_TAGS_KEY = 'abusefilter-fetch-all-tags'; + /** @var AbuseFilterVariableHolder|bool */ public static $successful_action_vars = false; - /** @var WikiPage|Article|bool */ - public static $last_edit_page = false; // make sure edit filter & edit save hooks match + /** @var WikiPage|Article|bool|null Make sure edit filter & edit save hooks match */ + public static $last_edit_page = false; // So far, all of the error message out-params for these hooks accept HTML. - // Hooray! /** * Called right after configuration has been loaded. */ public static function onRegistration() { global $wgAbuseFilterAvailableActions, $wgAbuseFilterRestrictedActions, - $wgAuthManagerAutoConfig; + $wgAuthManagerAutoConfig, $wgActionFilteredLogs; if ( isset( $wgAbuseFilterAvailableActions ) || isset( $wgAbuseFilterRestrictedActions ) ) { wfWarn( '$wgAbuseFilterAvailableActions and $wgAbuseFilterRestrictedActions have been ' @@ -28,8 +29,15 @@ class AbuseFilterHooks { $wgAuthManagerAutoConfig['preauth'][AbuseFilterPreAuthenticationProvider::class] = [ 'class' => AbuseFilterPreAuthenticationProvider::class, - 'sort' => 5, // run after normal preauth providers to keep the log cleaner + // Run after normal preauth providers to keep the log cleaner + 'sort' => 5, ]; + + $wgActionFilteredLogs['suppress'] = array_merge( + $wgActionFilteredLogs['suppress'], + // Message: log-action-filter-suppress-abuselog + [ 'abuselog' => [ 'hide-afl', 'unhide-afl' ] ] + ); } /** @@ -41,7 +49,6 @@ class AbuseFilterHooks { * @param string $summary Edit summary for page * @param User $user the user performing the edit * @param bool $minoredit whether this is a minor edit according to the user. - * @return bool Always true */ public static function onEditFilterMergedContent( IContextSource $context, Content $content, Status $status, $summary, User $user, $minoredit @@ -54,8 +61,6 @@ class AbuseFilterHooks { // Produce a useful error message for API edits $status->apiHookResult = self::getApiResult( $filterStatus ); } - - return true; } /** @@ -112,10 +117,10 @@ class AbuseFilterHooks { // Load vars for filters to check $vars = self::newVariableHolderForEdit( - $user, $title, $page, $summary, $content, $oldcontent, $text + $user, $title, $page, $summary, $content, $text, $oldcontent ); - $filter_result = AbuseFilter::filterAction( $vars, $title ); + $filter_result = AbuseFilter::filterAction( $vars, $title, 'default', $user ); if ( !$filter_result->isOK() ) { $status->merge( $filter_result ); @@ -134,19 +139,19 @@ class AbuseFilterHooks { * @param WikiPage|null $page * @param string $summary * @param Content $newcontent - * @param Content|null $oldcontent * @param string $text + * @param Content|null $oldcontent * @return AbuseFilterVariableHolder * @throws MWException */ private static function newVariableHolderForEdit( User $user, Title $title, $page, $summary, Content $newcontent, - $oldcontent = null, $text + $text, $oldcontent = null ) { $vars = new AbuseFilterVariableHolder(); $vars->addHolders( AbuseFilter::generateUserVars( $user ), - AbuseFilter::generateTitleVars( $title, 'ARTICLE' ) + AbuseFilter::generateTitleVars( $title, 'PAGE' ) ); $vars->setVar( 'action', 'edit' ); $vars->setVar( 'summary', $summary ); @@ -189,7 +194,7 @@ class AbuseFilterHooks { // The value is a nested structure keyed by filter id, which doesn't make sense when we only // return the result from one filter. Flatten it to a plain array of actions. $actionsTaken = array_values( array_unique( - call_user_func_array( 'array_merge', array_values( $status->getValue() ) ) + array_merge( ...array_values( $status->getValue() ) ) ) ); $code = ( $actionsTaken === [ 'warn' ] ) ? 'abusefilter-warning' : 'abusefilter-disallowed'; @@ -212,7 +217,7 @@ class AbuseFilterHooks { } /** - * @param Article|WikiPage $article + * @param WikiPage $wikiPage * @param User $user * @param string $content Content * @param string $summary @@ -223,29 +228,28 @@ class AbuseFilterHooks { * @param Revision $revision * @param Status $status * @param int $baseRevId - * @return bool */ public static function onPageContentSaveComplete( - &$article, &$user, $content, $summary, $minoredit, $watchthis, $sectionanchor, - &$flags, $revision, &$status, $baseRevId + WikiPage $wikiPage, $user, $content, $summary, $minoredit, $watchthis, $sectionanchor, + $flags, $revision, $status, $baseRevId ) { if ( !self::$successful_action_vars || !$revision ) { self::$successful_action_vars = false; - - return true; + return; } - /** @var AbuseFilterVariableHolder $vars */ + /** @var AbuseFilterVariableHolder|bool $vars */ $vars = self::$successful_action_vars; - if ( $vars->getVar( 'article_prefixedtext' )->toString() !== - $article->getTitle()->getPrefixedText() + if ( $vars->getVar( 'page_prefixedtitle' )->toString() !== + $wikiPage->getTitle()->getPrefixedText() ) { - return true; + return; } - if ( !self::identicalPageObjects( $article, self::$last_edit_page ) ) { - return true; // this isn't the edit $successful_action_vars was set for + if ( !self::identicalPageObjects( $wikiPage, self::$last_edit_page ) ) { + // This isn't the edit $successful_action_vars was set for + return; } self::$last_edit_page = false; @@ -254,7 +258,7 @@ class AbuseFilterHooks { $log_ids = $vars->getVar( 'local_log_ids' )->toNative(); $dbw = wfGetDB( DB_MASTER ); - if ( count( $log_ids ) ) { + if ( $log_ids !== null && count( $log_ids ) ) { $dbw->update( 'abuse_filter_log', [ 'afl_rev_id' => $revision->getId() ], [ 'afl_id' => $log_ids ], @@ -266,7 +270,7 @@ class AbuseFilterHooks { if ( $vars->getVar( 'global_log_ids' ) ) { $log_ids = $vars->getVar( 'global_log_ids' )->toNative(); - if ( count( $log_ids ) ) { + if ( $log_ids !== null && count( $log_ids ) ) { global $wgAbuseFilterCentralDB; $fdb = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB ); @@ -277,8 +281,6 @@ class AbuseFilterHooks { ); } } - - return true; } /** @@ -296,8 +298,7 @@ class AbuseFilterHooks { /** * @param User $user - * @param array $promote - * @return bool + * @param array &$promote */ public static function onGetAutoPromoteGroups( $user, &$promote ) { if ( $promote ) { @@ -314,8 +315,6 @@ class AbuseFilterHooks { $promote = []; } } - - return true; } /** @@ -338,7 +337,7 @@ class AbuseFilterHooks { $vars->setVar( 'SUMMARY', $reason ); $vars->setVar( 'ACTION', 'move' ); - $result = AbuseFilter::filterAction( $vars, $oldTitle ); + $result = AbuseFilter::filterAction( $vars, $oldTitle, 'default', $user ); $status->merge( $result ); return $result->isOK(); @@ -348,22 +347,22 @@ class AbuseFilterHooks { * @param WikiPage $article * @param User $user * @param string $reason - * @param string $error + * @param string &$error * @param Status $status * @return bool */ - public static function onArticleDelete( &$article, &$user, &$reason, &$error, &$status ) { + public static function onArticleDelete( $article, $user, $reason, &$error, $status ) { $vars = new AbuseFilterVariableHolder; $vars->addHolders( AbuseFilter::generateUserVars( $user ), - AbuseFilter::generateTitleVars( $article->getTitle(), 'ARTICLE' ) + AbuseFilter::generateTitleVars( $article->getTitle(), 'PAGE' ) ); $vars->setVar( 'SUMMARY', $reason ); $vars->setVar( 'ACTION', 'delete' ); - $filter_result = AbuseFilter::filterAction( $vars, $article->getTitle() ); + $filter_result = AbuseFilter::filterAction( $vars, $article->getTitle(), 'default', $user ); $status->merge( $filter_result ); $error = $filter_result->isOK() ? '' : $filter_result->getHTML(); @@ -372,65 +371,7 @@ class AbuseFilterHooks { } /** - * @param User $user - * @param string $message - * @param bool $autocreate Indicates whether the account is created automatically. - * @return bool - * @deprecated AbuseFilterPreAuthenticationProvider will take over this functionality - */ - private static function checkNewAccount( $user, &$message, $autocreate ) { - if ( $user->getName() == wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text() ) { - $message = wfMessage( 'abusefilter-accountreserved' )->text(); - - return false; - } - - $vars = new AbuseFilterVariableHolder; - - // Add variables only for a registered user, so IP addresses of - // new users won't be exposed - global $wgUser; - if ( !$autocreate && $wgUser->getId() ) { - $vars->addHolders( AbuseFilter::generateUserVars( $wgUser ) ); - } - - $vars->setVar( 'ACTION', $autocreate ? 'autocreateaccount' : 'createaccount' ); - $vars->setVar( 'ACCOUNTNAME', $user->getName() ); - - $filter_result = AbuseFilter::filterAction( - $vars, SpecialPage::getTitleFor( 'Userlogin' ) ); - - $message = $filter_result->isOK() ? '' : $filter_result->getWikiText(); - - return $filter_result->isOK(); - } - - /** - * @param User $user - * @param string $message - * @return bool - * @deprecated AbuseFilterPreAuthenticationProvider will take over this functionality - */ - public static function onAbortNewAccount( $user, &$message ) { - return self::checkNewAccount( $user, $message, false ); - } - - /** - * @param User $user - * @param string $message - * @return bool - * @deprecated AbuseFilterPreAuthenticationProvider will take over this functionality - */ - public static function onAbortAutoAccount( $user, &$message ) { - // FIXME: ERROR MESSAGE IS SHOWN IN A WEIRD WAY, BEACUSE $message - // HERE MEANS NAME OF THE MESSAGE, NOT THE TEXT OF THE MESSAGE AS - // IN AbortNewAccount HOOK WHICH WE CANNOT PROVIDE! - return self::checkNewAccount( $user, $message, true ); - } - - /** * @param RecentChange $recentChange - * @return bool */ public static function onRecentChangeSave( $recentChange ) { $title = Title::makeTitle( @@ -440,14 +381,12 @@ class AbuseFilterHooks { $action = $recentChange->mAttribs['rc_log_type'] ? $recentChange->mAttribs['rc_log_type'] : 'edit'; $actionID = implode( '-', [ - $title->getPrefixedText(), $recentChange->mAttribs['rc_user_text'], $action + $title->getPrefixedText(), $recentChange->getAttribute( 'rc_user_text' ), $action ] ); if ( isset( AbuseFilter::$tagsToSet[$actionID] ) ) { $recentChange->addTags( AbuseFilter::$tagsToSet[$actionID] ); } - - return true; } /** @@ -471,11 +410,11 @@ class AbuseFilterHooks { /** * @param array $tags * @param bool $enabled - * @return bool */ private static function fetchAllTags( array &$tags, $enabled ) { $services = MediaWikiServices::getInstance(); $cache = $services->getMainWANObjectCache(); + $fname = __METHOD__; $tags = $cache->getWithSetCallback( // Key to store the cached value under @@ -485,14 +424,14 @@ class AbuseFilterHooks { $cache::TTL_MINUTE, // Function that derives the new key value - function ( $oldValue, &$ttl, array &$setOpts ) use ( $enabled, $tags ) { + function ( $oldValue, &$ttl, array &$setOpts ) use ( $enabled, $tags, $fname ) { global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; $dbr = wfGetDB( DB_REPLICA ); // Account for any snapshot/replica DB lag $setOpts += Database::getCacheSetOptions( $dbr ); - # This is a pretty awful hack. + // This is a pretty awful hack. $where = [ 'afa_consequence' => 'tag', 'af_deleted' => false ]; if ( $enabled ) { @@ -502,7 +441,7 @@ class AbuseFilterHooks { [ 'abuse_filter_action', 'abuse_filter' ], 'afa_parameters', $where, - __METHOD__, + $fname, [], [ 'abuse_filter' => [ 'INNER JOIN', 'afa_filter=af_id' ] ] ); @@ -520,7 +459,7 @@ class AbuseFilterHooks { [ 'abuse_filter_action', 'abuse_filter' ], 'afa_parameters', $where, - __METHOD__, + $fname, [], [ 'abuse_filter' => [ 'INNER JOIN', 'afa_filter=af_id' ] ] ); @@ -536,31 +475,28 @@ class AbuseFilterHooks { } ); - return true; + $tags[] = 'abusefilter-condition-limit'; } /** - * @param string[] $tags - * @return bool + * @param string[] &$tags */ public static function onListDefinedTags( array &$tags ) { - return self::fetchAllTags( $tags, false ); + self::fetchAllTags( $tags, false ); } /** - * @param string[] $tags - * @return bool + * @param string[] &$tags */ public static function onChangeTagsListActive( array &$tags ) { - return self::fetchAllTags( $tags, true ); + self::fetchAllTags( $tags, true ); } /** - * @param DatabaseUpdater|null $updater + * @param DatabaseUpdater $updater * @throws MWException - * @return bool */ - public static function onLoadExtensionSchemaUpdates( $updater = null ) { + public static function onLoadExtensionSchemaUpdates( DatabaseUpdater $updater ) { $dir = dirname( __DIR__ ); if ( $updater->getDB()->getType() == 'mysql' || $updater->getDB()->getType() == 'sqlite' ) { @@ -649,22 +585,74 @@ class AbuseFilterHooks { $updater->addExtensionUpdate( [ 'addPgField', 'abuse_filter', 'af_global', 'SMALLINT NOT NULL DEFAULT 0' ] ); $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter', 'af_group', "TEXT NOT NULL DEFAULT 'default'" ] ); + $updater->addExtensionUpdate( [ + 'addPgExtIndex', 'abuse_filter', 'abuse_filter_group_enabled_id', + "(af_group, af_enabled, af_id)" + ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter_history', 'afh_group', "TEXT" ] ); + $updater->addExtensionUpdate( [ 'addPgField', 'abuse_filter_log', 'afl_wiki', 'TEXT' ] ); $updater->addExtensionUpdate( [ 'addPgField', 'abuse_filter_log', 'afl_deleted', 'SMALLINT' ] ); $updater->addExtensionUpdate( [ + 'setDefault', 'abuse_filter_log', 'afl_deleted', '0' ] ); + $updater->addExtensionUpdate( [ + 'changeNullableField', 'abuse_filter_log', 'afl_deleted', 'NOT NULL', true ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter_log', 'afl_patrolled_by', 'INTEGER' ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter_log', 'afl_rev_id', 'INTEGER' ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter_log', 'afl_log_id', 'INTEGER' ] ); + $updater->addExtensionUpdate( [ 'changeField', 'abuse_filter_log', 'afl_filter', 'TEXT', '' ] ); $updater->addExtensionUpdate( [ - 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_ip', "(afl_ip)" ] ); + 'changeField', 'abuse_filter_log', 'afl_namespace', "INTEGER", '' ] ); + $updater->addExtensionUpdate( [ + 'dropPgIndex', 'abuse_filter_log', 'abuse_filter_log_filter' ] ); + $updater->addExtensionUpdate( [ + 'dropPgIndex', 'abuse_filter_log', 'abuse_filter_log_ip' ] ); + $updater->addExtensionUpdate( [ + 'dropPgIndex', 'abuse_filter_log', 'abuse_filter_log_title' ] ); + $updater->addExtensionUpdate( [ + 'dropPgIndex', 'abuse_filter_log', 'abuse_filter_log_user' ] ); + $updater->addExtensionUpdate( [ + 'dropPgIndex', 'abuse_filter_log', 'abuse_filter_log_user_text' ] ); + $updater->addExtensionUpdate( [ + 'dropPgIndex', 'abuse_filter_log', 'abuse_filter_log_wiki' ] ); + $updater->addExtensionUpdate( [ + 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_filter_timestamp', + '(afl_filter,afl_timestamp)' + ] ); + $updater->addExtensionUpdate( [ + 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_user_timestamp', + '(afl_user,afl_user_text,afl_timestamp)' + ] ); + $updater->addExtensionUpdate( [ + 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_page_timestamp', + '(afl_namespace,afl_title,afl_timestamp)' + ] ); + $updater->addExtensionUpdate( [ + 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_ip_timestamp', + '(afl_ip, afl_timestamp)' + ] ); $updater->addExtensionUpdate( [ - 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_wiki', "(afl_wiki)" ] ); + 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_rev_id', + '(afl_rev_id)' + ] ); + $updater->addExtensionUpdate( [ + 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_log_id', + '(afl_log_id)' + ] ); $updater->addExtensionUpdate( [ - 'changeField', 'abuse_filter_log', 'afl_namespace', "INTEGER" ] ); + 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_wiki_timestamp', + '(afl_wiki,afl_timestamp)' + ] ); } $updater->addExtensionUpdate( [ [ __CLASS__, 'createAbuseFilterUser' ] ] ); - - return true; } /** @@ -678,26 +666,48 @@ class AbuseFilterHooks { if ( $user && !$updater->updateRowExists( 'create abusefilter-blocker-user' ) ) { $user = User::newSystemUser( $username, [ 'steal' => true ] ); $updater->insertUpdateRow( 'create abusefilter-blocker-user' ); - # Promote user so it doesn't look too crazy. + // Promote user so it doesn't look too crazy. $user->addGroup( 'sysop' ); } } /** - * @param $id + * @param int $id * @param Title $nt - * @param array $tools + * @param array &$tools * @param SpecialPage $sp for context */ - public static function onContributionsToolLinks( $id, $nt, &$tools, SpecialPage $sp ) { - if ( $sp->getUser()->isAllowed( 'abusefilter-log' ) ) { + public static function onContributionsToolLinks( $id, $nt, array &$tools, SpecialPage $sp ) { + $username = $nt->getText(); + if ( $sp->getUser()->isAllowed( 'abusefilter-log' ) && !IP::isValidRange( $username ) ) { $linkRenderer = $sp->getLinkRenderer(); $tools['abuselog'] = $linkRenderer->makeLink( SpecialPage::getTitleFor( 'AbuseLog' ), $sp->msg( 'abusefilter-log-linkoncontribs' )->text(), [ 'title' => $sp->msg( 'abusefilter-log-linkoncontribs-text', - $nt->getText() )->text() ], - [ 'wpSearchUser' => $nt->getText() ] + $username )->text() ], + [ 'wpSearchUser' => $username ] + ); + } + } + + /** + * @param IContextSource $context + * @param LinkRenderer $linkRenderer + * @param string[] &$links + */ + public static function onHistoryPageToolLinks( + IContextSource $context, + LinkRenderer $linkRenderer, + array &$links + ) { + $user = $context->getUser(); + if ( $user->isAllowed( 'abusefilter-log' ) ) { + $links[] = $linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseLog' ), + $context->msg( 'abusefilter-log-linkonhistory' )->text(), + [ 'title' => $context->msg( 'abusefilter-log-linkonhistory-text' )->text() ], + [ 'wpSearchTitle' => $context->getTitle()->getPrefixedText() ] ); } } @@ -756,7 +766,7 @@ class AbuseFilterHooks { $vars = new AbuseFilterVariableHolder; $vars->addHolders( AbuseFilter::generateUserVars( $user ), - AbuseFilter::generateTitleVars( $title, 'ARTICLE' ) + AbuseFilter::generateTitleVars( $title, 'PAGE' ) ); $vars->setVar( 'ACTION', $action ); @@ -768,7 +778,11 @@ class AbuseFilterHooks { $vars->setVar( 'file_size', $upload->getFileSize() ); $vars->setVar( 'file_mime', $props['mime'] ); - $vars->setVar( 'file_mediatype', MimeMagic::singleton()->getMediaType( null, $props['mime'] ) ); + $vars->setVar( + 'file_mediatype', + MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer() + ->getMediaType( null, $props['mime'] ) + ); $vars->setVar( 'file_width', $props['width'] ); $vars->setVar( 'file_height', $props['height'] ); $vars->setVar( 'file_bits_per_channel', $props['bits'] ); @@ -806,7 +820,7 @@ class AbuseFilterHooks { $vars->addHolders( AbuseFilter::getEditVars( $title, $page ) ); } - $filter_result = AbuseFilter::filterAction( $vars, $title ); + $filter_result = AbuseFilter::filterAction( $vars, $title, 'default', $user ); if ( !$filter_result->isOK() ) { $messageAndParams = $filter_result->getErrorsArray()[0]; @@ -824,8 +838,7 @@ class AbuseFilterHooks { /** * Adds global variables to the Javascript as needed * - * @param array $vars - * @return bool + * @param array &$vars */ public static function onMakeGlobalVariablesScript( array &$vars ) { if ( isset( AbuseFilter::$editboxName ) && AbuseFilter::$editboxName !== null ) { @@ -838,22 +851,17 @@ class AbuseFilterHooks { 'id' => AbuseFilterViewExamine::$examineId, ]; } - - return true; } /** * Tables that Extension:UserMerge needs to update * - * @param array $updateFields - * @return bool + * @param array &$updateFields */ public static function onUserMergeAccountFields( array &$updateFields ) { $updateFields[] = [ 'abuse_filter', 'af_user', 'af_user_text' ]; $updateFields[] = [ 'abuse_filter_log', 'afl_user', 'afl_user_text' ]; $updateFields[] = [ 'abuse_filter_history', 'afh_user', 'afh_user_text' ]; - - return true; } /** @@ -863,7 +871,7 @@ class AbuseFilterHooks { * @param Content $content * @param ParserOutput $output * @param string $summary - * @param User $user + * @param User|null $user */ public static function onParserOutputStashForEdit( WikiPage $page, Content $content, ParserOutput $output, $summary = '', $user = null @@ -880,9 +888,9 @@ class AbuseFilterHooks { // Cache any resulting filter matches. // Do this outside the synchronous stash lock to avoid any chance of slowdown. DeferredUpdates::addCallableUpdate( - function () use ( $user, $page, $summary, $content, $oldcontent, $text ) { + function () use ( $user, $page, $summary, $content, $text, $oldcontent ) { $vars = self::newVariableHolderForEdit( - $user, $page->getTitle(), $page, $summary, $content, $oldcontent, $text + $user, $page->getTitle(), $page, $summary, $content, $text, $oldcontent ); AbuseFilter::filterAction( $vars, $page->getTitle(), 'default', $user, 'stash' ); }, diff --git a/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php b/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php index 8a148f23..769c27d3 100644 --- a/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php +++ b/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php @@ -2,8 +2,15 @@ class AbuseFilterModifyLogFormatter extends LogFormatter { + /** + * @return string + */ protected function getMessageKey() { - return 'abusefilter-logentry-modify'; + $subtype = $this->entry->getSubtype(); + // Messages that can be used here: + // * abusefilter-logentry-create + // * abusefilter-logentry-modify + return "abusefilter-logentry-$subtype"; } /** diff --git a/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php b/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php index 9058c6ee..ed72c5a4 100644 --- a/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php +++ b/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php @@ -1,12 +1,25 @@ <?php use MediaWiki\Auth\AbstractPreAuthenticationProvider; +use MediaWiki\Auth\AuthenticationRequest; class AbuseFilterPreAuthenticationProvider extends AbstractPreAuthenticationProvider { + /** + * @param User $user + * @param User $creator + * @param AuthenticationRequest[] $reqs + * @return StatusValue + */ public function testForAccountCreation( $user, $creator, array $reqs ) { return $this->testUser( $user, $creator, false ); } + /** + * @param User $user + * @param bool|string $autocreate + * @param array $options + * @return StatusValue + */ public function testUserForCreation( $user, $autocreate, array $options = [] ) { // if this is not an autocreation, testForAccountCreation already handled it if ( $autocreate ) { diff --git a/AbuseFilter/includes/AbuseFilterSuppressLogFormatter.php b/AbuseFilter/includes/AbuseFilterSuppressLogFormatter.php new file mode 100644 index 00000000..b547b02b --- /dev/null +++ b/AbuseFilter/includes/AbuseFilterSuppressLogFormatter.php @@ -0,0 +1,16 @@ +<?php + +class AbuseFilterSuppressLogFormatter extends LogFormatter { + + /** + * @return string + */ + protected function getMessageKey() { + if ( $this->entry->getSubtype() === 'unhide-afl' ) { + return 'abusefilter-log-entry-unsuppress'; + } else { + return 'abusefilter-log-entry-suppress'; + } + } + +} diff --git a/AbuseFilter/includes/AbuseFilterVariableHolder.php b/AbuseFilter/includes/AbuseFilterVariableHolder.php index 8a1a1ee4..498c7b4e 100644 --- a/AbuseFilter/includes/AbuseFilterVariableHolder.php +++ b/AbuseFilter/includes/AbuseFilterVariableHolder.php @@ -1,9 +1,17 @@ <?php class AbuseFilterVariableHolder { + /** @var (AFPData|AFComputedVariable)[] */ public $mVars = []; - public static $varBlacklist = [ 'context' ]; + /** @var string[] Variables used to store meta-data, we'd better be safe. See T191715 */ + public static $varBlacklist = [ 'context', 'global_log_ids', 'local_log_ids' ]; + + /** @var int 2 is the default and means that new variables names (from T173889) should be used. + * 1 means that the old ones should be used, e.g. if this object is constructed from an + * afl_var_dump which still bears old variables. + */ + public $mVarsVersion = 2; public function __construct() { // Backwards-compatibility (unused now) @@ -11,10 +19,10 @@ class AbuseFilterVariableHolder { } /** - * @param $variable - * @param $datum + * @param string $variable + * @param mixed $datum */ - function setVar( $variable, $datum ) { + public function setVar( $variable, $datum ) { $variable = strtolower( $variable ); if ( !( $datum instanceof AFPData || $datum instanceof AFComputedVariable ) ) { $datum = AFPData::newFromPHPVar( $datum ); @@ -24,11 +32,11 @@ class AbuseFilterVariableHolder { } /** - * @param $variable - * @param $method - * @param $parameters + * @param string $variable + * @param string $method + * @param array $parameters */ - function setLazyLoadVar( $variable, $method, $parameters ) { + public function setLazyLoadVar( $variable, $method, $parameters ) { $placeholder = new AFComputedVariable( $method, $parameters ); $this->setVar( $variable, $placeholder ); } @@ -36,13 +44,19 @@ class AbuseFilterVariableHolder { /** * Get a variable from the current object * - * @param $variable string + * @param string $variable * @return AFPData */ - function getVar( $variable ) { + public function getVar( $variable ) { $variable = strtolower( $variable ); + if ( $this->mVarsVersion === 1 && in_array( $variable, AbuseFilter::getDeprecatedVariables() ) ) { + // Variables are stored with old names, but the parser has given us + // a new name. Translate it back. + $variable = array_search( $variable, AbuseFilter::getDeprecatedVariables() ); + } if ( isset( $this->mVars[$variable] ) ) { if ( $this->mVars[$variable] instanceof AFComputedVariable ) { + /** @suppress PhanUndeclaredMethod False positive */ $value = $this->mVars[$variable]->compute( $this ); $this->setVar( $variable, $value ); return $value; @@ -58,21 +72,12 @@ class AbuseFilterVariableHolder { */ public static function merge() { $newHolder = new AbuseFilterVariableHolder; - call_user_func_array( [ $newHolder, "addHolders" ], func_get_args() ); + $newHolder->addHolders( ...func_get_args() ); return $newHolder; } /** - * @param $addHolder - * @throws MWException - * @deprecated use addHolders() instead - */ - public function addHolder( $addHolder ) { - $this->addHolders( $addHolder ); - } - - /** * Merge any number of holders given as arguments into this holder. * * @throws MWException @@ -88,7 +93,7 @@ class AbuseFilterVariableHolder { } } - function __wakeup() { + public function __wakeup() { // Reset the context. $this->setVar( 'context', 'stored' ); } @@ -98,7 +103,7 @@ class AbuseFilterVariableHolder { * * @return string[] */ - function exportAllVars() { + public function exportAllVars() { $exported = []; foreach ( array_keys( $this->mVars ) as $varName ) { if ( !in_array( $varName, self::$varBlacklist ) ) { @@ -114,7 +119,7 @@ class AbuseFilterVariableHolder { * * @return string[] */ - function exportNonLazyVars() { + public function exportNonLazyVars() { $exported = []; foreach ( $this->mVars as $varName => $data ) { if ( @@ -134,8 +139,8 @@ class AbuseFilterVariableHolder { * either set $compute to an array with the name of the variable or set * $compute to true to compute all not yet set variables. * - * @param $compute array|bool Variables we should copute if not yet set - * @param $includeUserVars bool Include user set variables + * @param array|bool $compute Variables we should copute if not yet set + * @param bool $includeUserVars Include user set variables * @return array */ public function dumpAllVars( $compute = [], $includeUserVars = false ) { @@ -150,15 +155,18 @@ class AbuseFilterVariableHolder { $coreVariables = AbuseFilter::getBuilderValues(); $coreVariables = array_keys( $coreVariables['vars'] ); + $deprecatedVariables = array_keys( AbuseFilter::getDeprecatedVariables() ); + $coreVariables = array_merge( $coreVariables, $deprecatedVariables ); // Title vars can have several prefixes - $prefixes = [ 'ARTICLE', 'MOVED_FROM', 'MOVED_TO' ]; + $prefixes = [ 'MOVED_FROM', 'MOVED_TO', 'PAGE' ]; $titleVars = [ - '_ARTICLEID', + '_ID', '_NAMESPACE', - '_TEXT', - '_PREFIXEDTEXT', - '_recent_contributors' + '_TITLE', + '_PREFIXEDTITLE', + '_recent_contributors', + '_age', ]; foreach ( $wgRestrictionTypes as $action ) { $titleVars[] = "_restrictions_$action"; @@ -190,21 +198,25 @@ class AbuseFilterVariableHolder { } /** - * @param $var + * @param string $var * @return bool */ - function varIsSet( $var ) { + public function varIsSet( $var ) { return array_key_exists( $var, $this->mVars ); } /** * Compute all vars which need DB access. Useful for vars which are going to be saved * cross-wiki or used for offline analysis. + * + * @suppress PhanUndeclaredProperty for $value->mMethod (phan thinks $value is always AFPData) + * @suppress PhanUndeclaredMethod for $value->compute (phan thinks $value is always AFPData) */ - function computeDBVars() { + public function computeDBVars() { static $dbTypes = [ 'links-from-wikitext-or-database', 'load-recent-authors', + 'page-age', 'get-page-restrictions', 'simple-user-accessor', 'user-age', diff --git a/AbuseFilter/includes/AbuseLogHitFormatter.php b/AbuseFilter/includes/AbuseLogHitFormatter.php index 4daf0e57..a2fbb284 100644 --- a/AbuseFilter/includes/AbuseLogHitFormatter.php +++ b/AbuseFilter/includes/AbuseLogHitFormatter.php @@ -1,4 +1,5 @@ <?php + use MediaWiki\MediaWikiServices; /** @@ -17,9 +18,11 @@ class AbuseLogHitFormatter extends LogFormatter { $params = parent::getMessageParameters(); $filter_title = SpecialPage::getTitleFor( 'AbuseFilter', $entry['filter'] ); - $filter_caption = $this->msg( 'abusefilter-log-detailedentry-local' )->params( $entry['filter'] ); + $filter_caption = $this->msg( 'abusefilter-log-detailedentry-local' ) + ->params( $entry['filter'] ) + ->text(); $log_title = SpecialPage::getTitleFor( 'AbuseLog', $entry['log'] ); - $log_caption = $this->msg( 'abusefilter-log-detailslink' ); + $log_caption = $this->msg( 'abusefilter-log-detailslink' )->text(); $params[4] = $entry['action']; diff --git a/AbuseFilter/includes/TableDiffFormatterFullContext.php b/AbuseFilter/includes/TableDiffFormatterFullContext.php new file mode 100644 index 00000000..e962e9cf --- /dev/null +++ b/AbuseFilter/includes/TableDiffFormatterFullContext.php @@ -0,0 +1,36 @@ +<?php + +/** + * Like TableDiffFormatter, but will always render the full context + * (even for empty diffs). + * + * @private + */ +class TableDiffFormatterFullContext extends TableDiffFormatter { + /** + * Format a diff. + * + * @param Diff $diff + * @return string The formatted output. + */ + public function format( $diff ) { + $xlen = $ylen = 0; + + // Calculate the length of the left and the right side + foreach ( $diff->edits as $edit ) { + if ( $edit->orig ) { + $xlen += count( $edit->orig ); + } + if ( $edit->closing ) { + $ylen += count( $edit->closing ); + } + } + + // Just render the diff with no preprocessing + $this->startDiff(); + $this->block( 1, $xlen, 1, $ylen, $diff->edits ); + $end = $this->endDiff(); + + return $end; + } +} diff --git a/AbuseFilter/includes/Views/AbuseFilterView.php b/AbuseFilter/includes/Views/AbuseFilterView.php index b8d5fd57..9b019335 100644 --- a/AbuseFilter/includes/Views/AbuseFilterView.php +++ b/AbuseFilter/includes/Views/AbuseFilterView.php @@ -3,7 +3,7 @@ use Wikimedia\Rdbms\IDatabase; abstract class AbuseFilterView extends ContextSource { - public $mFilter, $mHistoryID, $mSubmit; + public $mFilter, $mHistoryID, $mSubmit, $mPage, $mParams; /** * @var \MediaWiki\Linker\LinkRenderer @@ -11,10 +11,10 @@ abstract class AbuseFilterView extends ContextSource { protected $linkRenderer; /** - * @param $page SpecialAbuseFilter - * @param $params array + * @param SpecialAbuseFilter $page + * @param array $params */ - function __construct( $page, $params ) { + public function __construct( $page, $params ) { $this->mPage = $page; $this->mParams = $params; $this->setContext( $this->mPage->getContext() ); @@ -25,11 +25,14 @@ abstract class AbuseFilterView extends ContextSource { * @param string $subpage * @return Title */ - function getTitle( $subpage = '' ) { + public function getTitle( $subpage = '' ) { return $this->mPage->getPageTitle( $subpage ); } - abstract function show(); + /** + * Function to show the page + */ + abstract public function show(); /** * @return bool @@ -63,11 +66,193 @@ abstract class AbuseFilterView extends ContextSource { } /** + * @param string $rules + * @param string $textName + * @param bool $addResultDiv + * @param bool $externalForm + * @param bool $needsModifyRights + * @param-taint $rules none + * @return string + */ + public function buildEditBox( + $rules, + $textName = 'wpFilterRules', + $addResultDiv = true, + $externalForm = false, + $needsModifyRights = true + ) { + $this->getOutput()->enableOOUI(); + + // Rules are in English + $editorAttrib = [ 'dir' => 'ltr' ]; + + $noTestAttrib = []; + $isUserAllowed = $needsModifyRights ? + $this->getUser()->isAllowed( 'abusefilter-modify' ) : + $this->canViewPrivate(); + if ( !$isUserAllowed ) { + $noTestAttrib['disabled'] = 'disabled'; + $addResultDiv = false; + } + + $rules = rtrim( $rules ) . "\n"; + $canEdit = $needsModifyRights ? $this->canEdit() : $this->canViewPrivate(); + $switchEditor = null; + + if ( ExtensionRegistry::getInstance()->isLoaded( 'CodeEditor' ) ) { + $editorAttrib['name'] = 'wpAceFilterEditor'; + $editorAttrib['id'] = 'wpAceFilterEditor'; + $editorAttrib['class'] = 'mw-abusefilter-editor'; + + $switchEditor = + new OOUI\ButtonWidget( + [ + 'label' => $this->msg( 'abusefilter-edit-switch-editor' )->text(), + 'id' => 'mw-abusefilter-switcheditor' + ] + $noTestAttrib + ); + + $rulesContainer = Xml::element( 'div', $editorAttrib, $rules ); + + // Dummy textarea for submitting form and to use in case JS is disabled + $textareaAttribs = []; + if ( !$canEdit ) { + $textareaAttribs['readonly'] = 'readonly'; + } + if ( $externalForm ) { + $textareaAttribs['form'] = 'wpFilterForm'; + } + $rulesContainer .= Xml::textarea( $textName, $rules, 40, 15, $textareaAttribs ); + + $editorConfig = AbuseFilter::getAceConfig( $canEdit ); + + // Add Ace configuration variable + $this->getOutput()->addJsConfigVars( 'aceConfig', $editorConfig ); + } else { + if ( !$canEdit ) { + $editorAttrib['readonly'] = 'readonly'; + } + if ( $externalForm ) { + $editorAttrib['form'] = 'wpFilterForm'; + } + $rulesContainer = Xml::textarea( $textName, $rules, 40, 15, $editorAttrib ); + } + + if ( $canEdit ) { + // Generate builder drop-down + $rawDropDown = AbuseFilter::getBuilderValues(); + + // The array needs to be rearranged to be understood by OOUI. It comes with the format + // [ group-msg-key => [ text-to-add => text-msg-key ] ] and we need it as + // [ group-msg => [ text-msg => text-to-add ] ] + // Also, the 'other' element must be the first one. + $dropDownOptions = [ $this->msg( 'abusefilter-edit-builder-select' )->text() => 'other' ]; + foreach ( $rawDropDown as $group => $values ) { + // Give grep a chance to find the usages: + // abusefilter-edit-builder-group-op-arithmetic, abusefilter-edit-builder-group-op-comparison, + // abusefilter-edit-builder-group-op-bool, abusefilter-edit-builder-group-misc, + // abusefilter-edit-builder-group-funcs, abusefilter-edit-builder-group-vars + $localisedGroup = $this->msg( "abusefilter-edit-builder-group-$group" )->text(); + $dropDownOptions[ $localisedGroup ] = array_flip( $values ); + $newKeys = array_map( + function ( $key ) use ( $group ) { + return $this->msg( "abusefilter-edit-builder-$group-$key" )->text(); + }, + array_keys( $dropDownOptions[ $localisedGroup ] ) + ); + $dropDownOptions[ $localisedGroup ] = array_combine( + $newKeys, $dropDownOptions[ $localisedGroup ] ); + } + + $dropDownList = Xml::listDropDownOptionsOoui( $dropDownOptions ); + $dropDown = new OOUI\DropdownInputWidget( [ + 'name' => 'wpFilterBuilder', + 'inputId' => 'wpFilterBuilder', + 'options' => $dropDownList + ] ); + + $formElements = [ new OOUI\FieldLayout( $dropDown ) ]; + + // Button for syntax check + $syntaxCheck = + new OOUI\ButtonWidget( + [ + 'label' => $this->msg( 'abusefilter-edit-check' )->text(), + 'id' => 'mw-abusefilter-syntaxcheck' + ] + $noTestAttrib + ); + + // Button for switching editor (if Ace is used) + if ( $switchEditor !== null ) { + $formElements[] = new OOUI\FieldLayout( + new OOUI\Widget( [ + 'content' => new OOUI\HorizontalLayout( [ + 'items' => [ $switchEditor, $syntaxCheck ] + ] ) + ] ) + ); + } else { + $formElements[] = new OOUI\FieldLayout( $syntaxCheck ); + } + + $fieldSet = new OOUI\FieldsetLayout( [ + 'items' => $formElements, + 'classes' => [ 'mw-abusefilter-edit-buttons', 'mw-abusefilter-javascript-tools' ] + ] ); + + $rulesContainer .= $fieldSet; + } + + if ( $addResultDiv ) { + $rulesContainer .= Xml::element( 'div', + [ 'id' => 'mw-abusefilter-syntaxresult', 'style' => 'display: none;' ], + ' ' ); + } + + // Add script + $this->getOutput()->addModules( 'ext.abuseFilter.edit' ); + AbuseFilter::$editboxName = $textName; + + return $rulesContainer; + } + + /** * @param IDatabase $db + * @param string|bool $action 'edit', 'move', 'createaccount', 'delete' or false for all * @return string */ - public function buildTestConditions( IDatabase $db ) { + public function buildTestConditions( IDatabase $db, $action = false ) { // If one of these is true, we're abusefilter compatible. + switch ( $action ) { + case 'edit': + return $db->makeList( [ + // Actually, this is only one condition, but this way we get it as string + 'rc_source' => [ + RecentChange::SRC_EDIT, + RecentChange::SRC_NEW, + ] + ], LIST_AND ); + case 'move': + return $db->makeList( [ + 'rc_source' => RecentChange::SRC_LOG, + 'rc_log_type' => 'move', + 'rc_log_action' => 'move' + ], LIST_AND ); + case 'createaccount': + return $db->makeList( [ + 'rc_source' => RecentChange::SRC_LOG, + 'rc_log_type' => 'newusers', + 'rc_log_action' => [ 'create', 'autocreate' ] + ], LIST_AND ); + case 'delete': + return $db->makeList( [ + 'rc_source' => RecentChange::SRC_LOG, + 'rc_log_type' => 'delete', + 'rc_log_action' => 'delete' + ], LIST_AND ); + // @ToDo: case 'upload' + } + return $db->makeList( [ 'rc_source' => [ RecentChange::SRC_EDIT, @@ -82,7 +267,7 @@ abstract class AbuseFilterView extends ContextSource { ], LIST_AND ), $db->makeList( [ 'rc_log_type' => 'newusers', - 'rc_log_action' => 'create' + 'rc_log_action' => [ 'create', 'autocreate' ] ], LIST_AND ), $db->makeList( [ 'rc_log_type' => 'delete', @@ -95,10 +280,21 @@ abstract class AbuseFilterView extends ContextSource { } /** - * @static + * @param string|int $id + * @param string|null $text + * @return string HTML + */ + public function getLinkToLatestDiff( $id, $text = null ) { + return $this->linkRenderer->makeKnownLink( + $this->getTitle( "history/$id/diff/prev/cur" ), + $text + ); + } + + /** * @return bool */ - static function canViewPrivate() { + public static function canViewPrivate() { global $wgUser; static $canView = null; @@ -110,102 +306,3 @@ abstract class AbuseFilterView extends ContextSource { } } - -class AbuseFilterChangesList extends OldChangesList { - /** - * @param $s - * @param $rc - * @param $classes array - */ - public function insertExtra( &$s, &$rc, &$classes ) { - if ( (int)$rc->getAttribute( 'rc_deleted' ) !== 0 ) { - $s .= ' ' . $this->msg( 'abusefilter-log-hidden-implicit' )->parse(); - if ( !$this->userCan( $rc, Revision::SUPPRESSED_ALL ) ) { - return; - } - } - - $examineParams = empty( $rc->examineParams ) ? [] : $rc->examineParams; - - $title = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/' . $rc->mAttribs['rc_id'] ); - $examineLink = $this->linkRenderer->makeLink( - $title, - new HtmlArmor( $this->msg( 'abusefilter-changeslist-examine' )->parse() ), - [], - $examineParams - ); - - $s .= ' '.$this->msg( 'parentheses' )->rawParams( $examineLink )->escaped(); - - # If we have a match.. - if ( isset( $rc->filterResult ) ) { - $class = $rc->filterResult ? - 'mw-abusefilter-changeslist-match' : - 'mw-abusefilter-changeslist-nomatch'; - - $classes[] = $class; - } - } - - /** - * Insert links to user page, user talk page and eventually a blocking link. - * Like the parent, but don't hide details if user can see them. - * - * @param string &$s HTML to update - * @param RecentChange &$rc - */ - public function insertUserRelatedLinks( &$s, &$rc ) { - $links = $this->getLanguage()->getDirMark() . Linker::userLink( $rc->mAttribs['rc_user'], - $rc->mAttribs['rc_user_text'] ) . - Linker::userToolLinks( $rc->mAttribs['rc_user'], $rc->mAttribs['rc_user_text'] ); - - if ( $this->isDeleted( $rc, Revision::DELETED_USER ) ) { - if ( $this->userCan( $rc, Revision::DELETED_USER ) ) { - $s .= ' <span class="history-deleted">' . $links . '</span>'; - } else { - $s .= ' <span class="history-deleted">' . - $this->msg( 'rev-deleted-user' )->escaped() . '</span>'; - } - } else { - $s .= $links; - } - } - - /** - * Insert a formatted comment. Like the parent, but don't hide details if user can see them. - * @param RecentChange $rc - * @return string - */ - public function insertComment( $rc ) { - if ( $this->isDeleted( $rc, Revision::DELETED_COMMENT ) ) { - if ( $this->userCan( $rc, Revision::DELETED_COMMENT ) ) { - return ' <span class="history-deleted">' . - Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() ) . '</span>'; - } else { - return ' <span class="history-deleted">' . - $this->msg( 'rev-deleted-comment' )->escaped() . '</span>'; - } - } else { - return Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() ); - } - } - - /** - * Insert a formatted action. The same as parent, but with a different audience in LogFormatter - * - * @param RecentChange $rc - * @return string - */ - public function insertLogEntry( $rc ) { - $formatter = LogFormatter::newFromRow( $rc->mAttribs ); - $formatter->setContext( $this->getContext() ); - $formatter->setAudience( LogFormatter::FOR_THIS_USER ); - $formatter->setShowUserToolLinks( true ); - $mark = $this->getLanguage()->getDirMark(); - return $formatter->getActionText() . " $mark" . $formatter->getComment(); - } - - // Kill rollback links. - public function insertRollback( &$s, &$rc ) { - } -} diff --git a/AbuseFilter/includes/Views/AbuseFilterViewDiff.php b/AbuseFilter/includes/Views/AbuseFilterViewDiff.php index 6c29664e..8128ad65 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewDiff.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewDiff.php @@ -1,100 +1,86 @@ <?php -/** - * Like TableDiffFormatter, but will always render the full context - * (even for empty diffs). - * - * @private - */ -class TableDiffFormatterFullContext extends TableDiffFormatter { - /** - * Format a diff. - * - * @param Diff $diff - * @return string The formatted output. - */ - function format( $diff ) { - $xlen = $ylen = 0; - - // Calculate the length of the left and the right side - foreach ( $diff->edits as $edit ) { - if ( $edit->orig ) { - $xlen += count( $edit->orig ); - } - if ( $edit->closing ) { - $ylen += count( $edit->closing ); - } - } - - // Just render the diff with no preprocessing - $this->startDiff(); - $this->block( 1, $xlen, 1, $ylen, $diff->edits ); - $end = $this->endDiff(); - - return $end; - } -} - class AbuseFilterViewDiff extends AbuseFilterView { public $mOldVersion = null; public $mNewVersion = null; public $mNextHistoryId = null; public $mFilter = null; - function show() { + /** + * Shows the page + */ + public function show() { $show = $this->loadData(); $out = $this->getOutput(); + $out->enableOOUI(); + $out->addModuleStyles( [ 'oojs-ui.styles.icons-movement' ] ); $links = []; if ( $this->mFilter ) { - $links['abusefilter-history-backedit'] = $this->getTitle( $this->mFilter ); - $links['abusefilter-diff-backhistory'] = $this->getTitle( 'history/' . $this->mFilter ); + $links['abusefilter-history-backedit'] = + $this->getTitle( $this->mFilter )->getFullURL(); + $links['abusefilter-diff-backhistory'] = + $this->getTitle( 'history/' . $this->mFilter )->getFullURL(); } - foreach ( $links as $msg => $title ) { - $links[$msg] = $this->linkRenderer->makeLink( $title, $this->msg( $msg )->text() ); + foreach ( $links as $msg => $href ) { + $links[$msg] = + new OOUI\ButtonWidget( [ + 'label' => $this->msg( $msg )->text(), + 'href' => $href + ] ); } - $backlinks = $this->getLanguage()->pipeList( $links ); - $out->addHTML( Xml::tags( 'p', null, $backlinks ) ); + $backlinks = + new OOUI\HorizontalLayout( [ + 'items' => $links + ] ); + $out->addHTML( $backlinks ); if ( $show ) { $out->addHTML( $this->formatDiff() ); - // Next and previous change links - $links = []; + $buttons = []; if ( AbuseFilter::getFirstFilterChange( $this->mFilter ) != $this->mOldVersion['meta']['history_id'] ) { // Create a "previous change" link if this isn't the first change of the given filter - $links[] = $this->linkRenderer->makeLink( - $this->getTitle( - 'history/' . $this->mFilter . '/diff/prev/' . $this->mOldVersion['meta']['history_id'] - ), - $this->getLanguage()->getArrow( 'backwards' ) . - ' ' . $this->msg( 'abusefilter-diff-prev' )->text() - ); + $href = $this->getTitle( + 'history/' . $this->mFilter . '/diff/prev/' . $this->mOldVersion['meta']['history_id'] + )->getFullURL(); + $buttons[] = new OOUI\ButtonWidget( [ + 'label' => $this->msg( 'abusefilter-diff-prev' )->text(), + 'href' => $href, + 'icon' => 'previous' + ] ); } if ( !is_null( $this->mNextHistoryId ) ) { // Create a "next change" link if this isn't the last change of the given filter - $links[] = $this->linkRenderer->makeLink( - $this->getTitle( - 'history/' . $this->mFilter . '/diff/prev/' . $this->mNextHistoryId - ), - $this->msg( 'abusefilter-diff-next' )->text() . - ' ' . $this->getLanguage()->getArrow( 'forwards' ) - ); + $href = $this->getTitle( + 'history/' . $this->mFilter . '/diff/prev/' . $this->mNextHistoryId + )->getFullURL(); + $buttons[] = new OOUI\ButtonWidget( [ + 'label' => $this->msg( 'abusefilter-diff-next' )->text(), + 'href' => $href, + 'icon' => 'next' + ] ); } - if ( count( $links ) > 0 ) { - $backlinks = $this->getLanguage()->pipeList( $links ); - $out->addHTML( Xml::tags( 'p', null, $backlinks ) ); + if ( count( $buttons ) > 0 ) { + $buttons = new OOUI\HorizontalLayout( [ + 'items' => $buttons, + 'classes' => [ 'mw-abusefilter-history-buttons' ] + ] ); + $out->addHTML( $buttons ); } } } - function loadData() { + /** + * @return bool + */ + public function loadData() { $oldSpec = $this->mParams[3]; $newSpec = $this->mParams[4]; $this->mFilter = $this->mParams[1]; @@ -127,7 +113,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { * @param int $historyId History id to find next change of * @return int|null Id of the next change or null if there isn't one */ - function getNextHistoryId( $historyId ) { + public function getNextHistoryId( $historyId ) { $dbr = wfGetDB( DB_REPLICA ); $row = $dbr->selectRow( 'abuse_filter_history', @@ -145,7 +131,12 @@ class AbuseFilterViewDiff extends AbuseFilterView { return null; } - function loadSpec( $spec, $otherSpec ) { + /** + * @param string $spec + * @param string $otherSpec + * @return array|null + */ + public function loadSpec( $spec, $otherSpec ) { static $dependentSpecs = [ 'prev', 'next' ]; static $cache = []; @@ -154,18 +145,31 @@ class AbuseFilterViewDiff extends AbuseFilterView { } $dbr = wfGetDB( DB_REPLICA ); + // All but afh_filter, afh_deleted and afh_changed_fields + $selectFields = [ + 'afh_id', + 'afh_user', + 'afh_user_text', + 'afh_timestamp', + 'afh_pattern', + 'afh_comments', + 'afh_flags', + 'afh_public_comments', + 'afh_actions', + 'afh_group', + ]; $row = null; if ( is_numeric( $spec ) ) { $row = $dbr->selectRow( 'abuse_filter_history', - '*', + $selectFields, [ 'afh_id' => $spec, 'afh_filter' => $this->mFilter ], __METHOD__ ); } elseif ( $spec == 'cur' ) { $row = $dbr->selectRow( 'abuse_filter_history', - '*', + $selectFields, [ 'afh_filter' => $this->mFilter ], __METHOD__, [ 'ORDER BY' => 'afh_timestamp desc' ] @@ -176,7 +180,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { $row = $dbr->selectRow( 'abuse_filter_history', - '*', + $selectFields, [ 'afh_filter' => $this->mFilter, 'afh_id<' . $dbr->addQuotes( $other['meta']['history_id'] ), @@ -196,7 +200,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { $row = $dbr->selectRow( 'abuse_filter_history', - '*', + $selectFields, [ 'afh_filter' => $this->mFilter, 'afh_id>' . $dbr->addQuotes( $other['meta']['history_id'] ), @@ -222,7 +226,11 @@ class AbuseFilterViewDiff extends AbuseFilterView { return $data; } - function loadFromHistoryRow( $row ) { + /** + * @param stdClass $row + * @return array + */ + public function loadFromHistoryRow( $row ) { return [ 'meta' => [ 'history_id' => $row->afh_id, @@ -246,7 +254,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { * @param int $history_id * @return string */ - function formatVersionLink( $timestamp, $history_id ) { + public function formatVersionLink( $timestamp, $history_id ) { $filter = $this->mFilter; $text = $this->getLanguage()->timeanddate( $timestamp, true ); $title = $this->getTitle( "history/$filter/item/$history_id" ); @@ -259,7 +267,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { /** * @return string */ - function formatDiff() { + public function formatDiff() { $oldVersion = $this->mOldVersion; $newVersion = $this->mNewVersion; @@ -300,17 +308,17 @@ class AbuseFilterViewDiff extends AbuseFilterView { $headings = Xml::tags( 'tr', null, $headings ); + $body = ''; // Basic info + $infoHeader = $this->getHeaderRow( 'abusefilter-diff-info' ); $info = ''; - $info .= $this->getHeaderRow( 'abusefilter-diff-info' ); $info .= $this->getDiffRow( 'abusefilter-edit-description', $oldVersion['info']['description'], $newVersion['info']['description'] ); - global $wgAbuseFilterValidGroups; if ( - count( $wgAbuseFilterValidGroups ) > 1 || + count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 || $oldVersion['info']['group'] != $newVersion['info']['group'] ) { $info .= $this->getDiffRow( @@ -331,29 +339,43 @@ class AbuseFilterViewDiff extends AbuseFilterView { $newVersion['info']['notes'] ); + if ( $info !== '' ) { + $body .= $infoHeader . $info; + } + // Pattern - $info .= $this->getHeaderRow( 'abusefilter-diff-pattern' ); - $info .= $this->getDiffRow( + $patternHeader = $this->getHeaderRow( 'abusefilter-diff-pattern' ); + $pattern = ''; + $pattern .= $this->getDiffRow( 'abusefilter-edit-rules', $oldVersion['pattern'], - $newVersion['pattern'], - 'text' + $newVersion['pattern'] ); + if ( $pattern !== '' ) { + $body .= $patternHeader . $pattern; + } + // Actions + $actionsHeader = $this->getHeaderRow( 'abusefilter-edit-consequences' ); + $actions = ''; + $oldActions = $this->stringifyActions( $oldVersion['actions'] ); $newActions = $this->stringifyActions( $newVersion['actions'] ); - $info .= $this->getHeaderRow( 'abusefilter-edit-consequences' ); - $info .= $this->getDiffRow( + $actions .= $this->getDiffRow( 'abusefilter-edit-consequences', $oldActions, $newActions ); + if ( $actions !== '' ) { + $body .= $actionsHeader . $actions; + } + $html = "<table class='wikitable'> <thead>$headings</thead> - <tbody>$info</tbody> + <tbody>$body</tbody> </table>"; $html = Xml::tags( 'h2', null, $this->msg( 'abusefilter-diff-title' )->parse() ) . $html; @@ -365,7 +387,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { * @param array $actions * @return array */ - function stringifyActions( $actions ) { + public function stringifyActions( $actions ) { $lines = []; ksort( $actions ); @@ -384,7 +406,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { * @param string $msg * @return string */ - function getHeaderRow( $msg ) { + public function getHeaderRow( $msg ) { $html = $this->msg( $msg )->parse(); $html = Xml::tags( 'th', [ 'colspan' => 3 ], $html ); $html = Xml::tags( 'tr', [ 'class' => 'mw-abusefilter-diff-header' ], $html ); @@ -398,7 +420,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { * @param array|string $new * @return string */ - function getDiffRow( $msg, $old, $new ) { + public function getDiffRow( $msg, $old, $new ) { if ( !is_array( $old ) ) { $old = explode( "\n", preg_replace( "/\\\r\\\n?/", "\n", $old ) ); } @@ -406,6 +428,10 @@ class AbuseFilterViewDiff extends AbuseFilterView { $new = explode( "\n", preg_replace( "/\\\r\\\n?/", "\n", $new ) ); } + if ( $old === $new ) { + return ''; + } + $diffEngine = new DifferenceEngine( $this->getContext() ); $diffEngine->showDiffStyle(); diff --git a/AbuseFilter/includes/Views/AbuseFilterViewEdit.php b/AbuseFilter/includes/Views/AbuseFilterViewEdit.php index 952fcce7..e4c5a93e 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewEdit.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewEdit.php @@ -1,87 +1,25 @@ <?php class AbuseFilterViewEdit extends AbuseFilterView { + public static $mLoadedRow = null, $mLoadedActions = null; /** * @param SpecialAbuseFilter $page * @param array $params */ - function __construct( $page, $params ) { + public function __construct( $page, $params ) { parent::__construct( $page, $params ); $this->mFilter = $page->mFilter; $this->mHistoryID = $page->mHistoryID; } - /// @todo When older versions of MediaWiki are no longer - /// supported, remove this method and call ChangeTags::isTagNameValid directly - /// Because it's planned for removal, this is private. /** - * Check whether the characters in the tag name are valid. - * - * @param string $tag Tag name - * @return Status + * Shows the page */ - private static function isTagNameValid( $tag ) { - if ( is_callable( 'ChangeTags::isTagNameValid' ) ) { - $status = ChangeTags::isTagNameValid( $tag ); - } else { - // BC - if ( strpos( $tag, ',' ) !== false || strpos( $tag, '|' ) !== false || - strpos( $tag, '/' ) !== false || - !Title::makeTitleSafe( NS_MEDIAWIKI, "tag-{$tag}-description" ) - ) { - $status = Status::newFatal( 'abusefilter-edit-bad-tags' ); - } else { - $status = Status::newGood(); - } - } - - return $status; - } - - /** - * Check whether a filter is allowed to use a tag - * - * @param string $tag Tag name - * @return Status - */ - protected function isAllowedTag( $tag ) { - $tagNameStatus = self::isTagNameValid( $tag ); - - if ( !$tagNameStatus->isGood() ) { - return $tagNameStatus; - } - - $finalStatus = Status::newGood(); - - $canAddStatus = - ChangeTags::canAddTagsAccompanyingChange( - [ $tag ] - ); - - if ( $canAddStatus->isGood() ) { - return $finalStatus; - } - - $alreadyDefinedTags = []; - AbuseFilterHooks::onListDefinedTags( $alreadyDefinedTags ); - - if ( in_array( $tag, $alreadyDefinedTags, true ) ) { - return $finalStatus; - } - - $canCreateTagStatus = ChangeTags::canCreateTag( $tag ); - if ( $canCreateTagStatus->isGood() ) { - return $finalStatus; - } - - $finalStatus->fatal( 'abusefilter-edit-bad-tags' ); - return $finalStatus; - } - - function show() { + public function show() { $user = $this->getUser(); $out = $this->getOutput(); $request = $this->getRequest(); + $config = $this->getConfig(); $out->setPageTitle( $this->msg( 'abusefilter-edit' ) ); $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); @@ -104,11 +42,17 @@ class AbuseFilterViewEdit extends AbuseFilterView { } } - // Add default warning messages - $this->exposeWarningMessages(); + // Add the default warning and disallow messages in a JS variable + $this->exposeMessages(); if ( $filter == 'new' && !$this->canEdit() ) { - $out->addWikiMsg( 'abusefilter-edit-notallowed' ); + $out->addHTML( + Xml::tags( + 'p', + null, + Html::errorBox( $this->msg( 'abusefilter-edit-notallowed' )->parse() ) + ) + ); return; } @@ -117,246 +61,55 @@ class AbuseFilterViewEdit extends AbuseFilterView { $editToken, [ 'abusefilter', $filter ], $request ); if ( $tokenMatches && $this->canEdit() ) { - // Check syntax - $syntaxerr = AbuseFilter::checkSyntax( $request->getVal( 'wpFilterRules' ) ); - if ( $syntaxerr !== true ) { - $out->addHTML( - $this->buildFilterEditor( - $this->msg( - 'abusefilter-edit-badsyntax', - [ $syntaxerr[0] ] - )->parseAsBlock(), - $filter, $history_id - ) - ); - return; - } - - $dbw = wfGetDB( DB_MASTER ); - list( $newRow, $actions ) = $this->loadRequest( $filter ); - - $differences = AbuseFilter::compareVersions( - [ $newRow, $actions ], - [ $newRow->mOriginalRow, $newRow->mOriginalActions ] - ); - - // Don't allow adding a new global rule, or updating a - // rule that is currently global, without permissions. - if ( !$this->canEditFilter( $newRow ) || !$this->canEditFilter( $newRow->mOriginalRow ) ) { - $out->addWikiMsg( 'abusefilter-edit-notallowed-global' ); - return; - } - - // Don't allow custom messages on global rules - if ( $newRow->af_global == 1 && - $request->getVal( 'wpFilterWarnMessage' ) !== 'abusefilter-warning' - ) { - $out->addWikiMsg( 'abusefilter-edit-notallowed-global-custom-msg' ); - return; - } - - $origActions = $newRow->mOriginalActions; - $wasGlobal = (bool)$newRow->mOriginalRow->af_global; - - unset( $newRow->mOriginalRow ); - unset( $newRow->mOriginalActions ); - - // Check for non-changes - if ( !count( $differences ) ) { - $out->redirect( $this->getTitle()->getLocalURL() ); - return; - } - - // Check for restricted actions - global $wgAbuseFilterRestrictions; - if ( count( array_intersect_key( - array_filter( $wgAbuseFilterRestrictions ), - array_merge( - array_filter( $actions ), - array_filter( $origActions ) - ) - ) ) - && !$user->isAllowed( 'abusefilter-modify-restricted' ) - ) { - $out->addHTML( - $this->buildFilterEditor( - $this->msg( 'abusefilter-edit-restricted' )->parseAsBlock(), - $this->mFilter, - $history_id - ) - ); - return; - } - - // If we've activated the 'tag' option, check the arguments for validity. - if ( !empty( $actions['tag'] ) ) { - foreach ( $actions['tag']['parameters'] as $tag ) { - $status = $this->isAllowedTag( $tag ); - - if ( !$status->isGood() ) { - $out->addHTML( - $this->buildFilterEditor( - $status->getMessage()->parseAsBlock(), - $this->mFilter, - $history_id - ) - ); - return; - } + $status = AbuseFilter::saveFilter( $this, $filter, $request, $newRow, $actions ); + if ( !$status->isGood() ) { + $err = $status->getErrors(); + $msg = $err[0]['message']; + $params = $err[0]['params']; + if ( $status->isOK() ) { + $out->addHTML( + $this->buildFilterEditor( + $this->msg( $msg, $params )->parseAsBlock(), + $filter, + $history_id + ) + ); + } else { + $out->addWikiMsg( $msg ); } - } - - $newRow = get_object_vars( $newRow ); // Convert from object to array - - // Set last modifier. - $newRow['af_timestamp'] = $dbw->timestamp( wfTimestampNow() ); - $newRow['af_user'] = $user->getId(); - $newRow['af_user_text'] = $user->getName(); - - $dbw->startAtomic( __METHOD__ ); - - // Insert MAIN row. - if ( $filter == 'new' ) { - $new_id = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' ); - $is_new = true; } else { - $new_id = $this->mFilter; - $is_new = false; - } - - // Reset throttled marker, if we're re-enabling it. - $newRow['af_throttled'] = $newRow['af_throttled'] && !$newRow['af_enabled']; - $newRow['af_id'] = $new_id; // ID. - - // T67807 - // integer 1's & 0's might be better understood than booleans - $newRow['af_enabled'] = (int)$newRow['af_enabled']; - $newRow['af_hidden'] = (int)$newRow['af_hidden']; - $newRow['af_throttled'] = (int)$newRow['af_throttled']; - $newRow['af_deleted'] = (int)$newRow['af_deleted']; - $newRow['af_global'] = (int)$newRow['af_global']; - - $dbw->replace( 'abuse_filter', [ 'af_id' ], $newRow, __METHOD__ ); - - if ( $is_new ) { - $new_id = $dbw->insertId(); - } - - // Actions - global $wgAbuseFilterActions; - $deadActions = []; - $actionsRows = []; - foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { - // Check if it's set - $enabled = isset( $actions[$action] ) && (bool)$actions[$action]; - - if ( $enabled ) { - $parameters = $actions[$action]['parameters']; - - $thisRow = [ - 'afa_filter' => $new_id, - 'afa_consequence' => $action, - 'afa_parameters' => implode( "\n", $parameters ) - ]; - $actionsRows[] = $thisRow; + if ( $status->getValue() === false ) { + // No change + $out->redirect( $this->getTitle()->getLocalURL() ); } else { - $deadActions[] = $action; - } - } - - // Create a history row - $afh_row = []; - - foreach ( AbuseFilter::$history_mappings as $af_col => $afh_col ) { - $afh_row[$afh_col] = $newRow[$af_col]; - } - - // Actions - $displayActions = []; - foreach ( $actions as $action ) { - $displayActions[$action['action']] = $action['parameters']; - } - $afh_row['afh_actions'] = serialize( $displayActions ); - - $afh_row['afh_changed_fields'] = implode( ',', $differences ); - - // Flags - $flags = []; - if ( $newRow['af_hidden'] ) { - $flags[] = 'hidden'; - } - if ( $newRow['af_enabled'] ) { - $flags[] = 'enabled'; - } - if ( $newRow['af_deleted'] ) { - $flags[] = 'deleted'; - } - if ( $newRow['af_global'] ) { - $flags[] = 'global'; - } - - $afh_row['afh_flags'] = implode( ',', $flags ); - - $afh_row['afh_filter'] = $new_id; - $afh_row['afh_id'] = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' ); - - // Do the update - $dbw->insert( 'abuse_filter_history', $afh_row, __METHOD__ ); - $history_id = $dbw->insertId(); - if ( $filter != 'new' ) { - $dbw->delete( - 'abuse_filter_action', - [ 'afa_filter' => $filter ], - __METHOD__ - ); - } - $dbw->insert( 'abuse_filter_action', $actionsRows, __METHOD__ ); - - $dbw->endAtomic( __METHOD__ ); - - // Invalidate cache if this was a global rule - if ( $wasGlobal || $newRow['af_global'] ) { - $group = 'default'; - if ( isset( $newRow['af_group'] ) && $newRow['af_group'] != '' ) { - $group = $newRow['af_group']; + list( $new_id, $history_id ) = $status->getValue(); + $out->redirect( + $this->getTitle()->getLocalURL( + [ + 'result' => 'success', + 'changedfilter' => $new_id, + 'changeid' => $history_id, + ] + ) + ); } - - $globalRulesKey = AbuseFilter::getGlobalRulesKey( $group ); - ObjectCache::getMainWANInstance()->touchCheckKey( $globalRulesKey ); } - - // Logging - $logEntry = new ManualLogEntry( 'abusefilter', 'modify' ); - $logEntry->setPerformer( $user ); - $logEntry->setTarget( $this->getTitle( $new_id ) ); - $logEntry->setParameters( [ - 'historyId' => $history_id, - 'newId' => $new_id - ] ); - $logid = $logEntry->insert(); - $logEntry->publish( $logid ); - - // Purge the tag list cache so the fetchAllTags hook applies tag changes - if ( isset( $actions['tag'] ) ) { - AbuseFilterHooks::purgeTagCache(); - } - - AbuseFilter::resetFilterProfile( $new_id ); - - $out->redirect( - $this->getTitle()->getLocalURL( - [ - 'result' => 'success', - 'changedfilter' => $new_id, - 'changeid' => $history_id, - ] - ) - ); } else { if ( $tokenMatches ) { - // lost rights meanwhile - $out->addWikiMsg( 'abusefilter-edit-notallowed' ); + // Lost rights meanwhile + $out->addHTML( + Xml::tags( + 'p', + null, + Html::errorBox( $this->msg( 'abusefilter-edit-notallowed' )->parse() ) + ) + ); + } elseif ( $request->wasPosted() ) { + // Warn the user to re-attempt save + $out->addHTML( + Html::warningBox( $this->msg( 'abusefilter-edit-token-not-match' )->escaped() ) + ); } if ( $history_id ) { @@ -377,19 +130,21 @@ class AbuseFilterViewEdit extends AbuseFilterView { * Builds the full form for edit filters. * Loads data either from the database or from the HTTP request. * The request takes precedence over the database - * @param $error string An error message to show above the filter box. - * @param $filter int The filter ID - * @param $history_id int The history ID of the filter, if applicable. Otherwise null + * @param string|null $error An error message to show above the filter box. + * @param int $filter The filter ID + * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null * @return bool|string False if there is a failure building the editor, * otherwise the HTML text for the editor. */ - function buildFilterEditor( $error, $filter, $history_id = null ) { + public function buildFilterEditor( $error, $filter, $history_id = null ) { if ( $filter === null ) { return false; } // Build the edit form $out = $this->getOutput(); + $out->enableOOUI(); + $out->addJsConfigVars( 'isFilterEditor', true ); $lang = $this->getLanguage(); $user = $this->getUser(); @@ -397,9 +152,19 @@ class AbuseFilterViewEdit extends AbuseFilterView { list( $row, $actions ) = $this->loadRequest( $filter, $history_id ); if ( !$row ) { - $out->addWikiMsg( 'abusefilter-edit-badfilter' ); - $out->addHTML( $this->linkRenderer->makeLink( $this->getTitle(), - $this->msg( 'abusefilter-return' )->text() ) ); + $out->addHTML( + Xml::tags( + 'p', + null, + Html::errorBox( $this->msg( 'abusefilter-edit-badfilter' )->parse() ) + ) + ); + $href = $this->getTitle()->getFullURL(); + $btn = new OOUI\ButtonWidget( [ + 'label' => $this->msg( 'abusefilter-return' )->text(), + 'href' => $href + ] ); + $out->addHTML( $btn ); return false; } @@ -412,60 +177,57 @@ class AbuseFilterViewEdit extends AbuseFilterView { if ( ( ( isset( $row->af_hidden ) && $row->af_hidden ) || AbuseFilter::filterHidden( $filter ) ) && !$this->canViewPrivate() ) { - return $this->msg( 'abusefilter-edit-denied' )->text(); + return $this->msg( 'abusefilter-edit-denied' )->escaped(); } $output = ''; if ( $error ) { - $out->addHTML( "<span class=\"error\">$error</span>" ); + $output .= Html::errorBox( $error ); } // Read-only attribute $readOnlyAttrib = []; - $cbReadOnlyAttrib = []; // For checkboxes - - $styleAttrib = [ 'style' => 'width:95%' ]; if ( !$this->canEditFilter( $row ) ) { - $readOnlyAttrib['readonly'] = 'readonly'; - $cbReadOnlyAttrib['disabled'] = 'disabled'; + $readOnlyAttrib['disabled'] = 'disabled'; } $fields = []; $fields['abusefilter-edit-id'] = $this->mFilter == 'new' ? - $this->msg( 'abusefilter-edit-new' )->text() : + $this->msg( 'abusefilter-edit-new' )->escaped() : $lang->formatNum( $filter ); $fields['abusefilter-edit-description'] = - Xml::input( - 'wpFilterDescription', - 45, - isset( $row->af_public_comments ) ? $row->af_public_comments : '', - array_merge( $readOnlyAttrib, $styleAttrib ) - ); - - global $wgAbuseFilterValidGroups; - if ( count( $wgAbuseFilterValidGroups ) > 1 ) { - $groupSelector = new XmlSelect( - 'wpFilterGroup', - 'mw-abusefilter-edit-group-input', - 'default' + new OOUI\TextInputWidget( [ + 'name' => 'wpFilterDescription', + 'value' => $row->af_public_comments ?? '' + ] + $readOnlyAttrib ); + $validGroups = $this->getConfig()->get( 'AbuseFilterValidGroups' ); + if ( count( $validGroups ) > 1 ) { + $groupSelector = + new OOUI\DropdownInputWidget( [ + 'name' => 'wpFilterGroup', + 'id' => 'mw-abusefilter-edit-group-input', + 'value' => 'default', + 'disabled' => !empty( $readOnlyAttrib ) + ] ); + + $options = []; if ( isset( $row->af_group ) && $row->af_group ) { - $groupSelector->setDefault( $row->af_group ); + $groupSelector->setValue( $row->af_group ); } - foreach ( $wgAbuseFilterValidGroups as $group ) { - $groupSelector->addOption( AbuseFilter::nameGroup( $group ), $group ); + foreach ( $validGroups as $group ) { + $options += [ AbuseFilter::nameGroup( $group ) => $group ]; } - if ( !empty( $readOnlyAttrib ) ) { - $groupSelector->setAttribute( 'disabled', 'disabled' ); - } + $options = Xml::listDropDownOptionsOoui( $options ); + $groupSelector->setOptions( $options ); - $fields['abusefilter-edit-group'] = $groupSelector->getHTML(); + $fields['abusefilter-edit-group'] = $groupSelector; } // Hit count display @@ -484,14 +246,13 @@ class AbuseFilterViewEdit extends AbuseFilterView { if ( $filter !== 'new' ) { // Statistics - global $wgAbuseFilterProfile; $stash = ObjectCache::getMainStashInstance(); $matches_count = (int)$stash->get( AbuseFilter::filterMatchesKey( $filter ) ); $total = (int)$stash->get( AbuseFilter::filterUsedKey( $row->af_group ) ); if ( $total > 0 ) { $matches_percent = sprintf( '%.2f', 100 * $matches_count / $total ); - if ( $wgAbuseFilterProfile ) { + if ( $this->getConfig()->get( 'AbuseFilterProfile' ) ) { list( $timeProfile, $condProfile ) = AbuseFilter::getFilterProfile( $filter ); $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status-profile' ) ->numParams( $total, $matches_count, $matches_percent, $timeProfile, $condProfile ) @@ -504,35 +265,32 @@ class AbuseFilterViewEdit extends AbuseFilterView { } } - $fields['abusefilter-edit-rules'] = AbuseFilter::buildEditBox( + $fields['abusefilter-edit-rules'] = $this->buildEditBox( $row->af_pattern, 'wpFilterRules', - true, - $this->canEditFilter( $row ) - ); - $fields['abusefilter-edit-notes'] = Xml::textarea( - 'wpFilterNotes', - ( isset( $row->af_comments ) ? $row->af_comments . "\n" : "\n" ), - 40, 15, - $readOnlyAttrib + true ); + $fields['abusefilter-edit-notes'] = + new OOUI\MultilineTextInputWidget( [ + 'name' => 'wpFilterNotes', + 'value' => isset( $row->af_comments ) ? $row->af_comments . "\n" : "\n", + 'rows' => 15 + ] + $readOnlyAttrib + ); - // Build checkboxen + // Build checkboxes $checkboxes = [ 'hidden', 'enabled', 'deleted' ]; $flags = ''; - global $wgAbuseFilterIsCentral; - if ( $wgAbuseFilterIsCentral ) { + if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) { $checkboxes[] = 'global'; } if ( isset( $row->af_throttled ) && $row->af_throttled ) { - global $wgAbuseFilterRestrictions; - $filterActions = explode( ',', $row->af_actions ); $throttledActions = array_intersect_key( array_flip( $filterActions ), - array_filter( $wgAbuseFilterRestrictions ) + array_filter( $this->getConfig()->get( 'AbuseFilterRestrictions' ) ) ); if ( $throttledActions ) { @@ -544,9 +302,11 @@ class AbuseFilterViewEdit extends AbuseFilterView { ); $flags .= $out->parse( - $this->msg( 'abusefilter-edit-throttled-warning' ) - ->plaintextParams( $lang->commaList( $throttledActions ) ) - ->text() + Html::warningBox( + $this->msg( 'abusefilter-edit-throttled-warning' ) + ->plaintextParams( $lang->commaList( $throttledActions ) ) + ->text() + ) ); } } @@ -561,18 +321,39 @@ class AbuseFilterViewEdit extends AbuseFilterView { $dbField = "af_$checkboxId"; $postVar = 'wpFilter' . ucfirst( $checkboxId ); + $checkboxAttribs = [ + 'name' => $postVar, + 'id' => $postVar, + 'selected' => $row->$dbField ?? false, + ] + $readOnlyAttrib; + $labelAttribs = [ + 'label' => $this->msg( $message )->text(), + 'align' => 'inline', + ]; + if ( $checkboxId == 'global' && !$this->canEditGlobal() ) { - $cbReadOnlyAttrib['disabled'] = 'disabled'; + $checkboxAttribs['disabled'] = 'disabled'; } - $checkbox = Xml::checkLabel( - $this->msg( $message )->text(), - $postVar, - $postVar, - isset( $row->$dbField ) ? $row->$dbField : false, - $cbReadOnlyAttrib - ); - $checkbox = Xml::tags( 'p', null, $checkbox ); + // Set readonly on deleted if the filter isn't disabled + if ( $checkboxId == 'deleted' && $row->af_enabled == 1 ) { + $checkboxAttribs['disabled'] = 'disabled'; + } + + // Add infusable where needed + if ( $checkboxId == 'deleted' || $checkboxId == 'enabled' ) { + $checkboxAttribs['infusable'] = true; + if ( $checkboxId == 'deleted' ) { + $labelAttribs['id'] = $postVar . 'Label'; + $labelAttribs['infusable'] = true; + } + } + + $checkbox = + new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( $checkboxAttribs ), + $labelAttribs + ); $flags .= $checkbox; } @@ -604,15 +385,24 @@ class AbuseFilterViewEdit extends AbuseFilterView { $userLink = Linker::userLink( $row->af_user, $row->af_user_text ) . Linker::userToolLinks( $row->af_user, $row->af_user_text ); - $userName = $row->af_user_text; $fields['abusefilter-edit-lastmod'] = $this->msg( 'abusefilter-edit-lastmod-text' ) ->rawParams( - $lang->timeanddate( $row->af_timestamp, true ), + $this->getLinkToLatestDiff( + $filter, + $lang->timeanddate( $row->af_timestamp, true ) + ), $userLink, - $lang->date( $row->af_timestamp, true ), - $lang->time( $row->af_timestamp, true ), - $userName + $this->getLinkToLatestDiff( + $filter, + $lang->date( $row->af_timestamp, true ) + ), + $this->getLinkToLatestDiff( + $filter, + $lang->time( $row->af_timestamp, true ) + ) + )->params( + wfEscapeWikiText( $row->af_user_text ) )->parse(); $history_display = new HtmlArmor( $this->msg( 'abusefilter-edit-viewhistory' )->parse() ); $fields['abusefilter-edit-history'] = @@ -623,10 +413,13 @@ class AbuseFilterViewEdit extends AbuseFilterView { $exportText = FormatJson::encode( [ 'row' => $row, 'actions' => $actions ] ); $tools .= Xml::tags( 'a', [ 'href' => '#', 'id' => 'mw-abusefilter-export-link' ], $this->msg( 'abusefilter-edit-export' )->parse() ); - $tools .= Xml::element( 'textarea', - [ 'readonly' => 'readonly', 'id' => 'mw-abusefilter-export' ], - $exportText - ); + $tools .= + new OOUI\MultilineTextInputWidget( [ + 'id' => 'mw-abusefilter-export', + 'readOnly' => true, + 'value' => $exportText, + 'rows' => 10 + ] ); $fields['abusefilter-edit-tools'] = $tools; @@ -638,10 +431,14 @@ class AbuseFilterViewEdit extends AbuseFilterView { ); if ( $this->canEditFilter( $row ) ) { - $form .= Xml::submitButton( - $this->msg( 'abusefilter-edit-save' )->text(), - [ 'accesskey' => 's' ] - ); + $form .= + new OOUI\ButtonInputWidget( [ + 'type' => 'submit', + 'label' => $this->msg( 'abusefilter-edit-save' )->text(), + 'useInputTag' => true, + 'accesskey' => 's', + 'flags' => [ 'progressive', 'primary' ] + ] ); $form .= Html::hidden( 'wpEditToken', $user->getEditToken( [ 'abusefilter', $filter ] ) @@ -663,15 +460,15 @@ class AbuseFilterViewEdit extends AbuseFilterView { /** * Builds the "actions" editor for a given filter. - * @param $row stdClass A row from the abuse_filter table. - * @param $actions Array of rows from the abuse_filter_action table + * @param stdClass $row A row from the abuse_filter table. + * @param array $actions Array of rows from the abuse_filter_action table * corresponding to the abuse filter held in $row. - * @return HTML text for an action editor. + * @return string HTML text for an action editor. */ - function buildConsequenceEditor( $row, $actions ) { - global $wgAbuseFilterActions; - - $enabledActions = array_filter( $wgAbuseFilterActions ); + public function buildConsequenceEditor( $row, $actions ) { + $enabledActions = array_filter( + $this->getConfig()->get( 'AbuseFilterActions' ) + ); $setActions = []; foreach ( $enabledActions as $action => $_ ) { @@ -681,9 +478,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { $output = ''; foreach ( $enabledActions as $action => $_ ) { - MediaWiki\suppressWarnings(); + Wikimedia\suppressWarnings(); $params = $actions[$action]['parameters']; - MediaWiki\restoreWarnings(); + Wikimedia\restoreWarnings(); $output .= $this->buildConsequenceSelector( $action, $setActions[$action], $params, $row ); } @@ -692,39 +489,45 @@ class AbuseFilterViewEdit extends AbuseFilterView { } /** - * @param $action string The action to build an editor for - * @param $set bool Whether or not the action is activated - * @param $parameters array Action parameters - * @param $row stdClass abuse_filter row object - * @return string + * @param string $action The action to build an editor for + * @param bool $set Whether or not the action is activated + * @param array $parameters Action parameters + * @param stdClass $row abuse_filter row object + * @return string|\OOUI\FieldLayout */ - function buildConsequenceSelector( $action, $set, $parameters, $row ) { - global $wgAbuseFilterActions, $wgMainCacheType; - - if ( empty( $wgAbuseFilterActions[$action] ) ) { + public function buildConsequenceSelector( $action, $set, $parameters, $row ) { + $config = $this->getConfig(); + $actions = $config->get( 'AbuseFilterActions' ); + if ( empty( $actions[$action] ) ) { return ''; } $readOnlyAttrib = []; - $cbReadOnlyAttrib = []; // For checkboxes if ( !$this->canEditFilter( $row ) ) { - $readOnlyAttrib['readonly'] = 'readonly'; - $cbReadOnlyAttrib['disabled'] = 'disabled'; + $readOnlyAttrib['disabled'] = 'disabled'; } switch ( $action ) { case 'throttle': // Throttling is only available via object caching - if ( $wgMainCacheType === CACHE_NONE ) { + if ( $config->get( 'MainCacheType' ) === CACHE_NONE ) { return ''; } - $throttleSettings = Xml::checkLabel( - $this->msg( 'abusefilter-edit-action-throttle' )->text(), - 'wpFilterActionThrottle', - "mw-abusefilter-action-checkbox-$action", - $set, - [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib ); + $throttleSettings = + new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpFilterActionThrottle', + 'id' => 'mw-abusefilter-action-checkbox-throttle', + 'selected' => $set, + 'classes' => [ 'mw-abusefilter-action-checkbox' ] + ] + $readOnlyAttrib + ), + [ + 'label' => $this->msg( 'abusefilter-edit-action-throttle' )->text(), + 'align' => 'inline' + ] + ); $throttleFields = []; if ( $set ) { @@ -733,94 +536,188 @@ class AbuseFilterViewEdit extends AbuseFilterView { $throttleCount = $throttleRate[0]; $throttlePeriod = $throttleRate[1]; - $throttleGroups = implode( "\n", array_slice( $parameters, 1 ) ); + $throttleGroups = array_slice( $parameters, 1 ); } else { $throttleCount = 3; $throttlePeriod = 60; - $throttleGroups = "user\n"; + $throttleGroups = [ 'user' ]; } $throttleFields['abusefilter-edit-throttle-count'] = - Xml::input( 'wpFilterThrottleCount', 20, $throttleCount, $readOnlyAttrib ); + new OOUI\FieldLayout( + new OOUI\TextInputWidget( [ + 'type' => 'number', + 'name' => 'wpFilterThrottleCount', + 'value' => $throttleCount + ] + $readOnlyAttrib + ), + [ + 'label' => $this->msg( 'abusefilter-edit-throttle-count' )->text() + ] + ); $throttleFields['abusefilter-edit-throttle-period'] = - $this->msg( 'abusefilter-edit-throttle-seconds' ) - ->rawParams( Xml::input( 'wpFilterThrottlePeriod', 20, $throttlePeriod, - $readOnlyAttrib ) - )->parse(); - $throttleFields['abusefilter-edit-throttle-groups'] = - Xml::textarea( 'wpFilterThrottleGroups', $throttleGroups . "\n", - 40, 5, $readOnlyAttrib ); + new OOUI\FieldLayout( + new OOUI\TextInputWidget( [ + 'type' => 'number', + 'name' => 'wpFilterThrottlePeriod', + 'value' => $throttlePeriod + ] + $readOnlyAttrib + ), + [ + 'label' => $this->msg( 'abusefilter-edit-throttle-period' )->text() + ] + ); + + $throttleConfig = [ + 'values' => $throttleGroups, + 'label' => $this->msg( 'abusefilter-edit-throttle-groups' )->parse(), + 'disabled' => $readOnlyAttrib + ]; + $this->getOutput()->addJsConfigVars( 'throttleConfig', $throttleConfig ); + + $hiddenGroups = + new OOUI\FieldLayout( + new OOUI\MultilineTextInputWidget( [ + 'name' => 'wpFilterThrottleGroups', + 'value' => implode( "\n", $throttleGroups ), + 'rows' => 5, + 'placeholder' => $this->msg( 'abusefilter-edit-throttle-hidden-placeholder' )->text(), + 'infusable' => true, + 'id' => 'mw-abusefilter-hidden-throttle-field' + ] + $readOnlyAttrib + ), + [ + 'label' => new OOUI\HtmlSnippet( + $this->msg( 'abusefilter-edit-throttle-groups' )->parse() + ), + 'align' => 'top', + 'id' => 'mw-abusefilter-hidden-throttle' + ] + ); + + $throttleFields['abusefilter-edit-throttle-groups'] = $hiddenGroups; + $throttleSettings .= Xml::tags( 'div', [ 'id' => 'mw-abusefilter-throttle-parameters' ], - Xml::buildForm( $throttleFields ) + new OOUI\FieldsetLayout( [ 'items' => $throttleFields ] ) ); return $throttleSettings; + case 'disallow': case 'warn': - global $wgAbuseFilterDefaultWarningMessage; $output = ''; - $checkbox = Xml::checkLabel( - $this->msg( 'abusefilter-edit-action-warn' )->text(), - 'wpFilterActionWarn', - "mw-abusefilter-action-checkbox-$action", - $set, - [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib ); - $output .= Xml::tags( 'p', null, $checkbox ); - if ( $set ) { - $warnMsg = $parameters[0]; + $formName = $action === 'warn' ? 'wpFilterActionWarn' : 'wpFilterActionDisallow'; + $checkbox = + new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => $formName, + // mw-abusefilter-action-checkbox-warn, mw-abusefilter-action-checkbox-disallow + 'id' => "mw-abusefilter-action-checkbox-$action", + 'selected' => $set, + 'classes' => [ 'mw-abusefilter-action-checkbox' ] + ] + $readOnlyAttrib + ), + [ + // abusefilter-edit-action-warn, abusefilter-edit-action-disallow + 'label' => $this->msg( "abusefilter-edit-action-$action" )->text(), + 'align' => 'inline' + ] + ); + $output .= $checkbox; + $defaultWarnMsg = $config->get( 'AbuseFilterDefaultWarningMessage' ); + $defaultDisallowMsg = $config->get( 'AbuseFilterDefaultDisallowMessage' ); + + if ( $set && isset( $parameters[0] ) ) { + $msg = $parameters[0]; } elseif ( $row && - isset( $row->af_group ) && $row->af_group && - isset( $wgAbuseFilterDefaultWarningMessage[$row->af_group] ) + isset( $row->af_group ) && $row->af_group && ( + ( $action === 'warn' && isset( $defaultWarnMsg[$row->af_group] ) ) || + ( $action === 'disallow' && isset( $defaultDisallowMsg[$row->af_group] ) ) + ) ) { - $warnMsg = $wgAbuseFilterDefaultWarningMessage[$row->af_group]; + $msg = $action === 'warn' ? $defaultWarnMsg[$row->af_group] : + $defaultDisallowMsg[$row->af_group]; } else { - $warnMsg = 'abusefilter-warning'; + $msg = $action === 'warn' ? 'abusefilter-warning' : 'abusefilter-disallowed'; } - $warnFields['abusefilter-edit-warn-message'] = - $this->getExistingSelector( $warnMsg, !empty( $readOnlyAttrib ) ); - $warnFields['abusefilter-edit-warn-other-label'] = - Xml::input( - 'wpFilterWarnMessageOther', - 45, - $warnMsg, - [ 'id' => 'mw-abusefilter-warn-message-other' ] + $cbReadOnlyAttrib + $fields["abusefilter-edit-$action-message"] = + $this->getExistingSelector( $msg, !empty( $readOnlyAttrib ), $action ); + $otherFieldName = $action === 'warn' ? 'wpFilterWarnMessageOther' + : 'wpFilterDisallowMessageOther'; + + $fields["abusefilter-edit-$action-other-label"] = + new OOUI\FieldLayout( + new OOUI\TextInputWidget( [ + 'name' => $otherFieldName, + 'value' => $msg, + // mw-abusefilter-warn-message-other, mw-abusefilter-disallow-message-other + 'id' => "mw-abusefilter-$action-message-other", + 'infusable' => true + ] + $readOnlyAttrib + ), + [ + 'label' => new OOUI\HtmlSnippet( + // abusefilter-edit-warn-other-label, abusefilter-edit-disallow-other-label + $this->msg( "abusefilter-edit-$action-other-label" )->parse() + ) + ] ); - $previewButton = Xml::element( - 'input', - [ - 'type' => 'button', - 'id' => 'mw-abusefilter-warn-preview-button', - 'value' => $this->msg( 'abusefilter-edit-warn-preview' )->text() - ] - ); - $editButton = ''; - if ( $this->getUser()->isAllowed( 'editinterface' ) ) { - $editButton .= ' ' . Xml::element( - 'input', - [ - 'type' => 'button', - 'id' => 'mw-abusefilter-warn-edit-button', - 'value' => $this->msg( 'abusefilter-edit-warn-edit' )->text() + $previewButton = + new OOUI\ButtonInputWidget( [ + // abusefilter-edit-warn-preview, abusefilter-edit-disallow-preview + 'label' => $this->msg( "abusefilter-edit-$action-preview" )->text(), + // mw-abusefilter-warn-preview-button, mw-abusefilter-disallow-preview-button + 'id' => "mw-abusefilter-$action-preview-button", + 'infusable' => true, + 'flags' => 'progressive' ] ); + + $buttonGroup = $previewButton; + if ( $this->getUser()->isAllowed( 'editinterface' ) ) { + $editButton = + new OOUI\ButtonInputWidget( [ + // abusefilter-edit-warn-edit, abusefilter-edit-disallow-edit + 'label' => $this->msg( "abusefilter-edit-$action-edit" )->text(), + // mw-abusefilter-warn-edit-button, mw-abusefilter-disallow-edit-button + 'id' => "mw-abusefilter-$action-edit-button" + ] + ); + $buttonGroup = + new OOUI\Widget( [ + 'content' => + new OOUI\HorizontalLayout( [ + 'items' => [ $previewButton, $editButton ], + 'classes' => [ + 'mw-abusefilter-preview-buttons', + 'mw-abusefilter-javascript-tools' + ] + ] ) + ] ); } - $previewHolder = Xml::element( + $previewHolder = Xml::tags( 'div', - [ 'id' => 'mw-abusefilter-warn-preview' ], '' + [ + // mw-abusefilter-warn-preview, mw-abusefilter-disallow-preview + 'id' => "mw-abusefilter-$action-preview", + 'style' => 'display:none' + ], + '' ); - $warnFields['abusefilter-edit-warn-actions'] = - Xml::tags( 'p', null, $previewButton . $editButton ) . "\n$previewHolder"; + $fields["abusefilter-edit-$action-actions"] = $buttonGroup; $output .= Xml::tags( 'div', - [ 'id' => 'mw-abusefilter-warn-parameters' ], - Xml::buildForm( $warnFields ) - ); + // mw-abusefilter-warn-parameters, mw-abusefilter-disallow-parameters + [ 'id' => "mw-abusefilter-$action-parameters" ], + new OOUI\FieldsetLayout( [ 'items' => $fields ] ) + ) . $previewHolder; + return $output; case 'tag': if ( $set ) { @@ -830,71 +727,225 @@ class AbuseFilterViewEdit extends AbuseFilterView { } $output = ''; - $checkbox = Xml::checkLabel( - $this->msg( 'abusefilter-edit-action-tag' )->text(), - 'wpFilterActionTag', - "mw-abusefilter-action-checkbox-$action", - $set, - [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib - ); - $output .= Xml::tags( 'p', null, $checkbox ); - - $tagFields['abusefilter-edit-tag-tag'] = - Xml::textarea( 'wpFilterTags', implode( "\n", $tags ), 40, 5, $readOnlyAttrib ); + $checkbox = + new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpFilterActionTag', + 'id' => 'mw-abusefilter-action-checkbox-tag', + 'selected' => $set, + 'classes' => [ 'mw-abusefilter-action-checkbox' ] + ] + $readOnlyAttrib + ), + [ + 'label' => $this->msg( 'abusefilter-edit-action-tag' )->text(), + 'align' => 'inline' + ] + ); + $output .= $checkbox; + + $tagConfig = [ + 'values' => $tags, + 'label' => $this->msg( 'abusefilter-edit-tag-tag' )->parse(), + 'disabled' => $readOnlyAttrib + ]; + $this->getOutput()->addJsConfigVars( 'tagConfig', $tagConfig ); + + $hiddenTags = + new OOUI\FieldLayout( + new OOUI\MultilineTextInputWidget( [ + 'name' => 'wpFilterTags', + 'value' => implode( ',', $tags ), + 'rows' => 5, + 'placeholder' => $this->msg( 'abusefilter-edit-tag-hidden-placeholder' )->text(), + 'infusable' => true, + 'id' => 'mw-abusefilter-hidden-tag-field' + ] + $readOnlyAttrib + ), + [ + 'label' => new OOUI\HtmlSnippet( + $this->msg( 'abusefilter-edit-tag-tag' )->parse() + ), + 'align' => 'top', + 'id' => 'mw-abusefilter-hidden-tag' + ] + ); $output .= Xml::tags( 'div', [ 'id' => 'mw-abusefilter-tag-parameters' ], - Xml::buildForm( $tagFields ) + $hiddenTags + ); + return $output; + case 'block': + if ( $set && count( $parameters ) === 3 ) { + // Both blocktalk and custom block durations available + $blockTalk = $parameters[0]; + $defaultAnonDuration = $parameters[1]; + $defaultUserDuration = $parameters[2]; + } else { + if ( $set && count( $parameters ) === 1 ) { + // Only blocktalk available + $blockTalk = $parameters[0]; + } + if ( $config->get( 'AbuseFilterAnonBlockDuration' ) ) { + $defaultAnonDuration = $config->get( 'AbuseFilterAnonBlockDuration' ); + } else { + $defaultAnonDuration = $config->get( 'AbuseFilterBlockDuration' ); + } + $defaultUserDuration = $config->get( 'AbuseFilterBlockDuration' ); + } + $suggestedBlocks = SpecialBlock::getSuggestedDurations( null, false ); + $suggestedBlocks = self::normalizeBlocks( $suggestedBlocks ); + + $output = ''; + $checkbox = + new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpFilterActionBlock', + 'id' => 'mw-abusefilter-action-checkbox-block', + 'selected' => $set, + 'classes' => [ 'mw-abusefilter-action-checkbox' ] + ] + $readOnlyAttrib + ), + [ + 'label' => $this->msg( 'abusefilter-edit-action-block' )->text(), + 'align' => 'inline' + ] + ); + $output .= $checkbox; + + $suggestedBlocks = Xml::listDropDownOptionsOoui( $suggestedBlocks ); + + $anonDuration = + new OOUI\DropdownInputWidget( [ + 'name' => 'wpBlockAnonDuration', + 'options' => $suggestedBlocks, + 'value' => $defaultAnonDuration, + 'disabled' => !$this->canEditFilter( $row ) + ] ); + + $userDuration = + new OOUI\DropdownInputWidget( [ + 'name' => 'wpBlockUserDuration', + 'options' => $suggestedBlocks, + 'value' => $defaultUserDuration, + 'disabled' => !$this->canEditFilter( $row ) + ] ); + + $blockOptions = []; + if ( $config->get( 'BlockAllowsUTEdit' ) === true ) { + $talkCheckbox = + new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpFilterBlockTalk', + 'id' => 'mw-abusefilter-action-checkbox-blocktalk', + 'selected' => isset( $blockTalk ) && $blockTalk == 'blocktalk', + 'classes' => [ 'mw-abusefilter-action-checkbox' ] + ] + $readOnlyAttrib + ), + [ + 'label' => $this->msg( 'abusefilter-edit-action-blocktalk' )->text(), + 'align' => 'left' + ] + ); + + $blockOptions['abusefilter-edit-block-options'] = $talkCheckbox; + } + $blockOptions['abusefilter-edit-block-anon-durations'] = + new OOUI\FieldLayout( + $anonDuration, + [ + 'label' => $this->msg( 'abusefilter-edit-block-anon-durations' )->text() + ] ); + $blockOptions['abusefilter-edit-block-user-durations'] = + new OOUI\FieldLayout( + $userDuration, + [ + 'label' => $this->msg( 'abusefilter-edit-block-user-durations' )->text() + ] + ); + + $output .= Xml::tags( + 'div', + [ 'id' => 'mw-abusefilter-block-parameters' ], + new OOUI\FieldsetLayout( [ 'items' => $blockOptions ] ) + ); + return $output; + default: // Give grep a chance to find the usages: - // abusefilter-edit-action-warn, abusefilter-edit-action-disallow - // abusefilter-edit-action-blockautopromote - // abusefilter-edit-action-degroup, abusefilter-edit-action-block - // abusefilter-edit-action-throttle, abusefilter-edit-action-rangeblock - // abusefilter-edit-action-tag + // abusefilter-edit-action-disallow, + // abusefilter-edit-action-blockautopromote, + // abusefilter-edit-action-degroup, + // abusefilter-edit-action-rangeblock, $message = 'abusefilter-edit-action-' . $action; $form_field = 'wpFilterAction' . ucfirst( $action ); $status = $set; - $thisAction = Xml::checkLabel( - $this->msg( $message )->text(), - $form_field, - "mw-abusefilter-action-checkbox-$action", - $status, - [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib - ); - $thisAction = Xml::tags( 'p', null, $thisAction ); + $thisAction = + new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => $form_field, + 'id' => "mw-abusefilter-action-checkbox-$action", + 'selected' => $status, + 'classes' => [ 'mw-abusefilter-action-checkbox' ] + ] + $readOnlyAttrib + ), + [ + 'label' => $this->msg( $message )->text(), + 'align' => 'inline' + ] + ); + $thisAction = $thisAction; return $thisAction; } } /** - * @param $warnMsg string - * @param $readOnly bool - * @return string + * @param string $warnMsg + * @param bool $readOnly + * @param string $action + * @return \OOUI\FieldLayout */ - function getExistingSelector( $warnMsg, $readOnly = false ) { - $existingSelector = new XmlSelect( - 'wpFilterWarnMessage', - 'mw-abusefilter-warn-message-existing', - $warnMsg == 'abusefilter-warning' ? 'abusefilter-warning' : 'other' - ); + public function getExistingSelector( $warnMsg, $readOnly = false, $action = 'warn' ) { + if ( $action === 'warn' ) { + $action = 'warning'; + $formId = 'warn'; + $inputName = 'wpFilterWarnMessage'; + } elseif ( $action === 'disallow' ) { + $action = 'disallowed'; + $formId = 'disallow'; + $inputName = 'wpFilterDisallowMessage'; + } else { + throw new MWException( "Unexpected action value $action" ); + } - $existingSelector->addOption( 'abusefilter-warning' ); + $existingSelector = + new OOUI\DropdownInputWidget( [ + 'name' => $inputName, + // mw-abusefilter-warn-message-existing, mw-abusefilter-disallow-message-existing + 'id' => "mw-abusefilter-$formId-message-existing", + // abusefilter-warning, abusefilter-disallowed + 'value' => $warnMsg == "abusefilter-$action" ? "abusefilter-$action" : 'other', + 'infusable' => true + ] ); + + // abusefilter-warning, abusefilter-disallowed + $options = [ "abusefilter-$action" => "abusefilter-$action" ]; if ( $readOnly ) { - $existingSelector->setAttribute( 'disabled', 'disabled' ); + $existingSelector->setDisabled( true ); } else { // Find other messages. $dbr = wfGetDB( DB_REPLICA ); + $pageTitlePrefix = "Abusefilter-$action"; $res = $dbr->select( 'page', [ 'page_title' ], [ 'page_namespace' => 8, - 'page_title LIKE ' . $dbr->addQuotes( 'Abusefilter-warning%' ) + 'page_title LIKE ' . $dbr->addQuotes( $pageTitlePrefix . '%' ) ], __METHOD__ ); @@ -902,27 +953,87 @@ class AbuseFilterViewEdit extends AbuseFilterView { $lang = $this->getLanguage(); foreach ( $res as $row ) { if ( $lang->lcfirst( $row->page_title ) == $lang->lcfirst( $warnMsg ) ) { - $existingSelector->setDefault( $lang->lcfirst( $warnMsg ) ); + $existingSelector->setValue( $lang->lcfirst( $warnMsg ) ); } - if ( $row->page_title != 'Abusefilter-warning' ) { - $existingSelector->addOption( $lang->lcfirst( $row->page_title ) ); + if ( $row->page_title != "Abusefilter-$action" ) { + $options += [ $lang->lcfirst( $row->page_title ) => $lang->lcfirst( $row->page_title ) ]; } } } - $existingSelector->addOption( $this->msg( 'abusefilter-edit-warn-other' )->text(), 'other' ); + // abusefilter-edit-warn-other, abusefilter-edit-disallow-other + $options += [ $this->msg( "abusefilter-edit-$formId-other" )->text() => 'other' ]; + + $options = Xml::listDropDownOptionsOoui( $options ); + $existingSelector->setOptions( $options ); + + $existingSelector = + new OOUI\FieldLayout( + $existingSelector, + [ + // abusefilter-edit-warn-message, abusefilter-edit-disallow-message + 'label' => $this->msg( "abusefilter-edit-$formId-message" )->text() + ] + ); + + return $existingSelector; + } + + /** + * @todo Maybe we should also check if global values belong to $durations + * and determine the right point to add them if missing. + * + * @param array $durations + * @return array + */ + protected static function normalizeBlocks( $durations ) { + global $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration; + // We need to have same values since it may happen that ipblocklist + // and one (or both) of the global variables use different wording + // for the same duration. In such case, when setting the default of + // the dropdowns it would fail. + $anonDuration = self::getAbsoluteBlockDuration( $wgAbuseFilterAnonBlockDuration ); + $userDuration = self::getAbsoluteBlockDuration( $wgAbuseFilterBlockDuration ); + foreach ( $durations as &$duration ) { + $currentDuration = self::getAbsoluteBlockDuration( $duration ); + + if ( $duration !== $wgAbuseFilterBlockDuration && + $currentDuration === $userDuration ) { + $duration = $wgAbuseFilterBlockDuration; + + } elseif ( $duration !== $wgAbuseFilterAnonBlockDuration && + $currentDuration === $anonDuration ) { + $duration = $wgAbuseFilterAnonBlockDuration; + } + } + + return $durations; + } - return $existingSelector->getHTML(); + /** + * Converts a string duration to an absolute timestamp, i.e. unrelated to the current + * time, taking into account infinity durations as well. The second parameter of + * strtotime is set to 0 in order to convert the duration in seconds (instead of + * a timestamp), thus making it unaffected by the execution time of the code. + * + * @param string $duration + * @return string|int + */ + protected static function getAbsoluteBlockDuration( $duration ) { + if ( wfIsInfinity( $duration ) ) { + return 'infinity'; + } + return strtotime( $duration, 0 ); } /** * Loads filter data from the database by ID. - * @param $id int The filter's ID number + * @param int $id The filter's ID number * @return array|null Either an associative array representing the filter, * or NULL if the filter does not exist. */ - function loadFilterData( $id ) { + public function loadFilterData( $id ) { if ( $id == 'new' ) { $obj = new stdClass; $obj->af_pattern = ''; @@ -969,8 +1080,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { // Load the actions $actions = []; - $res = $dbr->select( 'abuse_filter_action', - '*', + $res = $dbr->select( + 'abuse_filter_action', + [ 'afa_consequence', 'afa_parameters' ], [ 'afa_filter' => $id ], __METHOD__ ); @@ -978,7 +1090,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { foreach ( $res as $actionRow ) { $thisAction = []; $thisAction['action'] = $actionRow->afa_consequence; - $thisAction['parameters'] = explode( "\n", $actionRow->afa_parameters ); + $thisAction['parameters'] = array_filter( explode( "\n", $actionRow->afa_parameters ) ); $actions[$actionRow->afa_consequence] = $thisAction; } @@ -991,21 +1103,21 @@ class AbuseFilterViewEdit extends AbuseFilterView { * Either from the HTTP request or from the filter/history_id given. * The HTTP request always takes precedence. * Includes caching. - * @param $filter int The filter ID being requested. - * @param $history_id int If any, the history ID being requested. - * @return Array with filter data if available, otherwise null. + * @param int $filter The filter ID being requested. + * @param int|null $history_id If any, the history ID being requested. + * @return array|null Array with filter data if available, otherwise null. * The first element contains the abuse_filter database row, * the second element is an array of related abuse_filter_action rows. */ - function loadRequest( $filter, $history_id = null ) { - static $row = null; - static $actions = null; + public function loadRequest( $filter, $history_id = null ) { + $row = self::$mLoadedRow; + $actions = self::$mLoadedActions; $request = $this->getRequest(); if ( !is_null( $actions ) && !is_null( $row ) ) { return [ $row, $actions ]; } elseif ( $request->wasPosted() ) { - # Nothing, we do it all later + // Nothing, we do it all later } elseif ( $history_id ) { return $this->loadHistoryItem( $history_id ); } else { @@ -1051,18 +1163,17 @@ class AbuseFilterViewEdit extends AbuseFilterView { $row->af_group = $request->getVal( 'wpFilterGroup', 'default' ); - $row->af_deleted = $request->getBool( 'wpFilterDeleted' ); - $row->af_enabled = $request->getBool( 'wpFilterEnabled' ) && !$row->af_deleted; - $row->af_hidden = $request->getBool( 'wpFilterHidden' ); - global $wgAbuseFilterIsCentral; - $row->af_global = $request->getBool( 'wpFilterGlobal' ) && $wgAbuseFilterIsCentral; + $row->af_deleted = $request->getCheck( 'wpFilterDeleted' ); + $row->af_enabled = $request->getCheck( 'wpFilterEnabled' ); + $row->af_hidden = $request->getCheck( 'wpFilterHidden' ); + $row->af_global = $request->getCheck( 'wpFilterGlobal' ) + && $this->getConfig()->get( 'AbuseFilterIsCentral' ); // Actions - global $wgAbuseFilterActions; $actions = []; - foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { + foreach ( array_filter( $this->getConfig()->get( 'AbuseFilterActions' ) ) as $action => $_ ) { // Check if it's set - $enabled = $request->getBool( 'wpFilterAction' . ucfirst( $action ) ); + $enabled = $request->getCheck( 'wpFilterAction' . ucfirst( $action ) ); if ( $enabled ) { $parameters = []; @@ -1071,10 +1182,20 @@ class AbuseFilterViewEdit extends AbuseFilterView { // We need to load the parameters $throttleCount = $request->getIntOrNull( 'wpFilterThrottleCount' ); $throttlePeriod = $request->getIntOrNull( 'wpFilterThrottlePeriod' ); - $throttleGroups = explode( "\n", - trim( $request->getText( 'wpFilterThrottleGroups' ) ) ); + // First explode with \n, which is the delimiter used in the textarea + $rawGroups = explode( "\n", $request->getText( 'wpFilterThrottleGroups' ) ); + // Trim any space, both as an actual group and inside subgroups + $throttleGroups = []; + foreach ( $rawGroups as $group ) { + if ( strpos( $group, ',' ) !== false ) { + $subGroups = explode( ',', $group ); + $throttleGroups[] = implode( ',', array_map( 'trim', $subGroups ) ); + } elseif ( trim( $group ) !== '' ) { + $throttleGroups[] = trim( $group ); + } + } - $parameters[0] = $this->mFilter; // For now, anyway + $parameters[0] = $this->mFilter; $parameters[1] = "$throttleCount,$throttlePeriod"; $parameters = array_merge( $parameters, $throttleGroups ); } elseif ( $action == 'warn' ) { @@ -1085,8 +1206,21 @@ class AbuseFilterViewEdit extends AbuseFilterView { } $parameters[0] = $specMsg; + } elseif ( $action == 'block' ) { + $parameters[0] = $request->getCheck( 'wpFilterBlockTalk' ) ? + 'blocktalk' : 'noTalkBlockSet'; + $parameters[1] = $request->getVal( 'wpBlockAnonDuration' ); + $parameters[2] = $request->getVal( 'wpBlockUserDuration' ); + } elseif ( $action == 'disallow' ) { + $specMsg = $request->getVal( 'wpFilterDisallowMessage' ); + + if ( $specMsg == 'other' ) { + $specMsg = $request->getVal( 'wpFilterDisallowMessageOther' ); + } + + $parameters[0] = $specMsg; } elseif ( $action == 'tag' ) { - $parameters = explode( "\n", $request->getText( 'wpFilterTags' ) ); + $parameters = explode( ',', trim( $request->getText( 'wpFilterTags' ) ) ); } $thisAction = [ 'action' => $action, 'parameters' => $parameters ]; @@ -1097,20 +1231,21 @@ class AbuseFilterViewEdit extends AbuseFilterView { $row->af_actions = implode( ',', array_keys( array_filter( $actions ) ) ); + self::$mLoadedRow = $row; + self::$mLoadedActions = $actions; return [ $row, $actions ]; } /** * Loads historical data in a form that the editor can understand. - * @param $id int History ID + * @param int $id History ID * @return array|bool False if the history ID is not valid, otherwise array in the usual format: * First element contains the abuse_filter row (as it was). * Second element contains an array of abuse_filter_action rows. */ - function loadHistoryItem( $id ) { + public function loadHistoryItem( $id ) { $dbr = wfGetDB( DB_REPLICA ); - // Load the row. $row = $dbr->selectRow( 'abuse_filter_history', '*', [ 'afh_id' => $id ], @@ -1124,11 +1259,17 @@ class AbuseFilterViewEdit extends AbuseFilterView { return AbuseFilter::translateFromHistory( $row ); } - protected function exposeWarningMessages() { - global $wgOut, $wgAbuseFilterDefaultWarningMessage; - $wgOut->addJsConfigVars( + /** + * Exports the default warning and disallow messages to a JS variable + */ + protected function exposeMessages() { + $this->getOutput()->addJsConfigVars( 'wgAbuseFilterDefaultWarningMessage', - $wgAbuseFilterDefaultWarningMessage + $this->getConfig()->get( 'AbuseFilterDefaultWarningMessage' ) + ); + $this->getOutput()->addJsConfigVars( + 'wgAbuseFilterDefaultDisallowMessage', + $this->getConfig()->get( 'AbuseFilterDefaultDisallowMessage' ) ); } } diff --git a/AbuseFilter/includes/Views/AbuseFilterViewExamine.php b/AbuseFilter/includes/Views/AbuseFilterViewExamine.php index 4d72a7d4..6c3d065c 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewExamine.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewExamine.php @@ -4,10 +4,13 @@ class AbuseFilterViewExamine extends AbuseFilterView { public static $examineType = null; public static $examineId = null; - public $mCounter, $mSearchUser, $mSearchPeriodStart, $mSearchPeriodEnd, - $mTestFilter; + public $mCounter, $mSearchUser, $mSearchPeriodStart, $mSearchPeriodEnd; + public $mTestFilter; - function show() { + /** + * Shows the page + */ + public function show() { $out = $this->getOutput(); $out->setPageTitle( $this->msg( 'abusefilter-examine' ) ); $out->addWikiMsg( 'abusefilter-examine-intro' ); @@ -27,25 +30,36 @@ class AbuseFilterViewExamine extends AbuseFilterView { } } - function showSearch() { + /** + * Shows the search form + */ + public function showSearch() { + $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); + $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge ); + $max = wfTimestampNow(); $formDescriptor = [ 'SearchUser' => [ 'label-message' => 'abusefilter-test-user', 'type' => 'user', + 'ipallowed' => true, 'default' => $this->mSearchUser, ], 'SearchPeriodStart' => [ 'label-message' => 'abusefilter-test-period-start', - 'type' => 'text', + 'type' => 'datetime', 'default' => $this->mSearchPeriodStart, + 'min' => $min, + 'max' => $max, ], 'SearchPeriodEnd' => [ 'label-message' => 'abusefilter-test-period-end', - 'type' => 'text', + 'type' => 'datetime', 'default' => $this->mSearchPeriodEnd, + 'min' => $min, + 'max' => $max, ], ]; - $htmlForm = HTMLForm::factory( 'table', $formDescriptor, $this->getContext() ); + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); $htmlForm->setWrapperLegendMsg( 'abusefilter-examine-legend' ) ->addHiddenField( 'submit', 1 ) ->setSubmitTextMsg( 'abusefilter-examine-submit' ) @@ -58,8 +72,11 @@ class AbuseFilterViewExamine extends AbuseFilterView { } } - function showResults() { - $changesList = new AbuseFilterChangesList( $this->getSkin() ); + /** + * Show search results + */ + public function showResults() { + $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->mTestFilter ); $output = $changesList->beginRecentChangesList(); $this->mCounter = 1; @@ -74,14 +91,20 @@ class AbuseFilterViewExamine extends AbuseFilterView { $this->getOutput()->addHTML( $output ); } - function showExaminerForRC( $rcid ) { + /** + * @param int $rcid + */ + public function showExaminerForRC( $rcid ) { // Get data $dbr = wfGetDB( DB_REPLICA ); + $rcQuery = RecentChange::getQueryInfo(); $row = $dbr->selectRow( - 'recentchanges', - RecentChange::selectFields(), + $rcQuery['tables'], + $rcQuery['fields'], [ 'rc_id' => $rcid ], - __METHOD__ + __METHOD__, + [], + $rcQuery['joins'] ); $out = $this->getOutput(); if ( !$row ) { @@ -102,10 +125,23 @@ class AbuseFilterViewExamine extends AbuseFilterView { $this->showExaminer( $vars ); } - function showExaminerForLogEntry( $logid ) { + /** + * @param int $logid + */ + public function showExaminerForLogEntry( $logid ) { // Get data $dbr = wfGetDB( DB_REPLICA ); - $row = $dbr->selectRow( 'abuse_filter_log', '*', [ 'afl_id' => $logid ], __METHOD__ ); + $row = $dbr->selectRow( + 'abuse_filter_log', + [ + 'afl_filter', + 'afl_deleted', + 'afl_var_dump', + 'afl_rev_id' + ], + [ 'afl_id' => $logid ], + __METHOD__ + ); $out = $this->getOutput(); if ( !$row ) { @@ -138,8 +174,12 @@ class AbuseFilterViewExamine extends AbuseFilterView { $this->showExaminer( $vars ); } - function showExaminer( $vars ) { + /** + * @param AbuseFilterVariableHolder|null $vars + */ + public function showExaminer( $vars ) { $output = $this->getOutput(); + $output->enableOOUI(); if ( !$vars ) { $output->addWikiMsg( 'abusefilter-examine-incompatible' ); @@ -155,34 +195,16 @@ class AbuseFilterViewExamine extends AbuseFilterView { $output->addModules( 'ext.abuseFilter.examine' ); // Add test bit - if ( $this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + if ( $this->canViewPrivate() ) { $tester = Xml::tags( 'h2', null, $this->msg( 'abusefilter-examine-test' )->parse() ); - $tester .= AbuseFilter::buildEditBox( $this->mTestFilter, 'wpTestFilter', false ); - $tester .= - "\n" . - Xml::inputLabel( - $this->msg( 'abusefilter-test-load-filter' )->text(), - 'wpInsertFilter', - 'mw-abusefilter-load-filter', - 10, - '' - ) . - ' ' . - Xml::element( - 'input', - [ - 'type' => 'button', - 'value' => $this->msg( 'abusefilter-test-load' )->text(), - 'id' => 'mw-abusefilter-load' - ] - ); + $tester .= $this->buildEditBox( $this->mTestFilter, 'wpTestFilter', false, false, false ); + $tester .= AbuseFilter::buildFilterLoader(); $html .= Xml::tags( 'div', [ 'id' => 'mw-abusefilter-examine-editor' ], $tester ); $html .= Xml::tags( 'p', null, - Xml::element( 'input', + new OOUI\ButtonInputWidget( [ - 'type' => 'button', - 'value' => $this->msg( 'abusefilter-examine-test-button' )->text(), + 'label' => $this->msg( 'abusefilter-examine-test-button' )->text(), 'id' => 'mw-abusefilter-examine-test' ] ) . @@ -206,83 +228,19 @@ class AbuseFilterViewExamine extends AbuseFilterView { $output->addHTML( $html ); } - function loadParameters() { + /** + * Loads parameters from request + */ + public function loadParameters() { $request = $this->getRequest(); - $searchUsername = $request->getText( 'wpSearchUser' ); $this->mSearchPeriodStart = $request->getText( 'wpSearchPeriodStart' ); $this->mSearchPeriodEnd = $request->getText( 'wpSearchPeriodEnd' ); $this->mSubmit = $request->getCheck( 'submit' ); $this->mTestFilter = $request->getText( 'testfilter' ); // Normalise username - $userTitle = Title::newFromText( $searchUsername ); - - if ( $userTitle && $userTitle->getNamespace() == NS_USER ) { - $this->mSearchUser = $userTitle->getText(); // Allow User:Blah syntax. - } elseif ( $userTitle ) { - // Not sure of the value of prefixedText over text, but no need to munge unnecessarily. - $this->mSearchUser = $userTitle->getPrefixedText(); - } else { - $this->mSearchUser = ''; - } - } -} - -class AbuseFilterExaminePager extends ReverseChronologicalPager { - /** - * @param AbuseFilterViewExamine $page - * @param AbuseFilterChangesList $changesList - */ - function __construct( $page, $changesList ) { - parent::__construct(); - $this->mChangesList = $changesList; - $this->mPage = $page; - } - - /** - * @fixme this is similar to AbuseFilterViewTestBatch::doTest - */ - function getQueryInfo() { - $dbr = wfGetDB( DB_REPLICA ); - $conds = []; - $conds['rc_user_text'] = $this->mPage->mSearchUser; - - $startTS = strtotime( $this->mPage->mSearchPeriodStart ); - if ( $startTS ) { - $conds[] = 'rc_timestamp>=' . $dbr->addQuotes( $dbr->timestamp( $startTS ) ); - } - $endTS = strtotime( $this->mPage->mSearchPeriodEnd ); - if ( $endTS ) { - $conds[] = 'rc_timestamp<=' . $dbr->addQuotes( $dbr->timestamp( $endTS ) ); - } - - $conds[] = $this->mPage->buildTestConditions( $dbr ); - - $info = [ - 'tables' => 'recentchanges', - 'fields' => RecentChange::selectFields(), - 'conds' => array_filter( $conds ), - 'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ], - ]; - - return $info; - } - - function formatRow( $row ) { - $rc = RecentChange::newFromRow( $row ); - $rc->counter = $this->mPage->mCounter++; - return $this->mChangesList->recentChangesLine( $rc, false ); - } - - function getIndexField() { - return 'rc_id'; - } - - function getTitle() { - return $this->mPage->getTitle( 'examine' ); - } - - function getEmptyBody() { - return $this->msg( 'abusefilter-examine-noresults' )->parseAsBlock(); + $searchUsername = $request->getText( 'wpSearchUser' ); + $userTitle = Title::newFromText( $searchUsername, NS_USER ); + $this->mSearchUser = $userTitle ? $userTitle->getText() : ''; } } diff --git a/AbuseFilter/includes/Views/AbuseFilterViewHistory.php b/AbuseFilter/includes/Views/AbuseFilterViewHistory.php index a6ef187c..66a14a56 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewHistory.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewHistory.php @@ -1,22 +1,30 @@ <?php class AbuseFilterViewHistory extends AbuseFilterView { - function __construct( $page, $params ) { + /** + * @param SpecialAbuseFilter $page + * @param array $params + */ + public function __construct( $page, $params ) { parent::__construct( $page, $params ); $this->mFilter = $page->mFilter; } - function show() { + /** + * Shows the page + */ + public function show() { $out = $this->getOutput(); - $filter = $this->mFilter; + $out->enableOOUI(); + $filter = $this->getRequest()->getText( 'filter' ) ?: $this->mFilter; if ( $filter ) { - $out->setPageTitle( $this->msg( 'abusefilter-history', $filter ) ); + $out->setPageTitle( $this->msg( 'abusefilter-history' )->numParams( $filter ) ); } else { $out->setPageTitle( $this->msg( 'abusefilter-filter-log' ) ); } - # Check perms. abusefilter-modify is a superset of abusefilter-view-private + // Check perms. abusefilter-modify is a superset of abusefilter-view-private if ( $filter && AbuseFilter::filterHidden( $filter ) && !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) { @@ -24,30 +32,36 @@ class AbuseFilterViewHistory extends AbuseFilterView { return; } - # Useful links + // Useful links $links = []; if ( $filter ) { - $links['abusefilter-history-backedit'] = $this->getTitle( $filter ); + $links['abusefilter-history-backedit'] = $this->getTitle( $filter )->getFullURL(); } foreach ( $links as $msg => $title ) { - $links[$msg] = $this->linkRenderer->makeLink( - $title, - new HtmlArmor( $this->msg( $msg )->parse() ) - ); + $links[$msg] = + new OOUI\ButtonWidget( [ + 'label' => $this->msg( $msg )->text(), + 'href' => $title + ] ); } - $backlinks = $this->getLanguage()->pipeList( $links ); - $out->addHTML( Xml::tags( 'p', null, $backlinks ) ); + $backlinks = + new OOUI\HorizontalLayout( [ + 'items' => $links + ] ); + $out->addHTML( $backlinks ); - # For user + // For user $user = User::getCanonicalName( $this->getRequest()->getText( 'user' ), 'valid' ); if ( $user ) { $out->addSubtitle( $this->msg( 'abusefilter-history-foruser', - Linker::userLink( 1 /* We don't really need to get a user ID */, $user ), - $user // For GENDER + // We don't really need to get a user ID + Linker::userLink( 1, $user ), + // For GENDER + $user )->text() ); } @@ -59,223 +73,30 @@ class AbuseFilterViewHistory extends AbuseFilterView { 'default' => $user, 'size' => '45', 'label-message' => 'abusefilter-history-select-user' - ] + ], + 'filter' => [ + 'type' => 'text', + 'name' => 'filter', + 'default' => $filter, + 'size' => '45', + 'label-message' => 'abusefilter-history-select-filter' + ], ]; - $htmlForm = HTMLForm::factory( 'table', $formDescriptor, $this->getContext() ); + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); $htmlForm->setSubmitTextMsg( 'abusefilter-history-select-submit' ) ->setWrapperLegendMsg( 'abusefilter-history-select-legend' ) - ->setAction( $this->getTitle( "history/$filter" )->getLocalURL() ) + ->setAction( $this->getTitle( 'history' )->getLocalURL() ) ->setMethod( 'get' ) ->prepareForm() ->displayForm( false ); $pager = new AbuseFilterHistoryPager( $filter, $this, $user, $this->linkRenderer ); - $table = $pager->getBody(); - - $out->addHTML( $pager->getNavigationBar() . $table . $pager->getNavigationBar() ); - } -} - -class AbuseFilterHistoryPager extends TablePager { - - protected $linkRenderer; - /** - * @param $filter - * @param $page ContextSource - * @param $user string User name - * @param \MediaWiki\Linker\LinkRenderer $linkRenderer - */ - function __construct( $filter, $page, $user, $linkRenderer ) { - $this->mFilter = $filter; - $this->mPage = $page; - $this->mUser = $user; - $this->mDefaultDirection = true; - $this->linkRenderer = $linkRenderer; - parent::__construct( $this->mPage->getContext() ); - } - - function getFieldNames() { - static $headers = null; - - if ( !empty( $headers ) ) { - return $headers; - } - - $headers = [ - 'afh_timestamp' => 'abusefilter-history-timestamp', - 'afh_user_text' => 'abusefilter-history-user', - 'afh_public_comments' => 'abusefilter-history-public', - 'afh_flags' => 'abusefilter-history-flags', - 'afh_actions' => 'abusefilter-history-actions', - 'afh_id' => 'abusefilter-history-diff', - ]; - - if ( !$this->mFilter ) { - // awful hack - $headers = [ 'afh_filter' => 'abusefilter-history-filterid' ] + $headers; - unset( $headers['afh_comments'] ); - } - - foreach ( $headers as &$msg ) { - $msg = $this->msg( $msg )->text(); - } - - return $headers; - } - - function formatValue( $name, $value ) { - $lang = $this->getLanguage(); - - $row = $this->mCurrentRow; - - switch ( $name ) { - case 'afh_filter': - $formatted = $this->linkRenderer->makeLink( - SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->afh_filter ) ), - $lang->formatNum( $row->afh_filter ) - ); - break; - case 'afh_timestamp': - $title = SpecialPage::getTitleFor( 'AbuseFilter', - 'history/' . $row->afh_filter . '/item/' . $row->afh_id ); - $formatted = $this->linkRenderer->makeLink( - $title, - $lang->timeanddate( $row->afh_timestamp, true ) - ); - break; - case 'afh_user_text': - $formatted = - Linker::userLink( $row->afh_user, $row->afh_user_text ) . ' ' . - Linker::userToolLinks( $row->afh_user, $row->afh_user_text ); - break; - case 'afh_public_comments': - $formatted = htmlspecialchars( $value, ENT_QUOTES, 'UTF-8', false ); - break; - case 'afh_flags': - $formatted = AbuseFilter::formatFlags( $value ); - break; - case 'afh_actions': - $actions = unserialize( $value ); - - $display_actions = ''; - foreach ( $actions as $action => $parameters ) { - $displayAction = AbuseFilter::formatAction( $action, $parameters ); - $display_actions .= Xml::tags( 'li', null, $displayAction ); - } - $display_actions = Xml::tags( 'ul', null, $display_actions ); - - $formatted = $display_actions; - break; - case 'afh_id': - $formatted = ''; - if ( AbuseFilter::getFirstFilterChange( $row->afh_filter ) != $value ) { - // Set a link to a diff with the previous version if this isn't the first edit to the filter - $title = $this->mPage->getTitle( - 'history/' . $row->afh_filter . "/diff/prev/$value" ); - $formatted = $this->linkRenderer->makeLink( - $title, - new HtmlArmor( $this->msg( 'abusefilter-history-diff' )->parse() ) - ); - } - break; - default: - $formatted = "Unable to format $name"; - break; - } - - $mappings = array_flip( AbuseFilter::$history_mappings ) + - [ 'afh_actions' => 'actions', 'afh_id' => 'id' ]; - $changed = explode( ',', $row->afh_changed_fields ); - - $fieldChanged = false; - if ( $name == 'afh_flags' ) { - // This is a bit freaky, but it works. - // Basically, returns true if any of those filters are in the $changed array. - $filters = [ 'af_enabled', 'af_hidden', 'af_deleted', 'af_global' ]; - if ( count( array_diff( $filters, $changed ) ) < count( $filters ) ) { - $fieldChanged = true; - } - } elseif ( in_array( $mappings[$name], $changed ) ) { - $fieldChanged = true; - } - - if ( $fieldChanged ) { - $formatted = Xml::tags( 'div', - [ 'class' => 'mw-abusefilter-history-changed' ], - $formatted - ); - } - - return $formatted; - } - - function getQueryInfo() { - $info = [ - 'tables' => [ 'abuse_filter_history', 'abuse_filter' ], - 'fields' => [ - 'afh_filter', - 'afh_timestamp', - 'afh_user_text', - 'afh_public_comments', - 'afh_flags', - 'afh_comments', - 'afh_actions', - 'afh_id', - 'afh_user', - 'afh_changed_fields', - 'afh_pattern', - 'afh_id', - 'af_hidden' - ], - 'conds' => [], - 'join_conds' => [ - 'abuse_filter' => - [ - 'LEFT JOIN', - 'afh_filter=af_id', - ], - ], - ]; - - if ( $this->mUser ) { - $info['conds']['afh_user_text'] = $this->mUser; - } - - if ( $this->mFilter ) { - $info['conds']['afh_filter'] = $this->mFilter; - } - - if ( !$this->getUser()->isAllowedAny( - 'abusefilter-modify', 'abusefilter-view-private' ) - ) { - // Hide data the user can't see. - $info['conds']['af_hidden'] = 0; - } - - return $info; - } - - function getIndexField() { - return 'afh_timestamp'; - } - - function getDefaultSort() { - return 'afh_timestamp'; - } - - function isFieldSortable( $name ) { - $sortable_fields = [ 'afh_timestamp', 'afh_user_text' ]; - return in_array( $name, $sortable_fields ); - } - - /** - * Title used for self-links. - * - * @return Title - */ - function getTitle() { - return $this->mPage->getTitle( 'history/' . $this->mFilter ); + $out->addHTML( + $pager->getNavigationBar() . + $pager->getBody() . + $pager->getNavigationBar() + ); } } diff --git a/AbuseFilter/includes/Views/AbuseFilterViewImport.php b/AbuseFilter/includes/Views/AbuseFilterViewImport.php index 6bd4c269..01e2fc85 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewImport.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewImport.php @@ -1,7 +1,10 @@ <?php class AbuseFilterViewImport extends AbuseFilterView { - function show() { + /** + * Shows the page + */ + public function show() { $out = $this->getOutput(); if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { $out->addWikiMsg( 'abusefilter-edit-notallowed' ); diff --git a/AbuseFilter/includes/Views/AbuseFilterViewList.php b/AbuseFilter/includes/Views/AbuseFilterViewList.php index daecf8d7..ad92ad6c 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewList.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewList.php @@ -4,48 +4,76 @@ * The default view used in Special:AbuseFilter */ class AbuseFilterViewList extends AbuseFilterView { - function show() { - global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; - + /** + * Shows the page + */ + public function show() { $out = $this->getOutput(); $request = $this->getRequest(); + $config = $this->getConfig(); - // Status info... + // Show filter performance statistics $this->showStatus(); $out->addWikiMsg( 'abusefilter-intro' ); // New filter button if ( $this->canEdit() ) { - $title = $this->getTitle( 'new' ); - $link = $this->linkRenderer->makeLink( $title, $this->msg( 'abusefilter-new' )->text() ); - $links = Xml::tags( 'p', null, $link ) . "\n"; - $out->addHTML( $links ); + $out->enableOOUI(); + $link = new OOUI\ButtonWidget( [ + 'label' => $this->msg( 'abusefilter-new' )->text(), + 'href' => $this->getTitle( 'new' )->getFullURL(), + ] ); + $out->addHTML( $link ); } - // Options. $conds = []; $deleted = $request->getVal( 'deletedfilters' ); - $hidedisabled = $request->getBool( 'hidedisabled' ); + $furtherOptions = $request->getArray( 'furtheroptions', [] ); + // Backward compatibility with old links + if ( $request->getBool( 'hidedisabled' ) ) { + $furtherOptions[] = 'hidedisabled'; + } + if ( $request->getBool( 'hideprivate' ) ) { + $furtherOptions[] = 'hideprivate'; + } $defaultscope = 'all'; - if ( isset( $wgAbuseFilterCentralDB ) && !$wgAbuseFilterIsCentral ) { + if ( $config->get( 'AbuseFilterCentralDB' ) !== null + && !$config->get( 'AbuseFilterIsCentral' ) ) { // Show on remote wikis as default only local filters $defaultscope = 'local'; } $scope = $request->getVal( 'rulescope', $defaultscope ); + $searchEnabled = $this->canViewPrivate() && !( + $config->get( 'AbuseFilterCentralDB' ) !== null && + !$config->get( 'AbuseFilterIsCentral' ) && + $scope == 'global' ); + + if ( $searchEnabled ) { + $querypattern = $request->getVal( 'querypattern' ); + $searchmode = $request->getVal( 'searchoption', 'LIKE' ); + } else { + $querypattern = ''; + $searchmode = ''; + } + if ( $deleted == 'show' ) { - # Nothing + // Nothing } elseif ( $deleted == 'only' ) { $conds['af_deleted'] = 1; - } else { # hide, or anything else. + } else { + // hide, or anything else. $conds['af_deleted'] = 0; $deleted = 'hide'; } - if ( $hidedisabled ) { + if ( in_array( 'hidedisabled', $furtherOptions ) ) { $conds['af_deleted'] = 0; $conds['af_enabled'] = 1; } + if ( in_array( 'hideprivate', $furtherOptions ) ) { + $conds['af_hidden'] = 0; + } if ( $scope == 'local' ) { $conds['af_global'] = 0; @@ -53,123 +81,206 @@ class AbuseFilterViewList extends AbuseFilterView { $conds['af_global'] = 1; } - $this->showList( $conds, compact( 'deleted', 'hidedisabled', 'scope' ) ); - } - - function showList( $conds = [ 'af_deleted' => 0 ], $optarray = [] ) { - global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; + $dbr = wfGetDB( DB_REPLICA ); + + if ( $querypattern !== '' ) { + if ( $searchmode !== 'LIKE' ) { + // Check regex pattern validity + Wikimedia\suppressWarnings(); + $validreg = preg_match( '/' . $querypattern . '/', null ); + Wikimedia\restoreWarnings(); + + if ( $validreg === false ) { + $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() + ); + } + } - $output = ''; - $output .= Xml::element( 'h2', null, - $this->msg( 'abusefilter-list' )->parse() ); + $this->showList( + $conds, + compact( + 'deleted', + 'furtherOptions', + 'querypattern', + 'searchmode', + 'scope', + 'searchEnabled' + ) + ); + } - $pager = new AbuseFilterPager( $this, $conds, $this->linkRenderer ); + /** + * @param array $conds + * @param array $optarray + */ + public function showList( $conds = [ 'af_deleted' => 0 ], $optarray = [] ) { + $config = $this->getConfig(); + $this->getOutput()->addHTML( + Xml::tags( 'h2', null, $this->msg( 'abusefilter-list' )->parse() ) + ); $deleted = $optarray['deleted']; - $hidedisabled = $optarray['hidedisabled']; + $furtherOptions = $optarray['furtherOptions']; $scope = $optarray['scope']; - - # Options form - $fields = []; - $fields['abusefilter-list-options-deleted'] = - Xml::radioLabel( - $this->msg( 'abusefilter-list-options-deleted-show' )->text(), - 'deletedfilters', - 'show', - 'mw-abusefilter-deletedfilters-show', - $deleted == 'show' - ) . - Xml::radioLabel( - $this->msg( 'abusefilter-list-options-deleted-hide' )->text(), - 'deletedfilters', - 'hide', - 'mw-abusefilter-deletedfilters-hide', - $deleted == 'hide' - ) . - Xml::radioLabel( - $this->msg( 'abusefilter-list-options-deleted-only' )->text(), - 'deletedfilters', - 'only', - 'mw-abusefilter-deletedfilters-only', - $deleted == 'only' + $searchEnabled = $optarray['searchEnabled']; + $querypattern = $optarray['querypattern']; + $searchmode = $optarray['searchmode']; + + if ( + $config->get( 'AbuseFilterCentralDB' ) !== null + && !$config->get( 'AbuseFilterIsCentral' ) + && $scope == 'global' + ) { + $pager = new GlobalAbuseFilterPager( + $this, + $conds, + $this->linkRenderer + ); + } else { + $pager = new AbuseFilterPager( + $this, + $conds, + $this->linkRenderer, + [ $querypattern, $searchmode ] ); + } - if ( isset( $wgAbuseFilterCentralDB ) ) { - $fields['abusefilter-list-options-scope'] = - Xml::radioLabel( - $this->msg( 'abusefilter-list-options-scope-local' )->text(), - 'rulescope', - 'local', - 'mw-abusefilter-rulescope-local', - $scope == 'local' - ) . - Xml::radioLabel( - $this->msg( 'abusefilter-list-options-scope-global' )->text(), - 'rulescope', - 'global', - 'mw-abusefilter-rulescope-global', - $scope == 'global' - ); + // Options form + $formDescriptor = []; + $formDescriptor['deletedfilters'] = [ + 'name' => 'deletedfilters', + 'type' => 'radio', + 'flatlist' => true, + 'label-message' => 'abusefilter-list-options-deleted', + 'options-messages' => [ + 'abusefilter-list-options-deleted-show' => 'show', + 'abusefilter-list-options-deleted-hide' => 'hide', + 'abusefilter-list-options-deleted-only' => 'only', + ], + 'default' => $deleted, + ]; - if ( $wgAbuseFilterIsCentral ) { + if ( $config->get( 'AbuseFilterCentralDB' ) !== null ) { + $optionsMsg = [ + 'abusefilter-list-options-scope-local' => 'local', + 'abusefilter-list-options-scope-global' => 'global', + ]; + if ( $config->get( 'AbuseFilterIsCentral' ) ) { // For central wiki: add third scope option - $fields['abusefilter-list-options-scope'] .= - Xml::radioLabel( - $this->msg( 'abusefilter-list-options-scope-all' )->text(), - 'rulescope', - 'all', - 'mw-abusefilter-rulescope-all', - $scope == 'all' - ); + $optionsMsg['abusefilter-list-options-scope-all'] = 'all'; } + $formDescriptor['rulescope'] = [ + 'name' => 'rulescope', + 'type' => 'radio', + 'flatlist' => true, + 'label-message' => 'abusefilter-list-options-scope', + 'options-messages' => $optionsMsg, + 'default' => $scope, + ]; } - $fields['abusefilter-list-options-disabled'] = - Xml::checkLabel( - $this->msg( 'abusefilter-list-options-hidedisabled' )->text(), - 'hidedisabled', - 'mw-abusefilter-disabledfilters-hide', - $hidedisabled - ); - $fields['abusefilter-list-limit'] = $pager->getLimitSelect(); - - $options = Xml::buildForm( $fields, 'abusefilter-list-options-submit' ); - $options .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ); - $options = Xml::tags( 'form', - [ - 'method' => 'get', - 'action' => $this->getTitle()->getFullURL() + $formDescriptor['furtheroptions'] = [ + 'name' => 'furtheroptions', + 'type' => 'multiselect', + 'label-message' => 'abusefilter-list-options-further-options', + 'flatlist' => true, + 'options' => [ + $this->msg( 'abusefilter-list-options-hideprivate' )->parse() => 'hideprivate', + $this->msg( 'abusefilter-list-options-hidedisabled' )->parse() => 'hidedisabled', ], - $options - ); - $options = Xml::fieldset( $this->msg( 'abusefilter-list-options' )->text(), $options ); - - $output .= $options; + 'default' => $furtherOptions + ]; - if ( isset( $wgAbuseFilterCentralDB ) && !$wgAbuseFilterIsCentral && $scope == 'global' ) { - $globalPager = new GlobalAbuseFilterPager( $this, $conds, $this->linkRenderer ); - $output .= - $globalPager->getNavigationBar() . - $globalPager->getBody() . - $globalPager->getNavigationBar(); - } else { - $output .= - $pager->getNavigationBar() . - $pager->getBody() . - $pager->getNavigationBar(); + // ToDo: Since this is only for saving space, we should convert it to use a 'hide-if' + if ( $searchEnabled ) { + $formDescriptor['querypattern'] = [ + 'name' => 'querypattern', + 'type' => 'text', + 'label-message' => 'abusefilter-list-options-searchfield', + 'placeholder' => $this->msg( 'abusefilter-list-options-searchpattern' )->text(), + 'default' => $querypattern + ]; + + $formDescriptor['searchoption'] = [ + 'name' => 'searchoption', + 'type' => 'radio', + 'flatlist' => true, + 'label-message' => 'abusefilter-list-options-searchoptions', + 'options-messages' => [ + 'abusefilter-list-options-search-like' => 'LIKE', + 'abusefilter-list-options-search-rlike' => 'RLIKE', + 'abusefilter-list-options-search-irlike' => 'IRLIKE', + ], + 'default' => $searchmode + ]; } - $this->getOutput()->addHTML( $output ); - } + $formDescriptor['limit'] = [ + 'name' => 'limit', + 'type' => 'select', + 'label-message' => 'abusefilter-list-limit', + 'options' => $pager->getLimitSelectList(), + 'default' => $pager->getLimit(), + ]; - function showStatus() { - global $wgAbuseFilterConditionLimit, $wgAbuseFilterValidGroups; + HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + ->addHiddenField( 'title', $this->getTitle()->getPrefixedDBkey() ) + ->setAction( $this->getTitle()->getFullURL() ) + ->setWrapperLegendMsg( 'abusefilter-list-options' ) + ->setSubmitTextMsg( 'abusefilter-list-options-submit' ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + + $this->getOutput()->addHTML( + $pager->getNavigationBar() . + $pager->getBody() . + $pager->getNavigationBar() + ); + } + /** + * Show stats + */ + public function showStatus() { $stash = ObjectCache::getMainStashInstance(); $overflow_count = (int)$stash->get( AbuseFilter::filterLimitReachedKey() ); $match_count = (int)$stash->get( AbuseFilter::filterMatchesKey() ); $total_count = 0; - foreach ( $wgAbuseFilterValidGroups as $group ) { + foreach ( $this->getConfig()->get( 'AbuseFilterValidGroups' ) as $group ) { $total_count += (int)$stash->get( AbuseFilter::filterUsedKey( $group ) ); } @@ -182,7 +293,7 @@ class AbuseFilterViewList extends AbuseFilterView { $total_count, $overflow_count, $overflow_percent, - $wgAbuseFilterConditionLimit, + $this->getConfig()->get( 'AbuseFilterConditionLimit' ), $match_count, $match_percent )->parse(); @@ -192,255 +303,3 @@ class AbuseFilterViewList extends AbuseFilterView { } } } - -/** - * Class to build paginated filter list - */ -// Probably no need to autoload this class, as it will only be called from the class above. -class AbuseFilterPager extends TablePager { - - /** - * @var \MediaWiki\Linker\LinkRenderer - */ - protected $linkRenderer; - - function __construct( $page, $conds, $linkRenderer ) { - $this->mPage = $page; - $this->mConds = $conds; - $this->linkRenderer = $linkRenderer; - parent::__construct( $this->mPage->getContext() ); - } - - function getQueryInfo() { - return [ - 'tables' => [ 'abuse_filter' ], - 'fields' => [ - 'af_id', - 'af_enabled', - 'af_deleted', - 'af_global', - 'af_public_comments', - 'af_hidden', - 'af_hit_count', - 'af_timestamp', - 'af_user_text', - 'af_user', - 'af_actions', - 'af_group', - ], - 'conds' => $this->mConds, - ]; - } - - function getFieldNames() { - static $headers = null; - - if ( !empty( $headers ) ) { - return $headers; - } - - $headers = [ - 'af_id' => 'abusefilter-list-id', - 'af_public_comments' => 'abusefilter-list-public', - 'af_actions' => 'abusefilter-list-consequences', - 'af_enabled' => 'abusefilter-list-status', - 'af_timestamp' => 'abusefilter-list-lastmodified', - 'af_hidden' => 'abusefilter-list-visibility', - ]; - - if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) { - $headers['af_hit_count'] = 'abusefilter-list-hitcount'; - } - - global $wgAbuseFilterValidGroups; - if ( count( $wgAbuseFilterValidGroups ) > 1 ) { - $headers['af_group'] = 'abusefilter-list-group'; - } - - foreach ( $headers as &$msg ) { - $msg = $this->msg( $msg )->text(); - } - - return $headers; - } - - function formatValue( $name, $value ) { - $lang = $this->getLanguage(); - $row = $this->mCurrentRow; - - switch ( $name ) { - case 'af_id': - return $this->linkRenderer->makeLink( - SpecialPage::getTitleFor( 'AbuseFilter', intval( $value ) ), - $lang->formatNum( intval( $value ) ) - ); - case 'af_public_comments': - return $this->linkRenderer->makeLink( - SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->af_id ) ), - $value - ); - case 'af_actions': - $actions = explode( ',', $value ); - $displayActions = []; - foreach ( $actions as $action ) { - $displayActions[] = AbuseFilter::getActionDisplay( $action ); - } - return htmlspecialchars( $lang->commaList( $displayActions ) ); - case 'af_enabled': - $statuses = []; - if ( $row->af_deleted ) { - $statuses[] = $this->msg( 'abusefilter-deleted' )->parse(); - } elseif ( $row->af_enabled ) { - $statuses[] = $this->msg( 'abusefilter-enabled' )->parse(); - } else { - $statuses[] = $this->msg( 'abusefilter-disabled' )->parse(); - } - - global $wgAbuseFilterIsCentral; - if ( $row->af_global && $wgAbuseFilterIsCentral ) { - $statuses[] = $this->msg( 'abusefilter-status-global' )->parse(); - } - - return $lang->commaList( $statuses ); - case 'af_hidden': - $msg = $value ? 'abusefilter-hidden' : 'abusefilter-unhidden'; - return $this->msg( $msg )->parse(); - case 'af_hit_count': - if ( SpecialAbuseLog::canSeeDetails( $row->af_id, $row->af_hidden ) ) { - $count_display = $this->msg( 'abusefilter-hitcount' ) - ->numParams( $value )->parse(); - $link = $this->linkRenderer->makeKnownLink( - SpecialPage::getTitleFor( 'AbuseLog' ), - $count_display, - [], - [ 'wpSearchFilter' => $row->af_id ] - ); - } else { - $link = ""; - } - return $link; - case 'af_timestamp': - $userLink = - Linker::userLink( - $row->af_user, - $row->af_user_text - ) . - Linker::userToolLinks( - $row->af_user, - $row->af_user_text - ); - $user = $row->af_user_text; - return $this->msg( 'abusefilter-edit-lastmod-text' ) - ->rawParams( $lang->timeanddate( $value, true ), - $userLink, - $lang->date( $value, true ), - $lang->time( $value, true ), - $user - )->parse(); - case 'af_group': - return AbuseFilter::nameGroup( $value ); - break; - default: - throw new MWException( "Unknown row type $name!" ); - } - } - - function getDefaultSort() { - return 'af_id'; - } - - function getRowClass( $row ) { - if ( $row->af_enabled ) { - return 'mw-abusefilter-list-enabled'; - } elseif ( $row->af_deleted ) { - return 'mw-abusefilter-list-deleted'; - } else { - return 'mw-abusefilter-list-disabled'; - } - } - - function isFieldSortable( $name ) { - $sortable_fields = [ - 'af_id', - 'af_enabled', - 'af_throttled', - 'af_user_text', - 'af_timestamp', - 'af_hidden', - 'af_group', - ]; - if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) { - $sortable_fields[] = 'af_hit_count'; - } - return in_array( $name, $sortable_fields ); - } -} - -/** - * Class to build paginated filter list for wikis using global abuse filters - */ -class GlobalAbuseFilterPager extends AbuseFilterPager { - function __construct( $page, $conds, $linkRenderer ) { - parent::__construct( $page, $conds, $linkRenderer ); - global $wgAbuseFilterCentralDB; - $this->mDb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); - } - - function formatValue( $name, $value ) { - $lang = $this->getLanguage(); - $row = $this->mCurrentRow; - - switch ( $name ) { - case 'af_id': - return $lang->formatNum( intval( $value ) ); - case 'af_public_comments': - return $this->getOutput()->parseInline( $value ); - case 'af_actions': - $actions = explode( ',', $value ); - $displayActions = []; - foreach ( $actions as $action ) { - $displayActions[] = AbuseFilter::getActionDisplay( $action ); - } - return htmlspecialchars( $lang->commaList( $displayActions ) ); - case 'af_enabled': - $statuses = []; - if ( $row->af_deleted ) { - $statuses[] = $this->msg( 'abusefilter-deleted' )->parse(); - } elseif ( $row->af_enabled ) { - $statuses[] = $this->msg( 'abusefilter-enabled' )->parse(); - } else { - $statuses[] = $this->msg( 'abusefilter-disabled' )->parse(); - } - if ( $row->af_global ) { - $statuses[] = $this->msg( 'abusefilter-status-global' )->parse(); - } - - return $lang->commaList( $statuses ); - case 'af_hidden': - $msg = $value ? 'abusefilter-hidden' : 'abusefilter-unhidden'; - return $this->msg( $msg, 'parseinline' )->parse(); - case 'af_hit_count': - // If the rule is hidden, don't show it, even to priviledged local admins - if ( $row->af_hidden ) { - return ''; - } - return $this->msg( 'abusefilter-hitcount' )->numParams( $value )->parse(); - case 'af_timestamp': - $user = $row->af_user_text; - return $this->msg( - 'abusefilter-edit-lastmod-text', - $lang->timeanddate( $value, true ), - $user, - $lang->date( $value, true ), - $lang->time( $value, true ), - $user - )->parse(); - case 'af_group': - // If this is global, local name probably doesn't exist, but try - return AbuseFilter::nameGroup( $value ); - break; - default: - throw new MWException( "Unknown row type $name!" ); - } - } -} diff --git a/AbuseFilter/includes/Views/AbuseFilterViewRevert.php b/AbuseFilter/includes/Views/AbuseFilterViewRevert.php index 1e8c77b0..38a0d073 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewRevert.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewRevert.php @@ -1,10 +1,14 @@ <?php class AbuseFilterViewRevert extends AbuseFilterView { - public $origPeriodStart, $origPeriodEnd, $mPeriodStart, $mPeriodEnd, - $mReason; + public $origPeriodStart, $origPeriodEnd, $mPeriodStart, $mPeriodEnd; + public $mReason; - function show() { + /** + * Shows the page + */ + public function show() { + $lang = $this->getLanguage(); $filter = $this->mPage->mFilter; $user = $this->getUser(); @@ -20,32 +24,50 @@ class AbuseFilterViewRevert extends AbuseFilterView { return; } - $out->addWikiMsg( 'abusefilter-revert-intro', $filter ); - $out->setPageTitle( $this->msg( 'abusefilter-revert-title', $filter ) ); - - // First, the search form. - $searchFields = []; - $searchFields['abusefilter-revert-filter'] = - Xml::element( 'strong', null, $filter ); - $searchFields['abusefilter-revert-periodstart'] = - Xml::input( 'wpPeriodStart', 45, $this->origPeriodStart ); - $searchFields['abusefilter-revert-periodend'] = - Xml::input( 'wpPeriodEnd', 45, $this->origPeriodEnd ); - $searchForm = Xml::buildForm( $searchFields, 'abusefilter-revert-search' ); - $searchForm .= "\n" . Html::hidden( 'submit', 1 ); - $searchForm = - Xml::tags( - 'form', - [ - 'action' => $this->getTitle( "revert/$filter" )->getLocalURL(), - 'method' => 'post' - ], - $searchForm + $out->addWikiMsg( 'abusefilter-revert-intro', Message::numParam( $filter ) ); + $out->setPageTitle( $this->msg( 'abusefilter-revert-title' )->numParams( $filter ) ); + + // First, the search form. Limit dates to avoid huge queries + $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); + $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge ); + $max = wfTimestampNow(); + $filterLink = + $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', intval( $filter ) ), + $lang->formatNum( intval( $filter ) ) ); - $searchForm = - Xml::fieldset( $this->msg( 'abusefilter-revert-search-legend' )->text(), $searchForm ); - - $out->addHTML( $searchForm ); + $searchFields = []; + $searchFields['filterid'] = [ + 'type' => 'info', + 'default' => $filterLink, + 'raw' => true, + 'label-message' => 'abusefilter-revert-filter' + ]; + $searchFields['periodstart'] = [ + 'type' => 'datetime', + 'name' => 'wpPeriodStart', + 'default' => $this->origPeriodStart, + 'label-message' => 'abusefilter-revert-periodstart', + 'min' => $min, + 'max' => $max + ]; + $searchFields['periodend'] = [ + 'type' => 'datetime', + 'name' => 'wpPeriodEnd', + 'default' => $this->origPeriodEnd, + 'label-message' => 'abusefilter-revert-periodend', + 'min' => $min, + 'max' => $max + ]; + + HTMLForm::factory( 'ooui', $searchFields, $this->getContext() ) + ->addHiddenField( 'submit', 1 ) + ->setAction( $this->getTitle( "revert/$filter" )->getLocalURL() ) + ->setWrapperLegendMsg( 'abusefilter-revert-search-legend' ) + ->setSubmitTextMsg( 'abusefilter-revert-search' ) + ->setMethod( 'post' ) + ->prepareForm() + ->displayForm( false ); if ( $this->mSubmit ) { // Add a summary of everything that will be reversed. @@ -53,7 +75,6 @@ class AbuseFilterViewRevert extends AbuseFilterView { // Look up all of them. $results = $this->doLookup(); - $lang = $this->getLanguage(); $list = []; foreach ( $results as $result ) { @@ -62,12 +83,17 @@ class AbuseFilterViewRevert extends AbuseFilterView { $result['actions'] ); $msg = $this->msg( 'abusefilter-revert-preview-item' ) - ->rawParams( - $lang->timeanddate( $result['timestamp'], true ), - Linker::userLink( $result['userid'], $result['user'] ), - $result['action'], - $this->linkRenderer->makeLink( $result['title'] ), - $lang->commaList( $displayActions ), + ->params( + $lang->timeanddate( $result['timestamp'], true ) + )->rawParams( + Linker::userLink( $result['userid'], $result['user'] ) + )->params( + $result['action'] + )->rawParams( + $this->linkRenderer->makeLink( $result['title'] ) + )->params( + $lang->commaList( $displayActions ) + )->rawParams( $this->linkRenderer->makeLink( SpecialPage::getTitleFor( 'AbuseLog' ), $this->msg( 'abusefilter-log-detailslink' )->text(), @@ -81,30 +107,48 @@ class AbuseFilterViewRevert extends AbuseFilterView { $out->addHTML( Xml::tags( 'ul', null, implode( "\n", $list ) ) ); // Add a button down the bottom. - $confirmForm = - Html::hidden( 'editToken', $user->getEditToken( "abusefilter-revert-$filter" ) ) . - Html::hidden( 'title', $this->getTitle( "revert/$filter" )->getPrefixedDBkey() ) . - Html::hidden( 'wpPeriodStart', $this->origPeriodStart ) . - Html::hidden( 'wpPeriodEnd', $this->origPeriodEnd ) . - Xml::inputLabel( - $this->msg( 'abusefilter-revert-reasonfield' )->text(), - 'wpReason', 'wpReason', 45 - ) . - "\n" . - Xml::submitButton( $this->msg( 'abusefilter-revert-confirm' )->text() ); - $confirmForm = Xml::tags( - 'form', - [ - 'action' => $this->getTitle( "revert/$filter" )->getLocalURL(), - 'method' => 'post' - ], - $confirmForm - ); - $out->addHTML( $confirmForm ); + $confirmForm = []; + $confirmForm['edittoken'] = [ + 'type' => 'hidden', + 'name' => 'editToken', + 'default' => $user->getEditToken( "abusefilter-revert-$filter" ) + ]; + $confirmForm['title'] = [ + 'type' => 'hidden', + 'name' => 'title', + 'default' => $this->getTitle( "revert/$filter" )->getPrefixedDBkey() + ]; + $confirmForm['wpPeriodStart'] = [ + 'type' => 'hidden', + 'name' => 'wpPeriodStart', + 'default' => $this->origPeriodStart + ]; + $confirmForm['wpPeriodEnd'] = [ + 'type' => 'hidden', + 'name' => 'wpPeriodEnd', + 'default' => $this->origPeriodEnd + ]; + $confirmForm['reason'] = [ + 'type' => 'text', + 'label-message' => 'abusefilter-revert-reasonfield', + 'name' => 'wpReason', + 'id' => 'wpReason', + ]; + HTMLForm::factory( 'ooui', $confirmForm, $this->getContext() ) + ->setAction( $this->getTitle( "revert/$filter" )->getLocalURL() ) + ->setWrapperLegendMsg( 'abusefilter-revert-confirm-legend' ) + ->setSubmitTextMsg( 'abusefilter-revert-confirm' ) + ->setMethod( 'post' ) + ->prepareForm() + ->displayForm( false ); + } } - function doLookup() { + /** + * @return array + */ + public function doLookup() { $periodStart = $this->mPeriodStart; $periodEnd = $this->mPeriodEnd; $filter = $this->mPage->mFilter; @@ -114,14 +158,26 @@ class AbuseFilterViewRevert extends AbuseFilterView { $dbr = wfGetDB( DB_REPLICA ); if ( $periodStart ) { - $conds[] = 'afl_timestamp>' . $dbr->addQuotes( $dbr->timestamp( $periodStart ) ); + $conds[] = 'afl_timestamp >= ' . $dbr->addQuotes( $dbr->timestamp( $periodStart ) ); } if ( $periodEnd ) { - $conds[] = 'afl_timestamp<' . $dbr->addQuotes( $dbr->timestamp( $periodEnd ) ); + $conds[] = 'afl_timestamp <= ' . $dbr->addQuotes( $dbr->timestamp( $periodEnd ) ); } - // Database query. - $res = $dbr->select( 'abuse_filter_log', '*', $conds, __METHOD__ ); + // All but afl_filter, afl_ip, afl_deleted, afl_patrolled_by, afl_rev_id and afl_log_id + $selectFields = [ + 'afl_id', + 'afl_user', + 'afl_user_text', + 'afl_action', + 'afl_actions', + 'afl_var_dump', + 'afl_timestamp', + 'afl_namespace', + 'afl_title', + 'afl_wiki', + ]; + $res = $dbr->select( 'abuse_filter_log', $selectFields, $conds, __METHOD__ ); $results = []; foreach ( $res as $row ) { @@ -150,7 +206,10 @@ class AbuseFilterViewRevert extends AbuseFilterView { return $results; } - function loadParameters() { + /** + * Loads parameters from request + */ + public function loadParameters() { $request = $this->getRequest(); $this->origPeriodStart = $request->getText( 'wpPeriodStart' ); @@ -161,7 +220,10 @@ class AbuseFilterViewRevert extends AbuseFilterView { $this->mReason = $request->getVal( 'wpReason' ); } - function attemptRevert() { + /** + * @return bool + */ + public function attemptRevert() { $filter = $this->mPage->mFilter; $token = $this->getRequest()->getVal( 'editToken' ); if ( !$this->getUser()->matchEditToken( $token, "abusefilter-revert-$filter" ) ) { @@ -175,22 +237,25 @@ class AbuseFilterViewRevert extends AbuseFilterView { $this->revertAction( $action, $result ); } } - $this->getOutput()->addWikiMsg( - 'abusefilter-revert-success', - $filter, - $this->getLanguage()->formatNum( $filter ) + $this->getOutput()->wrapWikiMsg( + '<p class="success">$1</p>', + [ + 'abusefilter-revert-success', + $filter, + $this->getLanguage()->formatNum( $filter ) + ] ); return true; } /** - * @param $action string - * @param $result array + * @param string $action + * @param array $result * @return bool * @throws MWException */ - function revertAction( $action, $result ) { + public function revertAction( $action, $result ) { switch ( $action ) { case 'block': $block = Block::newFromTarget( $result['user'] ); @@ -216,8 +281,7 @@ class AbuseFilterViewRevert extends AbuseFilterView { return true; case 'degroup': // Pull the user's groups from the vars. - $oldGroups = $result['vars']['USER_GROUPS']; - $oldGroups = explode( ',', $oldGroups ); + $oldGroups = $result['vars']->getVar( 'user_groups' )->toNative(); $oldGroups = array_diff( $oldGroups, array_intersect( $oldGroups, User::getImplicitGroups() ) @@ -245,15 +309,21 @@ class AbuseFilterViewRevert extends AbuseFilterView { $dbw->insert( 'user_groups', $rows, __METHOD__, [ 'IGNORE' ] ); $user->invalidateCache(); - $log = new LogPage( 'rights' ); - $log->addEntry( 'rights', $user->getUserPage(), + $logEntry = new ManualLogEntry( 'rights', 'rights' ); + $logEntry->setTarget( $user->getUserPage() ); + $logEntry->setPerformer( $this->getUser() ); + $logEntry->setComment( $this->msg( 'abusefilter-revert-reason', $this->mPage->mFilter, $this->mReason - )->inContentLanguage()->text(), - [ implode( ',', $currentGroups ), implode( ',', $newGroups ) ] + )->inContentLanguage()->text() ); + $logEntry->setParameters( [ + '4::oldgroups' => $currentGroups, + '5::newgroups' => $newGroups + ] ); + $logEntry->publish( $logEntry->insert() ); return true; } diff --git a/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php b/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php index abc33fc6..168933ec 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php @@ -4,16 +4,19 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { // Hard-coded for now. protected static $mChangeLimit = 100; - public $mShowNegative, $mTestPeriodStart, $mTestPeriodEnd, $mTestPage, - $mTestUser; + public $mShowNegative, $mTestPeriodStart, $mTestPeriodEnd, $mTestPage; + public $mTestUser, $mExcludeBots, $mTestAction; - function show() { + /** + * Shows the page + */ + public function show() { $out = $this->getOutput(); AbuseFilter::disableConditionLimit(); - if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { - $out->addWikiMsg( 'abusefilter-mustbeeditor' ); + if ( !$this->canViewPrivate() ) { + $out->addWikiMsg( 'abusefilter-mustviewprivateoredit' ); return; } @@ -21,56 +24,95 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { $out->setPageTitle( $this->msg( 'abusefilter-test' ) ); $out->addWikiMsg( 'abusefilter-test-intro', self::$mChangeLimit ); + $out->enableOOUI(); $output = ''; - $output .= AbuseFilter::buildEditBox( $this->mFilter, 'wpTestFilter' ) . "\n"; $output .= - Xml::inputLabel( - $this->msg( 'abusefilter-test-load-filter' )->text(), - 'wpInsertFilter', - 'mw-abusefilter-load-filter', - 10, - '' - ) . - ' ' . - Xml::element( - 'input', - [ - 'type' => 'button', - 'value' => $this->msg( 'abusefilter-test-load' )->text(), - 'id' => 'mw-abusefilter-load' - ] - ); + $this->buildEditBox( + $this->mFilter, + 'wpTestFilter', + true, + true, + false + ) . "\n"; + + $output .= AbuseFilter::buildFilterLoader(); $output = Xml::tags( 'div', [ 'id' => 'mw-abusefilter-test-editor' ], $output ); - $output .= Xml::tags( 'p', null, Xml::checkLabel( - $this->msg( 'abusefilter-test-shownegative' )->text(), - 'wpShowNegative', 'wpShowNegative', $this->mShowNegative ) - ); - - // Selectory stuff - $selectFields = []; - $selectFields['abusefilter-test-user'] = Xml::input( 'wpTestUser', 45, $this->mTestUser ); - $selectFields['abusefilter-test-period-start'] = - Xml::input( 'wpTestPeriodStart', 45, $this->mTestPeriodStart ); - $selectFields['abusefilter-test-period-end'] = - Xml::input( 'wpTestPeriodEnd', 45, $this->mTestPeriodEnd ); - $selectFields['abusefilter-test-page'] = - Xml::input( 'wpTestPage', 45, $this->mTestPage ); - - $output .= Xml::buildForm( $selectFields, 'abusefilter-test-submit' ); - - $output .= Html::hidden( 'title', $this->getTitle( 'test' )->getPrefixedDBkey() ); - $output = Xml::tags( 'form', - [ - 'action' => $this->getTitle( 'test' )->getLocalURL(), - 'method' => 'post' - ], - $output - ); - - $output = Xml::fieldset( $this->msg( 'abusefilter-test-legend' )->text(), $output ); - + $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); + $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge ); + $max = wfTimestampNow(); + + // Search form + $formFields = []; + $formFields['wpTestAction'] = [ + 'name' => 'wpTestAction', + 'type' => 'select', + 'label-message' => 'abusefilter-test-action', + 'options' => [ + $this->msg( 'abusefilter-test-search-type-all' )->text() => 0, + $this->msg( 'abusefilter-test-search-type-edit' )->text() => 'edit', + $this->msg( 'abusefilter-test-search-type-move' )->text() => 'move', + $this->msg( 'abusefilter-test-search-type-delete' )->text() => 'delete', + $this->msg( 'abusefilter-test-search-type-createaccount' )->text() => 'createaccount' + // @ToDo: add 'upload' once T170249 is resolved + ] + ]; + $formFields['wpTestUser'] = [ + 'name' => 'wpTestUser', + 'type' => 'user', + 'ipallowed' => true, + 'label-message' => 'abusefilter-test-user', + 'default' => $this->mTestUser + ]; + $formFields['wpExcludeBots'] = [ + 'name' => 'wpExcludeBots', + 'type' => 'check', + 'label-message' => 'abusefilter-test-nobots', + 'default' => $this->mExcludeBots + ]; + $formFields['wpTestPeriodStart'] = [ + 'name' => 'wpTestPeriodStart', + 'type' => 'datetime', + 'label-message' => 'abusefilter-test-period-start', + 'default' => $this->mTestPeriodStart, + 'min' => $min, + 'max' => $max + ]; + $formFields['wpTestPeriodEnd'] = [ + 'name' => 'wpTestPeriodEnd', + 'type' => 'datetime', + 'label-message' => 'abusefilter-test-period-end', + 'default' => $this->mTestPeriodEnd, + 'min' => $min, + 'max' => $max + ]; + $formFields['wpTestPage'] = [ + 'name' => 'wpTestPage', + 'type' => 'title', + 'label-message' => 'abusefilter-test-page', + 'default' => $this->mTestPage, + 'creatable' => true, + 'required' => false + ]; + $formFields['wpShowNegative'] = [ + 'name' => 'wpShowNegative', + 'type' => 'check', + 'label-message' => 'abusefilter-test-shownegative', + 'selected' => $this->mShowNegative + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formFields, $this->getContext() ) + ->addHiddenField( 'title', $this->getTitle( 'test' )->getPrefixedDBkey() ) + ->setId( 'wpFilterForm' ) + ->setWrapperLegendMsg( 'abusefilter-list-options' ) + ->setAction( $this->getTitle( 'test' )->getLocalURL() ) + ->setSubmitTextMsg( 'abusefilter-test-submit' ) + ->setMethod( 'post' ) + ->prepareForm() + ->getHTML( true ); + + $output = Xml::fieldset( $this->msg( 'abusefilter-test-legend' )->text(), $output . $htmlForm ); $out->addHTML( $output ); if ( $this->getRequest()->wasPosted() ) { @@ -79,9 +121,9 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { } /** - * @fixme this is similar to AbuseFilterExaminePager::getQueryInfo + * Loads the revisions and checks the given syntax against them */ - function doTest() { + public function doTest() { // Quick syntax check. $out = $this->getOutput(); $result = AbuseFilter::checkSyntax( $this->mFilter ); @@ -92,7 +134,12 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { $dbr = wfGetDB( DB_REPLICA ); $conds = []; - $conds['rc_user_text'] = $this->mTestUser; + + if ( (string)$this->mTestUser !== '' ) { + $conds[] = ActorMigration::newMigration()->getWhere( + $dbr, 'rc_user', User::newFromName( $this->mTestUser, false ) + )['conds']; + } if ( $this->mTestPeriodStart ) { $conds[] = 'rc_timestamp >= ' . @@ -113,18 +160,28 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { } } - $conds[] = $this->buildTestConditions( $dbr ); + $action = $this->mTestAction != '0' ? $this->mTestAction : false; + $conds[] = $this->buildTestConditions( $dbr, $action ); + + $conds = array_filter( $conds ); + + // To be added after filtering, otherwise it gets stripped + if ( $this->mExcludeBots ) { + $conds['rc_bot'] = 0; + } // Get our ChangesList - $changesList = new AbuseFilterChangesList( $this->getSkin() ); + $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->mFilter ); $output = $changesList->beginRecentChangesList(); + $rcQuery = RecentChange::getQueryInfo(); $res = $dbr->select( - 'recentchanges', - RecentChange::selectFields(), - array_filter( $conds ), + $rcQuery['tables'], + $rcQuery['fields'], + $conds, __METHOD__, - [ 'LIMIT' => self::$mChangeLimit, 'ORDER BY' => 'rc_timestamp desc' ] + [ 'LIMIT' => self::$mChangeLimit, 'ORDER BY' => 'rc_timestamp desc' ], + $rcQuery['joins'] ); $counter = 1; @@ -141,7 +198,7 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { if ( $result || $this->mShowNegative ) { // Stash result in RC item $rc = RecentChange::newFromRow( $row ); - $rc->examineParams['testfilter'] = $this->mFilter; + /** @suppress PhanUndeclaredProperty for $rc->filterResult, which isn't a big deal */ $rc->filterResult = $result; $rc->counter = $counter++; $output .= $changesList->recentChangesLine( $rc, false ); @@ -153,7 +210,10 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { $out->addHTML( $output ); } - function loadParameters() { + /** + * Loads parameters from request + */ + public function loadParameters() { $request = $this->getRequest(); $this->mFilter = $request->getText( 'wpTestFilter' ); @@ -162,6 +222,8 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { $this->mTestPeriodEnd = $request->getText( 'wpTestPeriodEnd' ); $this->mTestPeriodStart = $request->getText( 'wpTestPeriodStart' ); $this->mTestPage = $request->getText( 'wpTestPage' ); + $this->mExcludeBots = $request->getBool( 'wpExcludeBots' ); + $this->mTestAction = $request->getText( 'wpTestAction' ); if ( !$this->mFilter && count( $this->mParams ) > 1 @@ -176,15 +238,7 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { } // Normalise username - $userTitle = Title::newFromText( $testUsername ); - - if ( $userTitle && $userTitle->getNamespace() == NS_USER ) { - $this->mTestUser = $userTitle->getText(); // Allow User:Blah syntax. - } elseif ( $userTitle ) { - // Not sure of the value of prefixedText over text, but no need to munge unnecessarily. - $this->mTestUser = $userTitle->getPrefixedText(); - } else { - $this->mTestUser = null; // No user specified. - } + $userTitle = Title::newFromText( $testUsername, NS_USER ); + $this->mTestUser = $userTitle ? $userTitle->getText() : null; } } diff --git a/AbuseFilter/includes/Views/AbuseFilterViewTools.php b/AbuseFilter/includes/Views/AbuseFilterViewTools.php index c8625617..44ee0eb4 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewTools.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewTools.php @@ -1,12 +1,16 @@ <?php class AbuseFilterViewTools extends AbuseFilterView { - function show() { + /** + * Shows the page + */ + public function show() { $out = $this->getOutput(); - $user = $this->getUser(); + $out->enableOOUI(); + $request = $this->getRequest(); - if ( !$user->isAllowed( 'abusefilter-modify' ) ) { - $out->addWikiMsg( 'abusefilter-mustbeeditor' ); + if ( !$this->canViewPrivate() ) { + $out->addWikiMsg( 'abusefilter-mustviewprivateoredit' ); return; } @@ -15,16 +19,21 @@ class AbuseFilterViewTools extends AbuseFilterView { // Expression evaluator $eval = ''; - $eval .= AbuseFilter::buildEditBox( '', 'wpTestExpr' ); - - $eval .= Xml::tags( 'p', null, - Xml::element( 'input', - [ - 'type' => 'button', - 'id' => 'mw-abusefilter-submitexpr', - 'value' => $this->msg( 'abusefilter-tools-submitexpr' )->text() ] - ) + $eval .= $this->buildEditBox( + $request->getText( 'wpTestExpr' ), + 'wpTestExpr', + true, + false, + false ); + + $eval .= + Xml::tags( 'p', null, + new OOUI\ButtonInputWidget( [ + 'label' => $this->msg( 'abusefilter-tools-submitexpr' )->text(), + 'id' => 'mw-abusefilter-submitexpr' + ] ) + ); $eval .= Xml::element( 'p', [ 'id' => 'mw-abusefilter-expr-result' ], ' ' ); $eval = Xml::fieldset( $this->msg( 'abusefilter-tools-expr' )->text(), $eval ); @@ -33,23 +42,21 @@ class AbuseFilterViewTools extends AbuseFilterView { $out->addModules( 'ext.abuseFilter.tools' ); // Hacky little box to re-enable autoconfirmed if it got disabled - $rac = ''; - $rac .= Xml::inputLabel( - $this->msg( 'abusefilter-tools-reautoconfirm-user' )->text(), - 'wpReAutoconfirmUser', - 'reautoconfirm-user', - 45 - ); - $rac .= ' '; - $rac .= Xml::element( - 'input', - [ - 'type' => 'button', - 'id' => 'mw-abusefilter-reautoconfirmsubmit', - 'value' => $this->msg( 'abusefilter-tools-reautoconfirm-submit' )->text() - ] - ); - $rac = Xml::fieldset( $this->msg( 'abusefilter-tools-reautoconfirm' )->text(), $rac ); - $out->addHTML( $rac ); + $formDescriptor = [ + 'RestoreAutoconfirmed' => [ + 'label-message' => 'abusefilter-tools-reautoconfirm-user', + 'type' => 'user', + 'name' => 'wpReAutoconfirmUser', + 'id' => 'reautoconfirm-user', + 'infusable' => true + ], + ]; + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm->setWrapperLegendMsg( 'abusefilter-tools-reautoconfirm' ) + ->setSubmitTextMsg( 'abusefilter-tools-reautoconfirm-submit' ) + ->setSubmitName( 'wpReautoconfirmSubmit' ) + ->setSubmitId( 'mw-abusefilter-reautoconfirmsubmit' ) + ->prepareForm() + ->displayForm( false ); } } diff --git a/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php b/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php index bcea726e..9d4fd8c3 100644 --- a/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php +++ b/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php @@ -1,12 +1,15 @@ <?php class ApiAbuseFilterCheckMatch extends ApiBase { + /** + * @see ApiBase::execute + */ public function execute() { $params = $this->extractRequestParams(); $this->requireOnlyOneParameter( $params, 'vars', 'rcid', 'logid' ); // "Anti-DoS" - if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + if ( !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) { $this->dieWithError( 'apierror-abusefilter-canttest', 'permissiondenied' ); } @@ -19,11 +22,14 @@ class ApiAbuseFilterCheckMatch extends ApiBase { } } elseif ( $params['rcid'] ) { $dbr = wfGetDB( DB_REPLICA ); + $rcQuery = RecentChange::getQueryInfo(); $row = $dbr->selectRow( - 'recentchanges', - RecentChange::selectFields(), + $rcQuery['tables'], + $rcQuery['fields'], [ 'rc_id' => $params['rcid'] ], - __METHOD__ + __METHOD__, + [], + $rcQuery['joins'] ); if ( !$row ) { @@ -35,7 +41,7 @@ class ApiAbuseFilterCheckMatch extends ApiBase { $dbr = wfGetDB( DB_REPLICA ); $row = $dbr->selectRow( 'abuse_filter_log', - '*', + 'afl_var_dump', [ 'afl_id' => $params['logid'] ], __METHOD__ ); @@ -63,6 +69,10 @@ class ApiAbuseFilterCheckMatch extends ApiBase { ); } + /** + * @see ApiBase::getAllowedParams + * @return array + */ public function getAllowedParams() { return [ 'filter' => [ @@ -80,6 +90,7 @@ class ApiAbuseFilterCheckMatch extends ApiBase { /** * @see ApiBase::getExamplesMessages() + * @return array */ protected function getExamplesMessages() { return [ diff --git a/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php b/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php index b0ac3f73..213fb904 100644 --- a/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php +++ b/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php @@ -2,9 +2,12 @@ class ApiAbuseFilterCheckSyntax extends ApiBase { + /** + * @see ApiBase::execute + */ public function execute() { // "Anti-DoS" - if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + if ( !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) { $this->dieWithError( 'apierror-abusefilter-cantcheck', 'permissiondenied' ); } @@ -26,6 +29,10 @@ class ApiAbuseFilterCheckSyntax extends ApiBase { $this->getResult()->addValue( null, $this->getModuleName(), $r ); } + /** + * @see ApiBase::getAllowedParams + * @return array + */ public function getAllowedParams() { return [ 'filter' => [ @@ -36,6 +43,7 @@ class ApiAbuseFilterCheckSyntax extends ApiBase { /** * @see ApiBase::getExamplesMessages() + * @return array */ protected function getExamplesMessages() { return [ diff --git a/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php b/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php index 74fb0852..18701670 100644 --- a/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php +++ b/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php @@ -1,6 +1,9 @@ <?php class ApiAbuseFilterEvalExpression extends ApiBase { + /** + * @see ApiBase::execute() + */ public function execute() { $params = $this->extractRequestParams(); @@ -9,6 +12,10 @@ class ApiAbuseFilterEvalExpression extends ApiBase { $this->getResult()->addValue( null, $this->getModuleName(), [ 'result' => $result ] ); } + /** + * @see ApiBase::getAllowedParams() + * @return array + */ public function getAllowedParams() { return [ 'expression' => [ @@ -19,6 +26,7 @@ class ApiAbuseFilterEvalExpression extends ApiBase { /** * @see ApiBase::getExamplesMessages() + * @return array */ protected function getExamplesMessages() { return [ diff --git a/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php b/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php index 5ceb17f8..195e72d3 100644 --- a/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php +++ b/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php @@ -1,6 +1,9 @@ <?php class ApiAbuseFilterUnblockAutopromote extends ApiBase { + /** + * @see ApiBase::execute() + */ public function execute() { $this->checkUserRightsAny( 'abusefilter-modify' ); @@ -10,7 +13,7 @@ class ApiAbuseFilterUnblockAutopromote extends ApiBase { if ( $user === false ) { $encParamName = $this->encodeParamName( 'user' ); $this->dieWithError( - [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $param['user'] ) ], + [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $params['user'] ) ], "baduser_{$encParamName}" ); } @@ -27,14 +30,26 @@ class ApiAbuseFilterUnblockAutopromote extends ApiBase { $this->getResult()->addValue( null, $this->getModuleName(), $res ); } + /** + * @see ApiBase::mustBePosted() + * @return bool + */ public function mustBePosted() { return true; } + /** + * @see ApiBase::isWriteMode() + * @return bool + */ public function isWriteMode() { return true; } + /** + * @see ApiBase::getAllowedParams() + * @return array + */ public function getAllowedParams() { return [ 'user' => [ @@ -45,12 +60,17 @@ class ApiAbuseFilterUnblockAutopromote extends ApiBase { ]; } + /** + * @see ApiBase::needsToken() + * @return string + */ public function needsToken() { return 'csrf'; } /** * @see ApiBase::getExamplesMessages() + * @return array */ protected function getExamplesMessages() { return [ diff --git a/AbuseFilter/includes/api/ApiQueryAbuseFilters.php b/AbuseFilter/includes/api/ApiQueryAbuseFilters.php index 79808125..cafe20d1 100644 --- a/AbuseFilter/includes/api/ApiQueryAbuseFilters.php +++ b/AbuseFilter/includes/api/ApiQueryAbuseFilters.php @@ -30,10 +30,17 @@ * @ingroup Extensions */ class ApiQueryAbuseFilters extends ApiQueryBase { + /** + * @param ApiQuery $query + * @param string $moduleName + */ public function __construct( $query, $moduleName ) { parent::__construct( $query, $moduleName, 'abf' ); } + /** + * @see ApiQueryBase::execute() + */ public function execute() { $user = $this->getUser(); $this->checkUserRightsAny( 'abusefilter-view' ); @@ -150,6 +157,10 @@ class ApiQueryAbuseFilters extends ApiQueryBase { $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'filter' ); } + /** + * @see ApiQueryBase::getAllowedParams() + * @return array + */ public function getAllowedParams() { return [ 'startid' => [ @@ -205,6 +216,7 @@ class ApiQueryAbuseFilters extends ApiQueryBase { /** * @see ApiBase::getExamplesMessages() + * @return array */ protected function getExamplesMessages() { return [ diff --git a/AbuseFilter/includes/api/ApiQueryAbuseLog.php b/AbuseFilter/includes/api/ApiQueryAbuseLog.php index b559b0c5..bf085318 100644 --- a/AbuseFilter/includes/api/ApiQueryAbuseLog.php +++ b/AbuseFilter/includes/api/ApiQueryAbuseLog.php @@ -30,10 +30,17 @@ * @ingroup Extensions */ class ApiQueryAbuseLog extends ApiQueryBase { + /** + * @param ApiQuery $query + * @param string $moduleName + */ public function __construct( $query, $moduleName ) { parent::__construct( $query, $moduleName, 'afl' ); } + /** + * @see ApiQueryBase::execute() + */ public function execute() { $user = $this->getUser(); $errors = $this->getTitle()->getUserPermissionsErrors( @@ -49,7 +56,6 @@ class ApiQueryAbuseLog extends ApiQueryBase { $fld_ids = isset( $prop['ids'] ); $fld_filter = isset( $prop['filter'] ); $fld_user = isset( $prop['user'] ); - $fld_ip = isset( $prop['ip'] ); $fld_title = isset( $prop['title'] ); $fld_action = isset( $prop['action'] ); $fld_details = isset( $prop['details'] ); @@ -57,10 +63,9 @@ class ApiQueryAbuseLog extends ApiQueryBase { $fld_timestamp = isset( $prop['timestamp'] ); $fld_hidden = isset( $prop['hidden'] ); $fld_revid = isset( $prop['revid'] ); + $isCentral = $this->getConfig()->get( 'AbuseFilterIsCentral' ); + $fld_wiki = $isCentral && isset( $prop['wiki'] ); - if ( $fld_ip ) { - $this->checkUserRightsAny( 'abusefilter-private' ); - } if ( $fld_details ) { $this->checkUserRightsAny( 'abusefilter-log-detail' ); } @@ -90,11 +95,11 @@ class ApiQueryAbuseLog extends ApiQueryBase { $this->addFields( 'afl_filter' ); $this->addFieldsIf( 'afl_id', $fld_ids ); $this->addFieldsIf( 'afl_user_text', $fld_user ); - $this->addFieldsIf( 'afl_ip', $fld_ip ); $this->addFieldsIf( [ 'afl_namespace', 'afl_title' ], $fld_title ); $this->addFieldsIf( 'afl_action', $fld_action ); $this->addFieldsIf( 'afl_var_dump', $fld_details ); $this->addFieldsIf( 'afl_actions', $fld_result ); + $this->addFieldsIf( 'afl_wiki', $fld_wiki ); if ( $fld_filter ) { $this->addTables( 'abuse_filter' ); @@ -105,6 +110,8 @@ class ApiQueryAbuseLog extends ApiQueryBase { $this->addOption( 'LIMIT', $params['limit'] + 1 ); + $this->addWhereIf( [ 'afl_id' => $params['logid'] ], isset( $params['logid'] ) ); + $this->addWhereRange( 'afl_timestamp', $params['dir'], $params['start'], $params['end'] ); $db = $this->getDB(); @@ -135,7 +142,11 @@ class ApiQueryAbuseLog extends ApiQueryBase { } $this->addWhereIf( [ 'afl_filter' => $params['filter'] ], isset( $params['filter'] ) ); - $this->addWhereIf( $notDeletedCond, !SpecialAbuseLog::canSeeHidden( $user ) ); + $this->addWhereIf( $notDeletedCond, !SpecialAbuseLog::canSeeHidden() ); + if ( isset( $params['wiki'] ) ) { + // 'wiki' won't be set if $wgAbuseFilterIsCentral = false + $this->addWhereIf( [ 'afl_wiki' => $params['wiki'] ], $isCentral ); + } $title = $params['title']; if ( !is_null( $title ) ) { @@ -170,10 +181,7 @@ class ApiQueryAbuseLog extends ApiQueryBase { $entry = []; if ( $fld_ids ) { $entry['id'] = intval( $row->afl_id ); - $entry['filter_id'] = ''; - if ( $canSeeDetails ) { - $entry['filter_id'] = $row->afl_filter; - } + $entry['filter_id'] = $canSeeDetails ? $row->afl_filter : ''; } if ( $fld_filter ) { $globalIndex = AbuseFilter::decodeGlobalName( $row->afl_filter ); @@ -186,8 +194,8 @@ class ApiQueryAbuseLog extends ApiQueryBase { if ( $fld_user ) { $entry['user'] = $row->afl_user_text; } - if ( $fld_ip ) { - $entry['ip'] = $row->afl_ip; + if ( $fld_wiki ) { + $entry['wiki'] = $row->afl_wiki; } if ( $fld_title ) { $title = Title::makeTitle( $row->afl_namespace, $row->afl_title ); @@ -200,10 +208,7 @@ class ApiQueryAbuseLog extends ApiQueryBase { $entry['result'] = $row->afl_actions; } if ( $fld_revid && !is_null( $row->afl_rev_id ) ) { - $entry['revid'] = ''; - if ( $canSeeDetails ) { - $entry['revid'] = $row->afl_rev_id; - } + $entry['revid'] = $canSeeDetails ? $row->afl_rev_id : ''; } if ( $fld_timestamp ) { $ts = new MWTimestamp( $row->afl_timestamp ); @@ -237,8 +242,15 @@ class ApiQueryAbuseLog extends ApiQueryBase { $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'item' ); } + /** + * @see ApiQueryBase::getAllowedParams() + * @return array + */ public function getAllowedParams() { - return [ + $params = [ + 'logid' => [ + ApiBase::PARAM_TYPE => 'integer' + ], 'start' => [ ApiBase::PARAM_TYPE => 'timestamp' ], @@ -256,6 +268,7 @@ class ApiQueryAbuseLog extends ApiQueryBase { 'user' => null, 'title' => null, 'filter' => [ + ApiBase::PARAM_TYPE => 'string', ApiBase::PARAM_ISMULTI => true ], 'limit' => [ @@ -271,7 +284,6 @@ class ApiQueryAbuseLog extends ApiQueryBase { 'ids', 'filter', 'user', - 'ip', 'title', 'action', 'details', @@ -283,10 +295,19 @@ class ApiQueryAbuseLog extends ApiQueryBase { ApiBase::PARAM_ISMULTI => true ] ]; + if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) { + $params['wiki'] = [ + ApiBase::PARAM_TYPE => 'string', + ]; + $params['prop'][ApiBase::PARAM_DFLT] .= '|wiki'; + $params['prop'][ApiBase::PARAM_TYPE][] = 'wiki'; + } + return $params; } /** * @see ApiBase::getExamplesMessages() + * @return array */ protected function getExamplesMessages() { return [ diff --git a/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php b/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php new file mode 100644 index 00000000..02b13755 --- /dev/null +++ b/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php @@ -0,0 +1,82 @@ +<?php + +class AbuseFilterExaminePager extends ReverseChronologicalPager { + public $mChangesList, $mPage; + + /** + * @param AbuseFilterViewExamine $page + * @param AbuseFilterChangesList $changesList + */ + public function __construct( $page, $changesList ) { + parent::__construct(); + $this->mChangesList = $changesList; + $this->mPage = $page; + } + + /** + * @return array + */ + public function getQueryInfo() { + $dbr = wfGetDB( DB_REPLICA ); + $conds = []; + + if ( (string)$this->mPage->mSearchUser !== '' ) { + $conds[] = ActorMigration::newMigration()->getWhere( + $dbr, 'rc_user', User::newFromName( $this->mPage->mSearchUser, false ) + )['conds']; + } + + $startTS = strtotime( $this->mPage->mSearchPeriodStart ); + if ( $startTS ) { + $conds[] = 'rc_timestamp>=' . $dbr->addQuotes( $dbr->timestamp( $startTS ) ); + } + $endTS = strtotime( $this->mPage->mSearchPeriodEnd ); + if ( $endTS ) { + $conds[] = 'rc_timestamp<=' . $dbr->addQuotes( $dbr->timestamp( $endTS ) ); + } + + $conds[] = $this->mPage->buildTestConditions( $dbr ); + + $rcQuery = RecentChange::getQueryInfo(); + $info = [ + 'tables' => $rcQuery['tables'], + 'fields' => $rcQuery['fields'], + 'conds' => array_filter( $conds ), + 'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ], + 'join_conds' => $rcQuery['joins'], + ]; + + return $info; + } + + /** + * @param stdClass $row + * @return string + */ + public function formatRow( $row ) { + $rc = RecentChange::newFromRow( $row ); + $rc->counter = $this->mPage->mCounter++; + return $this->mChangesList->recentChangesLine( $rc, false ); + } + + /** + * @return string + */ + public function getIndexField() { + return 'rc_id'; + } + + /** + * @return Title + */ + public function getTitle() { + return $this->mPage->getTitle( 'examine' ); + } + + /** + * @return string + */ + public function getEmptyBody() { + return $this->msg( 'abusefilter-examine-noresults' )->parseAsBlock(); + } +} diff --git a/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php b/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php new file mode 100644 index 00000000..265c97f1 --- /dev/null +++ b/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php @@ -0,0 +1,229 @@ +<?php + +use MediaWiki\Linker\LinkRenderer; + +class AbuseFilterHistoryPager extends TablePager { + public $mFilter, $mPage, $mUser; + + protected $linkRenderer; + /** + * @param string $filter + * @param AbuseFilterViewHistory $page + * @param string $user User name + * @param LinkRenderer $linkRenderer + */ + public function __construct( $filter, $page, $user, $linkRenderer ) { + $this->mFilter = $filter; + $this->mPage = $page; + $this->mUser = $user; + $this->mDefaultDirection = true; + $this->linkRenderer = $linkRenderer; + parent::__construct( $this->mPage->getContext() ); + } + + /** + * @see Pager::getFieldNames() + * @return array + */ + public function getFieldNames() { + static $headers = null; + + if ( !empty( $headers ) ) { + return $headers; + } + + $headers = [ + 'afh_timestamp' => 'abusefilter-history-timestamp', + 'afh_user_text' => 'abusefilter-history-user', + 'afh_public_comments' => 'abusefilter-history-public', + 'afh_flags' => 'abusefilter-history-flags', + 'afh_actions' => 'abusefilter-history-actions', + 'afh_id' => 'abusefilter-history-diff', + ]; + + if ( !$this->mFilter ) { + // awful hack + $headers = [ 'afh_filter' => 'abusefilter-history-filterid' ] + $headers; + unset( $headers['afh_comments'] ); + } + + foreach ( $headers as &$msg ) { + $msg = $this->msg( $msg )->text(); + } + + return $headers; + } + + /** + * @param string $name + * @param string $value + * @return string + */ + public function formatValue( $name, $value ) { + $lang = $this->getLanguage(); + + $row = $this->mCurrentRow; + + switch ( $name ) { + case 'afh_filter': + $formatted = $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->afh_filter ) ), + $lang->formatNum( $row->afh_filter ) + ); + break; + case 'afh_timestamp': + $title = SpecialPage::getTitleFor( 'AbuseFilter', + 'history/' . $row->afh_filter . '/item/' . $row->afh_id ); + $formatted = $this->linkRenderer->makeLink( + $title, + $lang->timeanddate( $row->afh_timestamp, true ) + ); + break; + case 'afh_user_text': + $formatted = + Linker::userLink( $row->afh_user, $row->afh_user_text ) . ' ' . + Linker::userToolLinks( $row->afh_user, $row->afh_user_text ); + break; + case 'afh_public_comments': + $formatted = htmlspecialchars( $value, ENT_QUOTES, 'UTF-8', false ); + break; + case 'afh_flags': + $formatted = AbuseFilter::formatFlags( $value ); + break; + case 'afh_actions': + $actions = unserialize( $value ); + + $display_actions = ''; + + foreach ( $actions as $action => $parameters ) { + $displayAction = AbuseFilter::formatAction( $action, $parameters ); + $display_actions .= Xml::tags( 'li', null, $displayAction ); + } + $display_actions = Xml::tags( 'ul', null, $display_actions ); + + $formatted = $display_actions; + break; + case 'afh_id': + $formatted = ''; + if ( AbuseFilter::getFirstFilterChange( $row->afh_filter ) != $value ) { + // Set a link to a diff with the previous version if this isn't the first edit to the filter + $title = $this->mPage->getTitle( + 'history/' . $row->afh_filter . "/diff/prev/$value" ); + $formatted = $this->linkRenderer->makeLink( + $title, + new HtmlArmor( $this->msg( 'abusefilter-history-diff' )->parse() ) + ); + } + break; + default: + $formatted = "Unable to format $name"; + break; + } + + $mappings = array_flip( AbuseFilter::$history_mappings ) + + [ 'afh_actions' => 'actions', 'afh_id' => 'id' ]; + $changed = explode( ',', $row->afh_changed_fields ); + + $fieldChanged = false; + if ( $name == 'afh_flags' ) { + // This is a bit freaky, but it works. + // Basically, returns true if any of those filters are in the $changed array. + $filters = [ 'af_enabled', 'af_hidden', 'af_deleted', 'af_global' ]; + if ( count( array_diff( $filters, $changed ) ) < count( $filters ) ) { + $fieldChanged = true; + } + } elseif ( in_array( $mappings[$name], $changed ) ) { + $fieldChanged = true; + } + + if ( $fieldChanged ) { + $formatted = Xml::tags( 'div', + [ 'class' => 'mw-abusefilter-history-changed' ], + $formatted + ); + } + + return $formatted; + } + + /** + * @return array + */ + public function getQueryInfo() { + $info = [ + 'tables' => [ 'abuse_filter_history', 'abuse_filter' ], + // All fields but afh_deleted on abuse_filter_history + 'fields' => [ + 'afh_filter', + 'afh_timestamp', + 'afh_user_text', + 'afh_public_comments', + 'afh_flags', + 'afh_comments', + 'afh_actions', + 'afh_id', + 'afh_user', + 'afh_changed_fields', + 'afh_pattern', + 'af_hidden' + ], + 'conds' => [], + 'join_conds' => [ + 'abuse_filter' => + [ + 'LEFT JOIN', + 'afh_filter=af_id', + ], + ], + ]; + + if ( $this->mUser ) { + $info['conds']['afh_user_text'] = $this->mUser; + } + + if ( $this->mFilter ) { + $info['conds']['afh_filter'] = $this->mFilter; + } + + if ( !$this->getUser()->isAllowedAny( + 'abusefilter-modify', 'abusefilter-view-private' ) + ) { + // Hide data the user can't see. + $info['conds']['af_hidden'] = 0; + } + + return $info; + } + + /** + * @return string + */ + public function getIndexField() { + return 'afh_timestamp'; + } + + /** + * @return string + */ + public function getDefaultSort() { + return 'afh_timestamp'; + } + + /** + * @param string $name + * @return bool + */ + public function isFieldSortable( $name ) { + $sortable_fields = [ 'afh_timestamp', 'afh_user_text' ]; + return in_array( $name, $sortable_fields ); + } + + /** + * Title used for self-links. + * + * @return Title + */ + public function getTitle() { + return $this->mPage->getTitle( 'history/' . $this->mFilter ); + } +} diff --git a/AbuseFilter/includes/pagers/AbuseFilterPager.php b/AbuseFilter/includes/pagers/AbuseFilterPager.php new file mode 100644 index 00000000..2092b850 --- /dev/null +++ b/AbuseFilter/includes/pagers/AbuseFilterPager.php @@ -0,0 +1,296 @@ +<?php + +use MediaWiki\Linker\LinkRenderer; + +/** + * Class to build paginated filter list + */ +class AbuseFilterPager extends TablePager { + + /** + * @var LinkRenderer + */ + protected $linkRenderer; + + public $mPage, $mConds, $mQuery; + + /** + * @param AbuseFilterViewList $page + * @param array $conds + * @param LinkRenderer $linkRenderer + * @param array $query + */ + public function __construct( $page, $conds, $linkRenderer, $query ) { + $this->mPage = $page; + $this->mConds = $conds; + $this->linkRenderer = $linkRenderer; + $this->mQuery = $query; + parent::__construct( $this->mPage->getContext() ); + } + + /** + * @return array + */ + public function getQueryInfo() { + return [ + 'tables' => [ 'abuse_filter' ], + 'fields' => [ + // All columns but af_comments + 'af_id', + 'af_enabled', + 'af_deleted', + 'af_pattern', + 'af_global', + 'af_public_comments', + 'af_hidden', + 'af_hit_count', + 'af_timestamp', + 'af_user_text', + 'af_user', + 'af_actions', + 'af_group', + 'af_throttled' + ], + 'conds' => $this->mConds, + ]; + } + + /** + * @see Pager::getFieldNames() + * @return array + */ + public function getFieldNames() { + static $headers = null; + + if ( !empty( $headers ) ) { + return $headers; + } + + $headers = [ + 'af_id' => 'abusefilter-list-id', + 'af_public_comments' => 'abusefilter-list-public', + 'af_actions' => 'abusefilter-list-consequences', + 'af_enabled' => 'abusefilter-list-status', + 'af_timestamp' => 'abusefilter-list-lastmodified', + 'af_hidden' => 'abusefilter-list-visibility', + ]; + + if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) { + $headers['af_hit_count'] = 'abusefilter-list-hitcount'; + } + + if ( AbuseFilterView::canViewPrivate() && !empty( $this->mQuery[0] ) ) { + $headers['af_pattern'] = 'abusefilter-list-pattern'; + } + + if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ) { + $headers['af_group'] = 'abusefilter-list-group'; + } + + foreach ( $headers as &$msg ) { + $msg = $this->msg( $msg )->text(); + } + + return $headers; + } + + /** + * @param string $name + * @param string $value + * @return string + */ + public function formatValue( $name, $value ) { + $lang = $this->getLanguage(); + $row = $this->mCurrentRow; + + switch ( $name ) { + case 'af_id': + return $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', intval( $value ) ), + $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 = '<b>' . + htmlspecialchars( mb_substr( $row->af_pattern, $position, 50 ) ) . + '</b>'; + } 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 ) ) . + '<b>' . + htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length ) ) . + '</b>' . + htmlspecialchars( mb_substr( + $pattern, + $position - $minoffset + $length, + $remaining - ( $position - $minoffset + $length ) + ) + ); + } + return $pattern; + case 'af_public_comments': + return $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->af_id ) ), + $value + ); + case 'af_actions': + $actions = explode( ',', $value ); + $displayActions = []; + foreach ( $actions as $action ) { + $displayActions[] = AbuseFilter::getActionDisplay( $action ); + } + return $lang->commaList( $displayActions ); + case 'af_enabled': + $statuses = []; + if ( $row->af_deleted ) { + $statuses[] = $this->msg( 'abusefilter-deleted' )->parse(); + } elseif ( $row->af_enabled ) { + $statuses[] = $this->msg( 'abusefilter-enabled' )->parse(); + if ( $row->af_throttled ) { + $statuses[] = $this->msg( 'abusefilter-throttled' )->parse(); + } + } else { + $statuses[] = $this->msg( 'abusefilter-disabled' )->parse(); + } + + if ( $row->af_global && $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) { + $statuses[] = $this->msg( 'abusefilter-status-global' )->parse(); + } + + return $lang->commaList( $statuses ); + case 'af_hidden': + $msg = $value ? 'abusefilter-hidden' : 'abusefilter-unhidden'; + return $this->msg( $msg )->parse(); + case 'af_hit_count': + if ( SpecialAbuseLog::canSeeDetails( $row->af_id, $row->af_hidden ) ) { + $count_display = $this->msg( 'abusefilter-hitcount' ) + ->numParams( $value )->text(); + $link = $this->linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'AbuseLog' ), + $count_display, + [], + [ 'wpSearchFilter' => $row->af_id ] + ); + } else { + $link = ""; + } + return $link; + case 'af_timestamp': + $userLink = + Linker::userLink( + $row->af_user, + $row->af_user_text + ) . + Linker::userToolLinks( + $row->af_user, + $row->af_user_text + ); + + return $this->msg( 'abusefilter-edit-lastmod-text' ) + ->rawParams( + $this->mPage->getLinkToLatestDiff( + $row->af_id, + $lang->timeanddate( $value, true ) + ), + $userLink, + $this->mPage->getLinkToLatestDiff( + $row->af_id, + $lang->date( $value, true ) + ), + $this->mPage->getLinkToLatestDiff( + $row->af_id, + $lang->time( $value, true ) + ) + )->params( + wfEscapeWikiText( $row->af_user_text ) + )->parse(); + case 'af_group': + return AbuseFilter::nameGroup( $value ); + default: + throw new MWException( "Unknown row type $name!" ); + } + } + + /** + * @return string + */ + public function getDefaultSort() { + return 'af_id'; + } + + /** + * @return string + */ + public function getTableClass() { + return 'TablePager mw-abusefilter-list-scrollable'; + } + + /** + * @see TablePager::getRowClass() + * @param stdClass $row + * @return string + */ + public function getRowClass( $row ) { + if ( $row->af_enabled ) { + return $row->af_throttled ? 'mw-abusefilter-list-throttled' : 'mw-abusefilter-list-enabled'; + } elseif ( $row->af_deleted ) { + return 'mw-abusefilter-list-deleted'; + } else { + return 'mw-abusefilter-list-disabled'; + } + } + + /** + * @param string $name + * @return bool + */ + public function isFieldSortable( $name ) { + $sortable_fields = [ + 'af_id', + 'af_enabled', + 'af_timestamp', + 'af_hidden', + 'af_group', + ]; + if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) { + $sortable_fields[] = 'af_hit_count'; + } + return in_array( $name, $sortable_fields ); + } +} diff --git a/AbuseFilter/includes/pagers/AbuseLogPager.php b/AbuseFilter/includes/pagers/AbuseLogPager.php new file mode 100644 index 00000000..5c4f1a66 --- /dev/null +++ b/AbuseFilter/includes/pagers/AbuseLogPager.php @@ -0,0 +1,89 @@ +<?php + +use Wikimedia\Rdbms\IResultWrapper; + +class AbuseLogPager extends ReverseChronologicalPager { + /** + * @var SpecialAbuseLog + */ + public $mForm; + + /** + * @var array + */ + public $mConds; + + /** + * @param SpecialAbuseLog $form + * @param array $conds + */ + public function __construct( $form, $conds = [] ) { + $this->mForm = $form; + $this->mConds = $conds; + parent::__construct(); + } + + /** + * @param object $row + * @return string + */ + public function formatRow( $row ) { + return $this->mForm->formatRow( $row ); + } + + /** + * @return array + */ + public function getQueryInfo() { + $conds = $this->mConds; + + $info = [ + 'tables' => [ 'abuse_filter_log', 'abuse_filter' ], + 'fields' => '*', + 'conds' => $conds, + 'join_conds' => + [ 'abuse_filter' => + [ + 'LEFT JOIN', + 'af_id=afl_filter', + ], + ], + ]; + + if ( !$this->mForm->canSeeHidden() ) { + $db = $this->mDb; + $info['conds'][] = SpecialAbuseLog::getNotDeletedCond( $db ); + } + + return $info; + } + + /** + * @param IResultWrapper $result + */ + protected function preprocessResults( $result ) { + if ( $this->getNumRows() === 0 ) { + return; + } + + $lb = new LinkBatch(); + $lb->setCaller( __METHOD__ ); + foreach ( $result as $row ) { + // Only for local wiki results + if ( !$row->afl_wiki ) { + $lb->add( $row->afl_namespace, $row->afl_title ); + $lb->add( NS_USER, $row->afl_user ); + $lb->add( NS_USER_TALK, $row->afl_user_text ); + } + } + $lb->execute(); + $result->seek( 0 ); + } + + /** + * @return string + */ + public function getIndexField() { + return 'afl_timestamp'; + } +} diff --git a/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php b/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php new file mode 100644 index 00000000..30173475 --- /dev/null +++ b/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php @@ -0,0 +1,81 @@ +<?php + +use MediaWiki\Linker\LinkRenderer; + +/** + * Class to build paginated filter list for wikis using global abuse filters + */ +class GlobalAbuseFilterPager extends AbuseFilterPager { + /** + * @param AbuseFilterViewList $page + * @param array $conds + * @param LinkRenderer $linkRenderer + */ + public function __construct( $page, $conds, $linkRenderer ) { + parent::__construct( $page, $conds, $linkRenderer, [ '', 'LIKE' ] ); + $this->mDb = wfGetDB( + DB_REPLICA, [], $this->getConfig()->get( 'AbuseFilterCentralDB' ) ); + } + + /** + * @param string $name + * @param string $value + * @return string + */ + public function formatValue( $name, $value ) { + $lang = $this->getLanguage(); + $row = $this->mCurrentRow; + + switch ( $name ) { + case 'af_id': + return $lang->formatNum( intval( $value ) ); + case 'af_public_comments': + return $this->getOutput()->parseInline( $value ); + case 'af_actions': + $actions = explode( ',', $value ); + $displayActions = []; + foreach ( $actions as $action ) { + $displayActions[] = AbuseFilter::getActionDisplay( $action ); + } + return $lang->commaList( $displayActions ); + case 'af_enabled': + $statuses = []; + if ( $row->af_deleted ) { + $statuses[] = $this->msg( 'abusefilter-deleted' )->parse(); + } elseif ( $row->af_enabled ) { + $statuses[] = $this->msg( 'abusefilter-enabled' )->parse(); + } else { + $statuses[] = $this->msg( 'abusefilter-disabled' )->parse(); + } + if ( $row->af_global ) { + $statuses[] = $this->msg( 'abusefilter-status-global' )->parse(); + } + + return $lang->commaList( $statuses ); + case 'af_hidden': + $msg = $value ? 'abusefilter-hidden' : 'abusefilter-unhidden'; + return $this->msg( $msg )->parse(); + case 'af_hit_count': + // If the rule is hidden, don't show it, even to priviledged local admins + if ( $row->af_hidden ) { + return ''; + } + return $this->msg( 'abusefilter-hitcount' )->numParams( $value )->parse(); + case 'af_timestamp': + $user = $row->af_user_text; + return $this->msg( + 'abusefilter-edit-lastmod-text', + $lang->timeanddate( $value, true ), + $user, + $lang->date( $value, true ), + $lang->time( $value, true ), + $user + )->parse(); + case 'af_group': + // If this is global, local name probably doesn't exist, but try + return AbuseFilter::nameGroup( $value ); + default: + throw new MWException( "Unknown row type $name!" ); + } + } +} diff --git a/AbuseFilter/includes/parser/AFPData.php b/AbuseFilter/includes/parser/AFPData.php index d5a0069d..de8d8270 100644 --- a/AbuseFilter/includes/parser/AFPData.php +++ b/AbuseFilter/includes/parser/AFPData.php @@ -7,7 +7,7 @@ class AFPData { const DNULL = 'null'; const DBOOL = 'bool'; const DFLOAT = 'float'; - const DLIST = 'list'; + const DARRAY = 'array'; // Translation table mapping shell-style wildcards to PCRE equivalents. // Derived from <http://www.php.net/manual/en/function.fnmatch.php#100207> @@ -28,7 +28,7 @@ class AFPData { /** * @param string $type - * @param null $val + * @param mixed|null $val */ public function __construct( $type = self::DNULL, $val = null ) { $this->type = $type; @@ -36,7 +36,7 @@ class AFPData { } /** - * @param $var + * @param mixed $var * @return AFPData * @throws AFPException */ @@ -55,7 +55,7 @@ class AFPData { $result[] = self::newFromPHPVar( $item ); } - return new AFPData( self::DLIST, $result ); + return new AFPData( self::DARRAY, $result ); } elseif ( is_null( $var ) ) { return new AFPData(); } else { @@ -73,8 +73,8 @@ class AFPData { } /** - * @param $orig AFPData - * @param $target + * @param AFPData $orig + * @param string $target * @return AFPData */ public static function castTypes( $orig, $target ) { @@ -85,7 +85,7 @@ class AFPData { return new AFPData(); } - if ( $orig->type == self::DLIST ) { + if ( $orig->type == self::DARRAY ) { if ( $target == self::DBOOL ) { return new AFPData( self::DBOOL, (bool)count( $orig->data ) ); } @@ -117,13 +117,13 @@ class AFPData { if ( $target == self::DSTRING ) { return new AFPData( self::DSTRING, strval( $orig->data ) ); } - if ( $target == self::DLIST ) { - return new AFPData( self::DLIST, [ $orig ] ); + if ( $target == self::DARRAY ) { + return new AFPData( self::DARRAY, [ $orig ] ); } } /** - * @param $value AFPData + * @param AFPData $value * @return AFPData */ public static function boolInvert( $value ) { @@ -131,17 +131,22 @@ class AFPData { } /** - * @param $base AFPData - * @param $exponent AFPData + * @param AFPData $base + * @param AFPData $exponent * @return AFPData */ public static function pow( $base, $exponent ) { - return new AFPData( self::DFLOAT, pow( $base->toFloat(), $exponent->toFloat() ) ); + $res = pow( $base->toNumber(), $exponent->toNumber() ); + if ( $res === (int)$res ) { + return new AFPData( self::DINT, $res ); + } else { + return new AFPData( self::DFLOAT, $res ); + } } /** - * @param $a AFPData - * @param $b AFPData + * @param AFPData $a + * @param AFPData $b * @return AFPData */ public static function keywordIn( $a, $b ) { @@ -156,8 +161,8 @@ class AFPData { } /** - * @param $a AFPData - * @param $b AFPData + * @param AFPData $a + * @param AFPData $b * @return AFPData */ public static function keywordContains( $a, $b ) { @@ -172,51 +177,64 @@ class AFPData { } /** - * @param $value - * @param $list + * @param AFPData $d1 + * @param AFPData $d2 + * @param bool $strict whether to also check types * @return bool */ - public static function listContains( $value, $list ) { - // Should use built-in PHP function somehow - foreach ( $list->data as $item ) { - if ( self::equals( $value, $item ) ) { - return true; + public static function equals( $d1, $d2, $strict = false ) { + if ( $d1->type != self::DARRAY && $d2->type != self::DARRAY ) { + $typecheck = $d1->type == $d2->type || !$strict; + return $typecheck && $d1->toString() === $d2->toString(); + } elseif ( $d1->type == self::DARRAY && $d2->type == self::DARRAY ) { + $data1 = $d1->data; + $data2 = $d2->data; + if ( count( $data1 ) !== count( $data2 ) ) { + return false; + } + $length = count( $data1 ); + for ( $i = 0; $i < $length; $i++ ) { + $result = self::equals( $data1[$i], $data2[$i], $strict ); + if ( $result === false ) { + return false; + } + } + return true; + } else { + // Trying to compare an array to something else + if ( $strict ) { + return false; + } + if ( $d1->type == self::DARRAY && count( $d1->data ) === 0 ) { + return ( $d2->type == self::DBOOL && $d2->toBool() == false ) || $d2->type == self::DNULL; + } elseif ( $d2->type == self::DARRAY && count( $d2->data ) === 0 ) { + return ( $d1->type == self::DBOOL && $d1->toBool() == false ) || $d1->type == self::DNULL; + } else { + return false; } } - - return false; } /** - * @param $d1 AFPData - * @param $d2 AFPData - * @return bool - */ - public static function equals( $d1, $d2 ) { - return $d1->type != self::DLIST && $d2->type != self::DLIST && - $d1->toString() === $d2->toString(); - } - - /** - * @param $str AFPData - * @param $pattern AFPData + * @param AFPData $str + * @param AFPData $pattern * @return AFPData */ public static function keywordLike( $str, $pattern ) { $str = $str->toString(); $pattern = '#^' . strtr( preg_quote( $pattern->toString(), '#' ), self::$wildcardMap ) . '$#u'; - MediaWiki\suppressWarnings(); + Wikimedia\suppressWarnings(); $result = preg_match( $pattern, $str ); - MediaWiki\restoreWarnings(); + Wikimedia\restoreWarnings(); return new AFPData( self::DBOOL, (bool)$result ); } /** - * @param $str AFPData - * @param $regex AFPData - * @param $pos - * @param $insensitive bool + * @param AFPData $str + * @param AFPData $regex + * @param int $pos + * @param bool $insensitive * @return AFPData * @throws Exception */ @@ -231,12 +249,14 @@ class AFPData { $pattern .= 'i'; } + Wikimedia\suppressWarnings(); $result = preg_match( $pattern, $str ); + Wikimedia\restoreWarnings(); if ( $result === false ) { throw new AFPUserVisibleException( 'regexfailure', $pos, - [ 'unspecified error in preg_match()', $pattern ] + [ $pattern ] ); } @@ -244,9 +264,9 @@ class AFPData { } /** - * @param $str - * @param $regex - * @param $pos + * @param AFPData $str + * @param AFPData $regex + * @param int $pos * @return AFPData */ public static function keywordRegexInsensitive( $str, $regex, $pos ) { @@ -254,7 +274,7 @@ class AFPData { } /** - * @param $data AFPData + * @param AFPData $data * @return AFPData */ public static function unaryMinus( $data ) { @@ -266,9 +286,9 @@ class AFPData { } /** - * @param $a AFPData - * @param $b AFPData - * @param $op string + * @param AFPData $a + * @param AFPData $b + * @param string $op * @return AFPData * @throws AFPException */ @@ -284,13 +304,14 @@ class AFPData { if ( $op == '^' ) { return new AFPData( self::DBOOL, $a xor $b ); } - throw new AFPException( "Invalid boolean operation: {$op}" ); // Should never happen. + // Should never happen. + throw new AFPException( "Invalid boolean operation: {$op}" ); } /** - * @param $a AFPData - * @param $b AFPData - * @param $op string + * @param AFPData $a + * @param AFPData $b + * @param string $op * @return AFPData * @throws AFPException */ @@ -302,10 +323,10 @@ class AFPData { return new AFPData( self::DBOOL, !self::equals( $a, $b ) ); } if ( $op == '===' ) { - return new AFPData( self::DBOOL, $a->type == $b->type && self::equals( $a, $b ) ); + return new AFPData( self::DBOOL, self::equals( $a, $b, true ) ); } if ( $op == '!==' ) { - return new AFPData( self::DBOOL, $a->type != $b->type || !self::equals( $a, $b ) ); + return new AFPData( self::DBOOL, !self::equals( $a, $b, true ) ); } $a = $a->toString(); $b = $b->toString(); @@ -321,31 +342,22 @@ class AFPData { if ( $op == '<=' ) { return new AFPData( self::DBOOL, $a <= $b ); } - throw new AFPException( "Invalid comparison operation: {$op}" ); // Should never happen + // Should never happen + throw new AFPException( "Invalid comparison operation: {$op}" ); } /** - * @param $a AFPData - * @param $b AFPData - * @param $op string - * @param $pos + * @param AFPData $a + * @param AFPData $b + * @param string $op + * @param int $pos * @return AFPData * @throws AFPUserVisibleException * @throws AFPException */ public static function mulRel( $a, $b, $op, $pos ) { - // Figure out the type. - if ( $a->type == self::DFLOAT || $b->type == self::DFLOAT || - $a->toFloat() != $a->toString() || $b->toFloat() != $b->toString() - ) { - $type = self::DFLOAT; - $a = $a->toFloat(); - $b = $b->toFloat(); - } else { - $type = self::DINT; - $a = $a->toInt(); - $b = $b->toInt(); - } + $a = $a->toNumber(); + $b = $b->toNumber(); if ( $op != '*' && $b == 0 ) { throw new AFPUserVisibleException( 'dividebyzero', $pos, [ $a ] ); @@ -362,37 +374,49 @@ class AFPData { throw new AFPException( "Invalid multiplication-related operation: {$op}" ); } - if ( $type == self::DINT ) { + if ( $data === (int)$data ) { $data = intval( $data ); + $type = self::DINT; } else { $data = floatval( $data ); + $type = self::DFLOAT; } return new AFPData( $type, $data ); } /** - * @param $a AFPData - * @param $b AFPData + * @param AFPData $a + * @param AFPData $b * @return AFPData */ public static function sum( $a, $b ) { if ( $a->type == self::DSTRING || $b->type == self::DSTRING ) { return new AFPData( self::DSTRING, $a->toString() . $b->toString() ); - } elseif ( $a->type == self::DLIST && $b->type == self::DLIST ) { - return new AFPData( self::DLIST, array_merge( $a->toList(), $b->toList() ) ); + } elseif ( $a->type == self::DARRAY && $b->type == self::DARRAY ) { + return new AFPData( self::DARRAY, array_merge( $a->toArray(), $b->toArray() ) ); } else { - return new AFPData( self::DFLOAT, $a->toFloat() + $b->toFloat() ); + $res = $a->toNumber() + $b->toNumber(); + if ( $res === (int)$res ) { + return new AFPData( self::DINT, $res ); + } else { + return new AFPData( self::DFLOAT, $res ); + } } } /** - * @param $a AFPData - * @param $b AFPData + * @param AFPData $a + * @param AFPData $b * @return AFPData */ public static function sub( $a, $b ) { - return new AFPData( self::DFLOAT, $a->toFloat() - $b->toFloat() ); + $res = $a->toNumber() - $b->toNumber(); + if ( $res === (int)$res ) { + return new AFPData( self::DINT, $res ); + } else { + return new AFPData( self::DFLOAT, $res ); + } } /** Convert shorteners */ @@ -411,8 +435,8 @@ class AFPData { return $this->toFloat(); case self::DINT: return $this->toInt(); - case self::DLIST: - $input = $this->toList(); + case self::DARRAY: + $input = $this->toArray(); $output = []; foreach ( $input as $item ) { $output[] = $item->toNative(); @@ -454,7 +478,17 @@ class AFPData { return self::castTypes( $this, self::DINT )->data; } - public function toList() { - return self::castTypes( $this, self::DLIST )->data; + /** + * @return int|float + */ + public function toNumber() { + return $this->type == self::DINT ? $this->toInt() : $this->toFloat(); + } + + /** + * @return array + */ + public function toArray() { + return self::castTypes( $this, self::DARRAY )->data; } } diff --git a/AbuseFilter/includes/parser/AFPParserState.php b/AbuseFilter/includes/parser/AFPParserState.php index 7a4f5a73..453948d1 100644 --- a/AbuseFilter/includes/parser/AFPParserState.php +++ b/AbuseFilter/includes/parser/AFPParserState.php @@ -3,6 +3,10 @@ class AFPParserState { public $pos, $token; + /** + * @param AFPToken $token + * @param int $pos + */ public function __construct( $token, $pos ) { $this->token = $token; $this->pos = $pos; diff --git a/AbuseFilter/includes/parser/AFPToken.php b/AbuseFilter/includes/parser/AFPToken.php index 2f7d9c99..897f5ded 100644 --- a/AbuseFilter/includes/parser/AFPToken.php +++ b/AbuseFilter/includes/parser/AFPToken.php @@ -21,7 +21,7 @@ * * Entry - catches unexpected characters * * Semicolon - ; * * Set - := - * * Conditionls (IF) - if-then-else-end, cond ? a :b + * * Conditionals (IF) - if-then-else-end, cond ? a :b * * BoolOps (BO) - &, |, ^ * * CompOps (CO) - ==, !=, ===, !==, >, <, >=, <= * * SumRel (SR) - +, - @@ -30,13 +30,12 @@ * * BoolNeg (BN) - ! operation * * SpecialOperators (SO) - in and like * * Unarys (U) - plus and minus in cases like -5 or -(2 * +2) - * * ListElement (LE) - list[number] + * * ArrayElement (AE) - array[number] * * Braces (B) - ( and ) * * Functions (F) * * Atom (A) - return value */ class AFPToken { - // Types of tken const TNONE = 'T_NONE'; const TID = 'T_ID'; const TKEYWORD = 'T_KEYWORD'; @@ -53,6 +52,11 @@ class AFPToken { public $value; public $pos; + /** + * @param string $type + * @param mixed|null $value + * @param int $pos + */ public function __construct( $type = self::TNONE, $value = null, $pos = 0 ) { $this->type = $type; $this->value = $value; diff --git a/AbuseFilter/includes/parser/AFPTreeNode.php b/AbuseFilter/includes/parser/AFPTreeNode.php index e185616c..a3c2a063 100644 --- a/AbuseFilter/includes/parser/AFPTreeNode.php +++ b/AbuseFilter/includes/parser/AFPTreeNode.php @@ -15,12 +15,12 @@ class AFPTreeNode { // ASSIGNMENT (formerly known as SET) is a node which is responsible for // assigning values to variables. ASSIGNMENT is a (variable name [string], // value [tree node]) tuple, INDEX_ASSIGNMENT (which is used to assign - // values at list offsets) is a (variable name [string], index [tree node], - // value [tree node]) tuple, and LIST_APPEND has the form of (variable name + // values at array offsets) is a (variable name [string], index [tree node], + // value [tree node]) tuple, and ARRAY_APPEND has the form of (variable name // [string], value [tree node]). const ASSIGNMENT = 'ASSIGNMENT'; const INDEX_ASSIGNMENT = 'INDEX_ASSIGNMENT'; - const LIST_APPEND = 'LIST_APPEND'; + const ARRAY_APPEND = 'ARRAY_APPEND'; // CONDITIONAL represents both a ternary operator and an if-then-else-end // construct. The format is (condition, evaluated-if-true, @@ -53,13 +53,12 @@ class AFPTreeNode { // filter language. The format is (keyword, left operand, right operand). const KEYWORD_OPERATOR = 'KEYWORD_OPERATOR'; - // UNARY is either unary minus or unary plus. The format is (operator, - // operand). + // UNARY is either unary minus or unary plus. The format is (operator, operand). const UNARY = 'UNARY'; - // LIST_INDEX is an operation of accessing a list by an offset. The format - // is (list, offset). - const LIST_INDEX = 'LIST_INDEX'; + // ARRAY_INDEX is an operation of accessing an array by an offset. The format + // is (array, offset). + const ARRAY_INDEX = 'ARRAY_INDEX'; // Since parenthesis only manipulate precedence of the operators, they are // not explicitly represented in the tree. @@ -69,9 +68,9 @@ class AFPTreeNode { // elements are the arguments. const FUNCTION_CALL = 'FUNCTION_CALL'; - // LIST_DEFINITION is a list literal. The $children field contains tree - // nodes for the values of each of the list element used. - const LIST_DEFINITION = 'LIST_DEFINITION'; + // ARRAY_DEFINITION is an array literal. The $children field contains tree + // nodes for the values of each of the array element used. + const ARRAY_DEFINITION = 'ARRAY_DEFINITION'; // ATOM is a node representing a literal. The only element of $children is a // token corresponding to the literal. @@ -90,12 +89,20 @@ class AFPTreeNode { // Position used for error reporting. public $position; + /** + * @param string $type + * @param AFPTreeNode[]|string[]|AFPToken $children + * @param int $position + */ public function __construct( $type, $children, $position ) { $this->type = $type; $this->children = $children; $this->position = $position; } + /** + * @return string + */ public function toDebugString() { return implode( "\n", $this->toDebugStringInner() ); } diff --git a/AbuseFilter/includes/parser/AFPTreeParser.php b/AbuseFilter/includes/parser/AFPTreeParser.php index 345adcb8..4fa35356 100644 --- a/AbuseFilter/includes/parser/AFPTreeParser.php +++ b/AbuseFilter/includes/parser/AFPTreeParser.php @@ -26,6 +26,9 @@ class AFPTreeParser { $this->resetState(); } + /** + * Resets the state + */ public function resetState() { $this->mTokens = []; $this->mPos = 0; @@ -174,7 +177,7 @@ class AFPTreeParser { $value = $this->doLevelSet(); if ( $index === 'append' ) { return new AFPTreeNode( - AFPTreeNode::LIST_APPEND, [ $varname, $value ], $position ); + AFPTreeNode::ARRAY_APPEND, [ $varname, $value ], $position ); } else { return new AFPTreeNode( AFPTreeNode::INDEX_ASSIGNMENT, @@ -412,7 +415,7 @@ class AFPTreeParser { $leftOperand = $this->doLevelUnarys(); $keyword = strtolower( $this->mCur->value ); if ( $this->mCur->type == AFPToken::TKEYWORD && - in_array( $keyword, array_keys( AbuseFilterParser::$mKeywords ) ) + isset( AbuseFilterParser::$mKeywords[$keyword] ) ) { $position = $this->mPos; $this->move(); @@ -438,24 +441,24 @@ class AFPTreeParser { if ( $this->mCur->type == AFPToken::TOP && ( $op == "+" || $op == "-" ) ) { $position = $this->mPos; $this->move(); - $argument = $this->doLevelListElements(); + $argument = $this->doLevelArrayElements(); return new AFPTreeNode( AFPTreeNode::UNARY, [ $op, $argument ], $position ); } - return $this->doLevelListElements(); + return $this->doLevelArrayElements(); } /** - * Handles accessing a list element by an offset. + * Handles accessing an array element by an offset. * * @return AFPTreeNode * @throws AFPUserVisibleException */ - protected function doLevelListElements() { - $list = $this->doLevelParenthesis(); + protected function doLevelArrayElements() { + $array = $this->doLevelParenthesis(); while ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) { $position = $this->mPos; $index = $this->doLevelSemicolon(); - $list = new AFPTreeNode( AFPTreeNode::LIST_INDEX, [ $list, $index ], $position ); + $array = new AFPTreeNode( AFPTreeNode::ARRAY_INDEX, [ $array, $index ], $position ); if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, @@ -464,7 +467,7 @@ class AFPTreeParser { $this->move(); } - return $list; + return $array; } /** @@ -568,14 +571,14 @@ class AFPTreeParser { /** @noinspection PhpMissingBreakStatementInspection */ case AFPToken::TSQUAREBRACKET: if ( $this->mCur->value == '[' ) { - $list = []; + $array = []; while ( true ) { $this->move(); if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { break; } - $list[] = $this->doLevelSet(); + $array[] = $this->doLevelSet(); if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { break; @@ -589,7 +592,7 @@ class AFPTreeParser { } } - $result = new AFPTreeNode( AFPTreeNode::LIST_DEFINITION, $list, $this->mPos ); + $result = new AFPTreeNode( AFPTreeNode::ARRAY_DEFINITION, $array, $this->mPos ); break; } diff --git a/AbuseFilter/includes/parser/AFPUserVisibleException.php b/AbuseFilter/includes/parser/AFPUserVisibleException.php index 45eea745..ab4b2264 100644 --- a/AbuseFilter/includes/parser/AFPUserVisibleException.php +++ b/AbuseFilter/includes/parser/AFPUserVisibleException.php @@ -3,8 +3,11 @@ // Exceptions that we might conceivably want to report to ordinary users // (i.e. exceptions that don't represent bugs in the extension itself) class AFPUserVisibleException extends AFPException { - public $mExceptionId; + /** @var string */ + public $mExceptionID; + /** @var int */ public $mPosition; + /** @var array */ public $mParams; /** @@ -12,7 +15,7 @@ class AFPUserVisibleException extends AFPException { * @param int $position * @param array $params */ - function __construct( $exception_id, $position, $params ) { + public function __construct( $exception_id, $position, $params ) { $this->mExceptionID = $exception_id; $this->mPosition = $position; $this->mParams = $params; @@ -22,6 +25,9 @@ class AFPUserVisibleException extends AFPException { parent::__construct( $msg ); } + /** + * @return Message + */ public function getMessageObj() { // Give grep a chance to find the usages: // abusefilter-exception-unexpectedatend, abusefilter-exception-expectednotfound @@ -31,10 +37,11 @@ class AFPUserVisibleException extends AFPException { // abusefilter-exception-dividebyzero, abusefilter-exception-unrecognisedvar // abusefilter-exception-notenoughargs, abusefilter-exception-regexfailure // abusefilter-exception-overridebuiltin, abusefilter-exception-outofbounds - // abusefilter-exception-notlist + // abusefilter-exception-notarray, abusefilter-exception-unclosedcomment + // abusefilter-exception-invalidiprange, abusefilter-exception-disabledvar return wfMessage( 'abusefilter-exception-' . $this->mExceptionID, - array_merge( [ $this->mPosition ], $this->mParams ) + $this->mPosition, ...$this->mParams ); } } diff --git a/AbuseFilter/includes/parser/AbuseFilterCachingParser.php b/AbuseFilter/includes/parser/AbuseFilterCachingParser.php index 0c5ba69e..ef634fd4 100644 --- a/AbuseFilter/includes/parser/AbuseFilterCachingParser.php +++ b/AbuseFilter/includes/parser/AbuseFilterCachingParser.php @@ -12,6 +12,7 @@ class AbuseFilterCachingParser extends AbuseFilterParser { * Return the generated version of the parser for cache invalidation * purposes. Automatically tracks list of all functions and invalidates the * cache if it is changed. + * @return string */ public static function getCacheVersion() { static $version = null; @@ -30,11 +31,18 @@ class AbuseFilterCachingParser extends AbuseFilterParser { return $version; } + /** + * Resets the state of the parser + */ public function resetState() { $this->mVars = new AbuseFilterVariableHolder; $this->mCur = new AFPToken(); } + /** + * @param string $code + * @return AFPData + */ public function intEval( $code ) { static $cache = null; if ( !$cache ) { @@ -63,7 +71,7 @@ class AbuseFilterCachingParser extends AbuseFilterParser { * Evaluate the value of the specified AST node. * * @param AFPTreeNode $node The node to evaluate. - * @return AFPData + * @return AFPData|AFPTreeNode|string * @throws AFPException * @throws AFPUserVisibleException * @throws MWException @@ -71,8 +79,7 @@ class AbuseFilterCachingParser extends AbuseFilterParser { public function evalNode( AFPTreeNode $node ) { // A lot of AbuseFilterParser features rely on $this->mCur->pos or // $this->mPos for error reporting. - // FIXME: this is a hack which needs to be removed when the parsers are - // merged. + // FIXME: this is a hack which needs to be removed when the parsers are merged. $this->mPos = $node->position; $this->mCur->pos = $node->position; @@ -102,9 +109,9 @@ class AbuseFilterCachingParser extends AbuseFilterParser { default: throw new AFPException( "Unknown token provided in the ATOM node" ); } - case AFPTreeNode::LIST_DEFINITION: + case AFPTreeNode::ARRAY_DEFINITION: $items = array_map( [ $this, 'evalNode' ], $node->children ); - return new AFPData( AFPData::DLIST, $items ); + return new AFPData( AFPData::DARRAY, $items ); case AFPTreeNode::FUNCTION_CALL: $functionName = $node->children[0]; @@ -131,23 +138,23 @@ class AbuseFilterCachingParser extends AbuseFilterParser { return $result; - case AFPTreeNode::LIST_INDEX: - list( $list, $offset ) = $node->children; + case AFPTreeNode::ARRAY_INDEX: + list( $array, $offset ) = $node->children; - $list = $this->evalNode( $list ); - if ( $list->type != AFPData::DLIST ) { - throw new AFPUserVisibleException( 'notlist', $node->position, [] ); + $array = $this->evalNode( $array ); + if ( $array->type != AFPData::DARRAY ) { + throw new AFPUserVisibleException( 'notarray', $node->position, [] ); } $offset = $this->evalNode( $offset )->toInt(); - $list = $list->toList(); - if ( count( $list ) <= $offset ) { + $array = $array->toArray(); + if ( count( $array ) <= $offset ) { throw new AFPUserVisibleException( 'outofbounds', $node->position, - [ $offset, count( $list ) ] ); + [ $offset, count( $array ) ] ); } - return $list[$offset]; + return $array[$offset]; case AFPTreeNode::UNARY: list( $operation, $argument ) = $node->children; @@ -182,8 +189,8 @@ class AbuseFilterCachingParser extends AbuseFilterParser { list( $op, $leftOperand, $rightOperand ) = $node->children; $leftOperand = $this->evalNode( $leftOperand ); $rightOperand = $this->evalNode( $rightOperand ); - return AFPData::mulRel( $leftOperand, $rightOperand, $op, /* FIXME */ - 0 ); + // FIXME + return AFPData::mulRel( $leftOperand, $rightOperand, $op, 0 ); case AFPTreeNode::SUM_REL: list( $op, $leftOperand, $rightOperand ) = $node->children; @@ -234,34 +241,34 @@ class AbuseFilterCachingParser extends AbuseFilterParser { case AFPTreeNode::INDEX_ASSIGNMENT: list( $varName, $offset, $value ) = $node->children; - $list = $this->mVars->getVar( $varName ); - if ( $list->type != AFPData::DLIST ) { - throw new AFPUserVisibleException( 'notlist', $node->position, [] ); + $array = $this->mVars->getVar( $varName ); + if ( $array->type != AFPData::DARRAY ) { + throw new AFPUserVisibleException( 'notarray', $node->position, [] ); } $offset = $this->evalNode( $offset )->toInt(); - $list = $list->toList(); - if ( count( $list ) <= $offset ) { + $array = $array->toArray(); + if ( count( $array ) <= $offset ) { throw new AFPUserVisibleException( 'outofbounds', $node->position, - [ $offset, count( $list ) ] ); + [ $offset, count( $array ) ] ); } - $list[$offset] = $this->evalNode( $value ); - $this->setUserVariable( $varName, new AFPData( AFPData::DLIST, $list ) ); + $array[$offset] = $this->evalNode( $value ); + $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) ); return $value; - case AFPTreeNode::LIST_APPEND: + case AFPTreeNode::ARRAY_APPEND: list( $varName, $value ) = $node->children; - $list = $this->mVars->getVar( $varName ); - if ( $list->type != AFPData::DLIST ) { - throw new AFPUserVisibleException( 'notlist', $node->position, [] ); + $array = $this->mVars->getVar( $varName ); + if ( $array->type != AFPData::DARRAY ) { + throw new AFPUserVisibleException( 'notarray', $node->position, [] ); } - $list = $list->toList(); - $list[] = $this->evalNode( $value ); - $this->setUserVariable( $varName, new AFPData( AFPData::DLIST, $list ) ); + $array = $array->toArray(); + $array[] = $this->evalNode( $value ); + $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) ); return $value; case AFPTreeNode::SEMICOLON: diff --git a/AbuseFilter/includes/parser/AbuseFilterParser.php b/AbuseFilter/includes/parser/AbuseFilterParser.php index f9bd15b3..89ddea0f 100644 --- a/AbuseFilter/includes/parser/AbuseFilterParser.php +++ b/AbuseFilter/includes/parser/AbuseFilterParser.php @@ -1,14 +1,19 @@ <?php +use Wikimedia\Equivset\Equivset; +use MediaWiki\Logger\LoggerFactory; + class AbuseFilterParser { - public $mCode, $mTokens, $mPos, $mCur, $mShortCircuit, $mAllowShort, $mLen; + public $mCode, $mTokens, $mPos, $mShortCircuit, $mAllowShort, $mLen; + /** @var AFPToken The current token */ + public $mCur; /** * @var AbuseFilterVariableHolder */ public $mVars; - // length,lcase,ucase,ccnorm,rmdoubles,specialratio,rmspecials,norm,count + // length,lcase,ucase,ccnorm,rmdoubles,specialratio,rmspecials,norm,count,get_matches public static $mFunctions = [ 'lcase' => 'funcLc', 'ucase' => 'funcUc', @@ -19,14 +24,19 @@ class AbuseFilterParser { 'bool' => 'castBool', 'norm' => 'funcNorm', 'ccnorm' => 'funcCCNorm', + 'ccnorm_contains_any' => 'funcCCNormContainsAny', + 'ccnorm_contains_all' => 'funcCCNormContainsAll', 'specialratio' => 'funcSpecialRatio', 'rmspecials' => 'funcRMSpecials', 'rmdoubles' => 'funcRMDoubles', 'rmwhitespace' => 'funcRMWhitespace', 'count' => 'funcCount', 'rcount' => 'funcRCount', + 'get_matches' => 'funcGetMatches', 'ip_in_range' => 'funcIPInRange', 'contains_any' => 'funcContainsAny', + 'contains_all' => 'funcContainsAll', + 'equals_to_any' => 'funcEqualsToAny', 'substr' => 'funcSubstr', 'strlen' => 'funcLen', 'strpos' => 'funcStrPos', @@ -34,6 +44,7 @@ class AbuseFilterParser { 'rescape' => 'funcStrRegexEscape', 'set' => 'funcSetVar', 'set_var' => 'funcSetVar', + 'sanitize' => 'funcSanitize', ]; // Functions that affect parser state, and shouldn't be cached. @@ -48,15 +59,20 @@ class AbuseFilterParser { 'contains' => 'keywordContains', 'rlike' => 'keywordRegex', 'irlike' => 'keywordRegexInsensitive', - 'regex' => 'keywordRegex' + 'regex' => 'keywordRegex', ]; public static $funcCache = []; /** + * @var Equivset + */ + protected static $equivset; + + /** * Create a new instance * - * @param $vars AbuseFilterVariableHolder + * @param AbuseFilterVariableHolder|null $vars */ public function __construct( $vars = null ) { $this->resetState(); @@ -65,6 +81,9 @@ class AbuseFilterParser { } } + /** + * Resets the state of the parser. + */ public function resetState() { $this->mCode = ''; $this->mTokens = []; @@ -75,12 +94,13 @@ class AbuseFilterParser { } /** - * @param $filter - * @return array|bool + * @param string $filter + * @return true|array True when successful, otherwise a two-element array with exception message + * and character position of the syntax error */ public function checkSyntax( $filter ) { + $origAS = $this->mAllowShort; try { - $origAS = $this->mAllowShort; $this->mAllowShort = false; $this->parse( $filter ); } catch ( AFPUserVisibleException $excep ) { @@ -94,28 +114,7 @@ class AbuseFilterParser { } /** - * @param $name - * @param $value - */ - public function setVar( $name, $value ) { - $this->mVars->setVar( $name, $value ); - } - - /** - * @param $vars - */ - public function setVars( $vars ) { - if ( is_array( $vars ) ) { - foreach ( $vars as $name => $var ) { - $this->setVar( $name, $var ); - } - } elseif ( $vars instanceof AbuseFilterVariableHolder ) { - $this->mVars->addHolders( $vars ); - } - } - - /** - * @return AFPToken + * Move to the next token */ protected function move() { list( $this->mCur, $this->mPos ) = $this->mTokens[$this->mPos]; @@ -139,16 +138,9 @@ class AbuseFilterParser { } /** - * @return mixed * @throws AFPUserVisibleException */ protected function skipOverBraces() { - if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == '(' ) || - !$this->mShortCircuit - ) { - return; - } - $braces = 1; while ( $this->mCur->type != AFPToken::TNONE && $braces > 0 ) { $this->move(); @@ -166,7 +158,7 @@ class AbuseFilterParser { } /** - * @param $code + * @param string $code * @return bool */ public function parse( $code ) { @@ -174,7 +166,7 @@ class AbuseFilterParser { } /** - * @param $filter + * @param string $filter * @return string */ public function evaluateExpression( $filter ) { @@ -182,11 +174,11 @@ class AbuseFilterParser { } /** - * @param $code + * @param string $code * @return AFPData */ - function intEval( $code ) { - // Setup, resetting + public function intEval( $code ) { + // Reset all class members to their default value $this->mCode = $code; $this->mTokens = AbuseFilterTokenizer::tokenize( $code ); $this->mPos = 0; @@ -199,25 +191,12 @@ class AbuseFilterParser { return $result; } - /** - * @param $a - * @param $b - * @return int - */ - static function lengthCompare( $a, $b ) { - if ( strlen( $a ) == strlen( $b ) ) { - return 0; - } - - return ( strlen( $a ) < strlen( $b ) ) ? -1 : 1; - } - /* Levels */ /** * Handles unexpected characters after the expression * - * @param $result AFPData + * @param AFPData &$result * @throws AFPUserVisibleException */ protected function doLevelEntry( &$result ) { @@ -232,8 +211,9 @@ class AbuseFilterParser { } /** - * Handles multiple expressions - * @param $result AFPData + * Handles multiple expressions delimited by a semicolon + * + * @param AFPData &$result */ protected function doLevelSemicolon( &$result ) { do { @@ -245,9 +225,9 @@ class AbuseFilterParser { } /** - * Handles multiple expressions + * Handles assignments (:=) * - * @param $result AFPData + * @param AFPData &$result * @throws AFPUserVisibleException */ protected function doLevelSet( &$result ) { @@ -269,11 +249,11 @@ class AbuseFilterParser { [ $varname ] ); } - $list = $this->mVars->getVar( $varname ); - if ( $list->type != AFPData::DLIST ) { - throw new AFPUserVisibleException( 'notlist', $this->mCur->pos, [] ); + $array = $this->mVars->getVar( $varname ); + if ( $array->type != AFPData::DARRAY ) { + throw new AFPUserVisibleException( 'notarray', $this->mCur->pos, [] ); } - $list = $list->toList(); + $array = $array->toArray(); $this->move(); if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { $idx = 'new'; @@ -287,7 +267,7 @@ class AbuseFilterParser { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ ']', $this->mCur->type, $this->mCur->value ] ); } - if ( count( $list ) <= $idx ) { + if ( count( $array ) <= $idx ) { throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos, [ $idx, count( $result->data ) ] ); } @@ -297,11 +277,11 @@ class AbuseFilterParser { $this->move(); $this->doLevelSet( $result ); if ( $idx === 'new' ) { - $list[] = $result; + $array[] = $result; } else { - $list[$idx] = $result; + $array[$idx] = $result; } - $this->setUserVariable( $varname, new AFPData( AFPData::DLIST, $list ) ); + $this->setUserVariable( $varname, new AFPData( AFPData::DARRAY, $array ) ); return; } else { @@ -315,7 +295,9 @@ class AbuseFilterParser { } /** - * @param $result AFPData + * Handles conditionals: if-then-else and ternary operator + * + * @param AFPData &$result * @throws AFPUserVisibleException */ protected function doLevelConditions( &$result ) { @@ -436,7 +418,9 @@ class AbuseFilterParser { } /** - * @param $result AFPData + * Handles boolean operators (&, |, ^) + * + * @param AFPData &$result */ protected function doLevelBoolOps( &$result ) { $this->doLevelCompares( $result ); @@ -446,7 +430,7 @@ class AbuseFilterParser { $this->move(); $r2 = new AFPData(); - // We can go on quickly as either one statement with | is true or on with & is false + // We can go on quickly as either one statement with | is true or one with & is false if ( ( $op == '&' && !$result->toBool() ) || ( $op == '|' && $result->toBool() ) ) { $orig = $this->mShortCircuit; $this->mShortCircuit = $this->mAllowShort; @@ -463,7 +447,9 @@ class AbuseFilterParser { } /** - * @param $result + * Handles comparison operators + * + * @param AFPData &$result */ protected function doLevelCompares( &$result ) { $this->doLevelSumRels( $result ); @@ -474,7 +460,8 @@ class AbuseFilterParser { $r2 = new AFPData(); $this->doLevelSumRels( $r2 ); if ( $this->mShortCircuit ) { - break; // The result doesn't matter. + // The result doesn't matter. + break; } AbuseFilter::triggerLimiter(); $result = AFPData::compareOp( $result, $r2, $op ); @@ -482,7 +469,9 @@ class AbuseFilterParser { } /** - * @param $result + * Handles sum-related operations (+ and -) + * + * @param AFPData &$result */ protected function doLevelSumRels( &$result ) { $this->doLevelMulRels( $result ); @@ -493,7 +482,8 @@ class AbuseFilterParser { $r2 = new AFPData(); $this->doLevelMulRels( $r2 ); if ( $this->mShortCircuit ) { - break; // The result doesn't matter. + // The result doesn't matter. + break; } if ( $op == '+' ) { $result = AFPData::sum( $result, $r2 ); @@ -505,7 +495,9 @@ class AbuseFilterParser { } /** - * @param $result + * Handles multiplication-related operations (*, / and %) + * + * @param AFPData &$result */ protected function doLevelMulRels( &$result ) { $this->doLevelPow( $result ); @@ -516,14 +508,17 @@ class AbuseFilterParser { $r2 = new AFPData(); $this->doLevelPow( $r2 ); if ( $this->mShortCircuit ) { - break; // The result doesn't matter. + // The result doesn't matter. + break; } $result = AFPData::mulRel( $result, $r2, $op, $this->mCur->pos ); } } /** - * @param $result + * Handles powers (**) + * + * @param AFPData &$result */ protected function doLevelPow( &$result ) { $this->doLevelBoolInvert( $result ); @@ -532,21 +527,25 @@ class AbuseFilterParser { $expanent = new AFPData(); $this->doLevelBoolInvert( $expanent ); if ( $this->mShortCircuit ) { - break; // The result doesn't matter. + // The result doesn't matter. + break; } $result = AFPData::pow( $result, $expanent ); } } /** - * @param $result + * Handles boolean inversion (!) + * + * @param AFPData &$result */ protected function doLevelBoolInvert( &$result ) { if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '!' ) { $this->move(); $this->doLevelSpecialWords( $result ); if ( $this->mShortCircuit ) { - return; // The result doesn't matter. + // The result doesn't matter. + return; } $result = AFPData::boolInvert( $result ); } else { @@ -555,13 +554,15 @@ class AbuseFilterParser { } /** - * @param $result + * Handles keywords (in, like, rlike, contains, ...) + * + * @param AFPData &$result */ protected function doLevelSpecialWords( &$result ) { $this->doLevelUnarys( $result ); $keyword = strtolower( $this->mCur->value ); if ( $this->mCur->type == AFPToken::TKEYWORD - && in_array( $keyword, array_keys( self::$mKeywords ) ) + && isset( self::$mKeywords[$keyword] ) ) { $func = self::$mKeywords[$keyword]; $this->move(); @@ -569,7 +570,8 @@ class AbuseFilterParser { $this->doLevelUnarys( $r2 ); if ( $this->mShortCircuit ) { - return; // The result doesn't matter. + // The result doesn't matter. + return; } AbuseFilter::triggerLimiter(); @@ -579,29 +581,34 @@ class AbuseFilterParser { } /** - * @param $result + * Handles unary plus and minus, like in -5 or -(2 * +2) + * + * @param AFPData &$result */ protected function doLevelUnarys( &$result ) { $op = $this->mCur->value; if ( $this->mCur->type == AFPToken::TOP && ( $op == "+" || $op == "-" ) ) { $this->move(); - $this->doLevelListElements( $result ); + $this->doLevelArrayElements( $result ); if ( $this->mShortCircuit ) { - return; // The result doesn't matter. + // The result doesn't matter. + return; } if ( $op == '-' ) { $result = AFPData::unaryMinus( $result ); } } else { - $this->doLevelListElements( $result ); + $this->doLevelArrayElements( $result ); } } /** - * @param $result + * Handles array elements, parsing expressions like array[number] + * + * @param AFPData &$result * @throws AFPUserVisibleException */ - protected function doLevelListElements( &$result ) { + protected function doLevelArrayElements( &$result ) { $this->doLevelBraces( $result ); while ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) { $idx = new AFPData(); @@ -611,21 +618,23 @@ class AbuseFilterParser { [ ']', $this->mCur->type, $this->mCur->value ] ); } $idx = $idx->toInt(); - if ( $result->type == AFPData::DLIST ) { + if ( $result->type == AFPData::DARRAY ) { if ( count( $result->data ) <= $idx ) { throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos, [ $idx, count( $result->data ) ] ); } $result = $result->data[$idx]; } else { - throw new AFPUserVisibleException( 'notlist', $this->mCur->pos, [] ); + throw new AFPUserVisibleException( 'notarray', $this->mCur->pos, [] ); } $this->move(); } } /** - * @param $result + * Handles brackets, only ( and ) + * + * @param AFPData &$result * @throws AFPUserVisibleException */ protected function doLevelBraces( &$result ) { @@ -649,7 +658,9 @@ class AbuseFilterParser { } /** - * @param $result + * Handles functions + * + * @param AFPData &$result * @throws AFPUserVisibleException */ protected function doLevelFunction( &$result ) { @@ -671,15 +682,21 @@ class AbuseFilterParser { $this->skipOverBraces(); $this->move(); - return; // The result doesn't matter. + // The result doesn't matter. + return; } $args = []; - do { - $r = new AFPData(); - $this->doLevelSemicolon( $r ); - $args[] = $r; - } while ( $this->mCur->type == AFPToken::TCOMMA ); + $state = $this->getState(); + $this->move(); + if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) { + $this->setState( $state ); + do { + $r = new AFPData(); + $this->doLevelSemicolon( $r ); + $args[] = $r; + } while ( $this->mCur->type == AFPToken::TCOMMA ); + } if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) { throw new AFPUserVisibleException( 'expectednotfound', @@ -713,9 +730,10 @@ class AbuseFilterParser { } /** - * @param $result + * Handles the return value + * + * @param AFPData &$result * @throws AFPUserVisibleException - * @return AFPData */ protected function doLevelAtom( &$result ) { $tok = $this->mCur->value; @@ -752,14 +770,16 @@ class AbuseFilterParser { } break; case AFPToken::TNONE: - return; // Handled at entry level + // Handled at entry level + return; case AFPToken::TBRACE: if ( $this->mCur->value == ')' ) { - return; // Handled at the entry level + // Handled at the entry level + return; } case AFPToken::TSQUAREBRACKET: if ( $this->mCur->value == '[' ) { - $list = []; + $array = []; while ( true ) { $this->move(); if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { @@ -767,7 +787,7 @@ class AbuseFilterParser { } $item = new AFPData(); $this->doLevelSet( $item ); - $list[] = $item; + $array[] = $item; if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { break; } @@ -779,7 +799,7 @@ class AbuseFilterParser { ); } } - $result = new AFPData( AFPData::DLIST, $list ); + $result = new AFPData( AFPData::DARRAY, $array ); break; } default: @@ -798,19 +818,28 @@ class AbuseFilterParser { /* End of levels */ /** - * @param $var + * @param string $var * @return AFPData * @throws AFPUserVisibleException */ protected function getVarValue( $var ) { $var = strtolower( $var ); $builderValues = AbuseFilter::getBuilderValues(); + $deprecatedVars = AbuseFilter::getDeprecatedVariables(); + if ( array_key_exists( $var, $deprecatedVars ) ) { + $logger = LoggerFactory::getInstance( 'AbuseFilterDeprecatedVars' ); + $logger->debug( "AbuseFilter: deprecated variable $var used." ); + $var = $deprecatedVars[$var]; + } if ( !( array_key_exists( $var, $builderValues['vars'] ) || $this->mVars->varIsSet( $var ) ) ) { + $msg = array_key_exists( $var, AbuseFilter::$disabledVars ) ? + 'disabledvar' : + 'unrecognisedvar'; // If the variable is invalid, throw an exception throw new AFPUserVisibleException( - 'unrecognisedvar', + $msg, $this->mCur->pos, [ $var ] ); @@ -820,13 +849,18 @@ class AbuseFilterParser { } /** - * @param $name - * @param $value + * @param string $name + * @param mixed $value * @throws AFPUserVisibleException */ protected function setUserVariable( $name, $value ) { $builderValues = AbuseFilter::getBuilderValues(); - if ( array_key_exists( $name, $builderValues['vars'] ) ) { + $deprecatedVars = AbuseFilter::getDeprecatedVariables(); + $blacklistedValues = AbuseFilterVariableHolder::$varBlacklist; + if ( array_key_exists( $name, $builderValues['vars'] ) || + array_key_exists( $name, AbuseFilter::$disabledVars ) || + array_key_exists( $name, $deprecatedVars ) || + in_array( $name, $blacklistedValues ) ) { throw new AFPUserVisibleException( 'overridebuiltin', $this->mCur->pos, [ $name ] ); } $this->mVars->setVar( $name, $value ); @@ -835,17 +869,17 @@ class AbuseFilterParser { // Built-in functions /** - * @param $args + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcLc( $args ) { global $wgContLang; - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'lc', 2, count( $args ) ] + [ 'lc', 1 ] ); } $s = $args[0]->toString(); @@ -854,17 +888,17 @@ class AbuseFilterParser { } /** - * @param $args + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcUc( $args ) { global $wgContLang; - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'uc', 2, count( $args ) ] + [ 'uc', 1 ] ); } $s = $args[0]->toString(); @@ -873,20 +907,20 @@ class AbuseFilterParser { } /** - * @param $args + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcLen( $args ) { - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'len', 2, count( $args ) ] + [ 'len', 1 ] ); } - if ( $args[0]->type == AFPData::DLIST ) { - // Don't use toString on lists, but count + if ( $args[0]->type == AFPData::DARRAY ) { + // Don't use toString on arrays, but count return new AFPData( AFPData::DINT, count( $args[0]->data ) ); } $s = $args[0]->toString(); @@ -895,37 +929,16 @@ class AbuseFilterParser { } /** - * @param $args - * @return AFPData - * @throws AFPUserVisibleException - */ - protected function funcSimpleNorm( $args ) { - if ( count( $args ) < 1 ) { - throw new AFPUserVisibleException( - 'notenoughargs', - $this->mCur->pos, - [ 'simplenorm', 2, count( $args ) ] - ); - } - $s = $args[0]->toString(); - - $s = preg_replace( '/[\d\W]+/', '', $s ); - $s = strtolower( $s ); - - return new AFPData( AFPData::DSTRING, $s ); - } - - /** - * @param $args + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcSpecialRatio( $args ) { - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'specialratio', 1, count( $args ) ] + [ 'specialratio', 1 ] ); } $s = $args[0]->toString(); @@ -942,20 +955,20 @@ class AbuseFilterParser { } /** - * @param $args + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcCount( $args ) { - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'count', 1, count( $args ) ] + [ 'count', 1 ] ); } - if ( $args[0]->type == AFPData::DLIST && count( $args ) == 1 ) { + if ( $args[0]->type == AFPData::DARRAY && count( $args ) == 1 ) { return new AFPData( AFPData::DINT, count( $args[0]->data ) ); } @@ -965,7 +978,7 @@ class AbuseFilterParser { $needle = $args[0]->toString(); $haystack = $args[1]->toString(); - // Bug #60203: Keep empty parameters from causing PHP warnings + // T62203: Keep empty parameters from causing PHP warnings if ( $needle === '' ) { $count = 0; } else { @@ -977,17 +990,17 @@ class AbuseFilterParser { } /** - * @param $args + * @param array $args * @return AFPData * @throws AFPUserVisibleException * @throws Exception */ protected function funcRCount( $args ) { - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'rcount', 1, count( $args ) ] + [ 'rcount', 1 ] ); } @@ -997,18 +1010,20 @@ class AbuseFilterParser { $needle = $args[0]->toString(); $haystack = $args[1]->toString(); - # Munge the regex + // Munge the regex $needle = preg_replace( '!(\\\\\\\\)*(\\\\)?/!', '$1\/', $needle ); $needle = "/$needle/u"; - // Omit the '$matches' argument to avoid computing them, just count. + // Suppress and restore needed per T177744 + Wikimedia\suppressWarnings(); $count = preg_match_all( $needle, $haystack ); + Wikimedia\restoreWarnings(); if ( $count === false ) { throw new AFPUserVisibleException( 'regexfailure', $this->mCur->pos, - [ 'unspecified error in preg_match_all()', $needle ] + [ $needle ] ); } } @@ -1017,7 +1032,60 @@ class AbuseFilterParser { } /** - * @param $args + * Returns an array of matches of needle in the haystack, the first one for the whole regex, + * the other ones for every capturing group. + * + * @param array $args + * @return AFPData An array of matches. + * @throws AFPUserVisibleException + */ + protected function funcGetMatches( $args ) { + if ( count( $args ) < 2 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'get_matches', 2, count( $args ) ] + ); + } + $needle = $args[0]->toString(); + $haystack = $args[1]->toString(); + + // Count the amount of capturing groups in the submitted pattern. + // This way we can return a fixed-dimension array, much easier to manage. + // First, strip away escaped parentheses + $sanitized = preg_replace( '/(\\\\\\\\)*\\\\\(/', '', $needle ); + // Then strip starting parentheses of non-capturing groups + // (also atomics, lookahead and so on, even if not every of them is supported) + $sanitized = preg_replace( '/\(\?/', '', $sanitized ); + // Finally create an array of falses with dimension = # of capturing groups + $groupscount = substr_count( $sanitized, '(' ) + 1; + $falsy = array_fill( 0, $groupscount, false ); + + // Munge the regex by escaping slashes + $needle = preg_replace( '!(\\\\\\\\)*(\\\\)?/!', '$1\/', $needle ); + $needle = "/$needle/u"; + + // Suppress and restore are here for the same reason as T177744 + Wikimedia\suppressWarnings(); + $check = preg_match( $needle, $haystack, $matches ); + Wikimedia\restoreWarnings(); + + if ( $check === false ) { + throw new AFPUserVisibleException( + 'regexfailure', + $this->mCur->pos, + [ $needle ] + ); + } + + // Returned array has non-empty positions identical to the ones returned + // by the third parameter of a standard preg_match call ($matches in this case). + // We want an union with falsy to return a fixed-dimension array. + return AFPData::newFromPHPVar( $matches + $falsy ); + } + + /** + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ @@ -1033,22 +1101,30 @@ class AbuseFilterParser { $ip = $args[0]->toString(); $range = $args[1]->toString(); + if ( !IP::isValidRange( $range ) ) { + throw new AFPUserVisibleException( + 'invalidiprange', + $this->mCur->pos, + [ $range ] + ); + } + $result = IP::isInRange( $ip, $range ); return new AFPData( AFPData::DBOOL, $result ); } /** - * @param $args + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcCCNorm( $args ) { - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'ccnorm', 1, count( $args ) ] + [ 'ccnorm', 1 ] ); } $s = $args[0]->toString(); @@ -1060,7 +1136,28 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcSanitize( $args ) { + if ( count( $args ) === 0 ) { + throw new AFPUserVisibleException( + 'noparams', + $this->mCur->pos, + [ 'sanitize', 1 ] + ); + } + $s = $args[0]->toString(); + + $s = html_entity_decode( $s, ENT_QUOTES, 'UTF-8' ); + $s = Sanitizer::decodeCharReferences( $s ); + + return new AFPData( AFPData::DSTRING, $s ); + } + + /** + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ @@ -1074,53 +1171,177 @@ class AbuseFilterParser { } $s = array_shift( $args ); - $s = $s->toString(); - $searchStrings = []; + return new AFPData( AFPData::DBOOL, self::contains( $s, $args, true ) ); + } - foreach ( $args as $arg ) { - $searchStrings[] = $arg->toString(); + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcContainsAll( $args ) { + if ( count( $args ) < 2 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'contains_all', 2, count( $args ) ] + ); } - if ( function_exists( 'fss_prep_search' ) ) { - $fss = fss_prep_search( $searchStrings ); - $result = fss_exec_search( $fss, $s ); + $s = array_shift( $args ); - $ok = is_array( $result ); - } else { - $ok = false; - foreach ( $searchStrings as $needle ) { - // Bug #60203: Keep empty parameters from causing PHP warnings - if ( $needle !== '' && strpos( $s, $needle ) !== false ) { - $ok = true; - break; - } + return new AFPData( AFPData::DBOOL, self::contains( $s, $args, false, false ) ); + } + + /** + * Normalize and search a string for multiple substrings in OR mode + * + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcCCNormContainsAny( $args ) { + if ( count( $args ) < 2 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'ccnorm_contains_any', 2, count( $args ) ] + ); + } + + $s = array_shift( $args ); + + return new AFPData( AFPData::DBOOL, self::contains( $s, $args, true, true ) ); + } + + /** + * Normalize and search a string for multiple substrings in AND mode + * + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcCCNormContainsAll( $args ) { + if ( count( $args ) < 2 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'ccnorm_contains_all', 2, count( $args ) ] + ); + } + + $s = array_shift( $args ); + + return new AFPData( AFPData::DBOOL, self::contains( $s, $args, false, true ) ); + } + + /** + * Search for substrings in a string + * + * Use is_any to determine wether to use logic OR (true) or AND (false). + * + * Use normalize = true to make use of ccnorm and + * normalize both sides of the search. + * + * @param AFPData $string + * @param AFPData[] $values + * @param bool $is_any + * @param bool $normalize + * + * @return bool + */ + protected static function contains( $string, $values, $is_any = true, $normalize = false ) { + $string = $string->toString(); + + if ( $string === '' ) { + return false; + } + + if ( $normalize ) { + $string = self::ccnorm( $string ); + } + + foreach ( $values as $needle ) { + $needle = $needle->toString(); + if ( $normalize ) { + $needle = self::ccnorm( $needle ); + } + if ( $needle === '' ) { + // T62203: Keep empty parameters from causing PHP warnings + continue; + } + + $is_found = strpos( $string, $needle ) !== false; + if ( $is_found === $is_any ) { + // If I'm here and it's ANY (OR) => something is found. + // If I'm here and it's ALL (AND) => nothing is found. + // In both cases, we've had enough. + return $is_found; } } - return new AFPData( AFPData::DBOOL, $ok ); + // If I'm here and it's ANY (OR) => nothing was found: return false ($is_any is true) + // If I'm here and it's ALL (AND) => everything was found: return true ($is_any is false) + return ! $is_any; } /** - * @param $s - * @return mixed + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException */ - protected function ccnorm( $s ) { - if ( is_callable( 'AntiSpoof::normalizeString' ) ) { - $s = AntiSpoof::normalizeString( $s ); - } else { - // AntiSpoof isn't available, so ignore and return same string - wfDebugLog( - 'AbuseFilter', - "Can't compute normalized string (ccnorm) as the AntiSpoof Extension isn't installed." + protected function funcEqualsToAny( $args ) { + if ( count( $args ) < 2 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'equals_to_any', 2, count( $args ) ] ); } - return $s; + $s = array_shift( $args ); + + return new AFPData( AFPData::DBOOL, self::equalsToAny( $s, $args ) ); } /** - * @param $s string + * Check if the given string is equals to any of the following strings + * + * @param AFPData $string + * @param AFPData[] $values + * + * @return bool + */ + protected static function equalsToAny( $string, $values ) { + $string = $string->toString(); + + foreach ( $values as $needle ) { + $needle = $needle->toString(); + + if ( $string === $needle ) { + return true; + } + } + + return false; + } + + /** + * @param string $s + * @return mixed + */ + protected static function ccnorm( $s ) { + // Instantiate a single version of the equivset so the data is only loaded once. + if ( !self::$equivset ) { + self::$equivset = new Equivset(); + } + + return self::$equivset->normalize( $s ); + } + + /** + * @param string $s * @return array|string */ protected function rmspecials( $s ) { @@ -1128,7 +1349,7 @@ class AbuseFilterParser { } /** - * @param $s string + * @param string $s * @return array|string */ protected function rmdoubles( $s ) { @@ -1136,7 +1357,7 @@ class AbuseFilterParser { } /** - * @param $s string + * @param string $s * @return array|string */ protected function rmwhitespace( $s ) { @@ -1144,16 +1365,16 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcRMSpecials( $args ) { - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'rmspecials', 1, count( $args ) ] + [ 'rmspecials', 1 ] ); } $s = $args[0]->toString(); @@ -1164,16 +1385,16 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcRMWhitespace( $args ) { - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'rmwhitespace', 1, count( $args ) ] + [ 'rmwhitespace', 1 ] ); } $s = $args[0]->toString(); @@ -1184,16 +1405,16 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcRMDoubles( $args ) { - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'rmdoubles', 1, count( $args ) ] + [ 'rmdoubles', 1 ] ); } $s = $args[0]->toString(); @@ -1204,16 +1425,16 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcNorm( $args ) { - if ( count( $args ) < 1 ) { + if ( count( $args ) === 0 ) { throw new AFPUserVisibleException( - 'notenoughargs', + 'noparams', $this->mCur->pos, - [ 'norm', 1, count( $args ) ] + [ 'norm', 1 ] ); } $s = $args[0]->toString(); @@ -1227,7 +1448,7 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ @@ -1255,7 +1476,7 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ @@ -1271,7 +1492,7 @@ class AbuseFilterParser { $haystack = $args[0]->toString(); $needle = $args[1]->toString(); - // Bug #60203: Keep empty parameters from causing PHP warnings + // T62203: Keep empty parameters from causing PHP warnings if ( $needle === '' ) { return new AFPData( AFPData::DINT, -1 ); } @@ -1292,7 +1513,7 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ @@ -1313,14 +1534,17 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function funcStrRegexEscape( $args ) { - if ( count( $args ) < 1 ) { - throw new AFPUserVisibleException( 'notenoughargs', $this->mCur->pos, - [ 'rescape', 1, count( $args ) ] ); + if ( count( $args ) === 0 ) { + throw new AFPUserVisibleException( + 'noparams', + $this->mCur->pos, + [ 'rescape', 1 ] + ); } $string = $args[0]->toString(); @@ -1331,7 +1555,7 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return mixed * @throws AFPUserVisibleException */ @@ -1353,13 +1577,17 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function castString( $args ) { - if ( count( $args ) < 1 ) { - throw new AFPUserVisibleException( 'noparams', $this->mCur->pos, [ __METHOD__ ] ); + if ( count( $args ) === 0 ) { + throw new AFPUserVisibleException( + 'noparams', + $this->mCur->pos, + [ 'string', 1 ] + ); } $val = $args[0]; @@ -1367,13 +1595,17 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function castInt( $args ) { - if ( count( $args ) < 1 ) { - throw new AFPUserVisibleException( 'noparams', $this->mCur->pos, [ __METHOD__ ] ); + if ( count( $args ) === 0 ) { + throw new AFPUserVisibleException( + 'noparams', + $this->mCur->pos, + [ 'int', 1 ] + ); } $val = $args[0]; @@ -1381,13 +1613,17 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function castFloat( $args ) { - if ( count( $args ) < 1 ) { - throw new AFPUserVisibleException( 'noparams', $this->mCur->pos, [ __METHOD__ ] ); + if ( count( $args ) === 0 ) { + throw new AFPUserVisibleException( + 'noparams', + $this->mCur->pos, + [ 'float', 1 ] + ); } $val = $args[0]; @@ -1395,13 +1631,17 @@ class AbuseFilterParser { } /** - * @param $args array + * @param array $args * @return AFPData * @throws AFPUserVisibleException */ protected function castBool( $args ) { - if ( count( $args ) < 1 ) { - throw new AFPUserVisibleException( 'noparams', $this->mCur->pos, [ __METHOD__ ] ); + if ( count( $args ) === 0 ) { + throw new AFPUserVisibleException( + 'noparams', + $this->mCur->pos, + [ 'bool', 1 ] + ); } $val = $args[0]; diff --git a/AbuseFilter/includes/parser/AbuseFilterTokenizer.php b/AbuseFilter/includes/parser/AbuseFilterTokenizer.php index 025314e8..d95a6864 100644 --- a/AbuseFilter/includes/parser/AbuseFilterTokenizer.php +++ b/AbuseFilter/includes/parser/AbuseFilterTokenizer.php @@ -19,15 +19,24 @@ class AbuseFilterTokenizer { // ** comes before *, etc. They are sorted to make it easy to spot // such errors. public static $operators = [ - '!==', '!=', '!', // Inequality - '**', '*', // Multiplication/exponentiation - '/', '+', '-', '%', // Other arithmetic - '&', '|', '^', // Logic - ':=', // Setting - '?', ':', // Ternery - '<=', '<', // Less than - '>=', '>', // Greater than - '===', '==', '=', // Equality + // Inequality + '!==', '!=', '!', + // Multiplication/exponentiation + '**', '*', + // Other arithmetic + '/', '+', '-', '%', + // Logic + '&', '|', '^', + // Setting + ':=', + // Ternary + '?', ':', + // Less than + '<=', '<', + // Greater than + '>=', '>', + // Equality + '===', '==', '=', ]; public static $punctuation = [ @@ -63,7 +72,7 @@ class AbuseFilterTokenizer { * @throws AFPException * @throws AFPUserVisibleException */ - static function tokenize( $code ) { + public static function tokenize( $code ) { static $tokenizerCache = null; if ( !$tokenizerCache ) { @@ -113,6 +122,10 @@ class AbuseFilterTokenizer { // Read past comments while ( preg_match( self::COMMENT_START_RE, $code, $matches, 0, $offset ) ) { + if ( strpos( $code, '*/', $offset ) === false ) { + throw new AFPUserVisibleException( + 'unclosedcomment', $offset, [] ); + } $offset = strpos( $code, '*/', $offset ) + 2; } @@ -148,7 +161,7 @@ class AbuseFilterTokenizer { if ( preg_match( self::RADIX_RE, $code, $matches, 0, $offset ) ) { $token = $matches[0]; $input = $matches[1]; - $baseChar = isset( $matches[2] ) ? $matches[2] : null; + $baseChar = $matches[2] ?? null; // Sometimes the base char gets mixed in with the rest of it because // the regex targets hex, too. // This mostly happens with binary @@ -233,7 +246,8 @@ class AbuseFilterTokenizer { if ( preg_match( '/^[0-9A-Fa-f]{2}$/', $chr ) ) { $chr = base_convert( $chr, 16, 10 ); $token .= chr( $chr ); - $offset += 2; # \xXX -- 2 done later + // \xXX -- 2 done later + $offset += 2; } else { $token .= 'x'; } @@ -245,6 +259,7 @@ class AbuseFilterTokenizer { $offset += 2; } else { + // Should never happen $token .= $code[$offset]; $offset++; } diff --git a/AbuseFilter/includes/special/SpecialAbuseFilter.php b/AbuseFilter/includes/special/SpecialAbuseFilter.php index 04d58191..ccd2f8ee 100644 --- a/AbuseFilter/includes/special/SpecialAbuseFilter.php +++ b/AbuseFilter/includes/special/SpecialAbuseFilter.php @@ -7,10 +7,16 @@ class SpecialAbuseFilter extends SpecialPage { parent::__construct( 'AbuseFilter', 'abusefilter-view' ); } + /** + * @return bool + */ public function doesWrites() { return true; } + /** + * @param string|null $subpage + */ public function execute( $subpage ) { $out = $this->getOutput(); $request = $this->getRequest(); @@ -23,7 +29,6 @@ class SpecialAbuseFilter extends SpecialPage { $this->loadParameters( $subpage ); $out->setPageTitle( $this->msg( 'abusefilter-management' ) ); - // Are we allowed? $this->checkPermissions(); if ( $request->getVal( 'result' ) == 'success' ) { @@ -83,8 +88,9 @@ class SpecialAbuseFilter extends SpecialPage { $view = 'AbuseFilterViewHistory'; $pageType = 'recentchanges'; } elseif ( count( $params ) == 2 ) { - # Second param is a filter ID + // Second param is a filter ID $view = 'AbuseFilterViewHistory'; + $pageType = 'recentchanges'; $this->mFilter = $params[1]; } elseif ( count( $params ) == 4 && $params[2] == 'item' ) { $this->mFilter = $params[1]; @@ -108,14 +114,18 @@ class SpecialAbuseFilter extends SpecialPage { } // Links at the top - AbuseFilter::addNavigationLinks( $this->getContext(), $pageType ); + AbuseFilter::addNavigationLinks( + $this->getContext(), $pageType, $this->getLinkRenderer() ); /** @var AbuseFilterView $v */ $v = new $view( $this, $params ); $v->show(); } - function loadParameters( $subpage ) { + /** + * @param string|null $subpage + */ + public function loadParameters( $subpage ) { $filter = $subpage; if ( !is_numeric( $filter ) && $filter != 'new' ) { @@ -124,6 +134,9 @@ class SpecialAbuseFilter extends SpecialPage { $this->mFilter = $filter; } + /** + * @return string + */ protected function getGroupName() { return 'wiki'; } diff --git a/AbuseFilter/includes/special/SpecialAbuseLog.php b/AbuseFilter/includes/special/SpecialAbuseLog.php index a8eac3a0..ab2e2cbc 100644 --- a/AbuseFilter/includes/special/SpecialAbuseLog.php +++ b/AbuseFilter/includes/special/SpecialAbuseLog.php @@ -6,30 +6,74 @@ class SpecialAbuseLog extends SpecialPage { */ protected $mSearchUser; + protected $mSearchPeriodStart; + + protected $mSearchPeriodEnd; + /** * @var Title */ protected $mSearchTitle; + /** + * @var string + */ + protected $mSearchAction; + + /** + * @var string + */ + protected $mSearchActionTaken; + protected $mSearchWiki; protected $mSearchFilter; protected $mSearchEntries; + protected $mSearchImpact; + public function __construct() { parent::__construct( 'AbuseLog', 'abusefilter-log' ); } + /** + * @return bool + */ public function doesWrites() { return true; } + /** + * Main routine + * + * $parameter string is converted into the $args array, which can come in + * three shapes: + * + * An array of size 2: only if the URL is like Special:AbuseLog/private/id + * where id is the log identifier. In this case, the private details of the + * log (e.g. IP address) will be shown. + * + * An array of size 1: either the URL is like Special:AbuseLog/id where + * the id is log identifier, in which case the details of the log except for + * private bits (e.g. IP address) are shown, or the URL is incomplete as in + * Special:AbuseLog/private (without speciying id), in which case a warning + * is shown to the user + * + * An array of size 0 when URL is like Special:AbuseLog or an array of size + * 1 when the URL is like Special:AbuseFilter/ (i.e. without anything after + * the slash). In this case, if the parameter `hide` was passed, it will be + * used as the identifier of the log entry that we want to hide; otherwise, + * the abuse logs are shown as a list, with a search form above the list. + * + * @param string $parameter URL parameters + */ public function execute( $parameter ) { $out = $this->getOutput(); $request = $this->getRequest(); - AbuseFilter::addNavigationLinks( $this->getContext(), 'log' ); + AbuseFilter::addNavigationLinks( + $this->getContext(), 'log', $this->getLinkRenderer() ); $this->setHeaders(); $this->outputHeader( 'abusefilter-log-summary' ); @@ -46,7 +90,6 @@ class SpecialAbuseLog extends SpecialPage { $errors = $this->getPageTitle()->getUserPermissionsErrors( 'abusefilter-log', $this->getUser(), true, [ 'ns-specialprotected' ] ); if ( count( $errors ) ) { - // Go away. $out->showPermissionsErrorPage( $errors, 'abusefilter-log' ); return; @@ -54,68 +97,153 @@ class SpecialAbuseLog extends SpecialPage { $detailsid = $request->getIntOrNull( 'details' ); $hideid = $request->getIntOrNull( 'hide' ); + $args = explode( '/', $parameter ); - if ( $parameter ) { - $detailsid = $parameter; - } - - if ( $detailsid ) { - $this->showDetails( $detailsid ); - } elseif ( $hideid ) { - $this->showHideForm( $hideid ); + if ( count( $args ) === 2 && $args[0] === 'private' ) { + $this->showPrivateDetails( $args[1] ); + } elseif ( count( $args ) === 1 && $args[0] !== '' ) { + if ( $args[0] === 'private' ) { + $out->addWikiMsg( 'abusefilter-invalid-request-noid' ); + } else { + $this->showDetails( $args[0] ); + } } else { - // Show the search form. - $this->searchForm(); - - // Show the log itself. - $this->showList(); + if ( $hideid ) { + $this->showHideForm( $hideid ); + } else { + $this->searchForm(); + $this->showList(); + } } } - function loadParameters() { - global $wgAbuseFilterIsCentral; - + /** + * Loads parameters from request + */ + public function loadParameters() { $request = $this->getRequest(); - $this->mSearchUser = trim( $request->getText( 'wpSearchUser' ) ); - if ( $wgAbuseFilterIsCentral ) { + $searchUsername = trim( $request->getText( 'wpSearchUser' ) ); + $userTitle = Title::newFromText( $searchUsername, NS_USER ); + $this->mSearchUser = $userTitle ? $userTitle->getText() : null; + if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) { $this->mSearchWiki = $request->getText( 'wpSearchWiki' ); } - $u = User::newFromName( $this->mSearchUser ); - if ( $u ) { - $this->mSearchUser = $u->getName(); // Username normalisation - } elseif ( IP::isIPAddress( $this->mSearchUser ) ) { - // It's an IP - $this->mSearchUser = IP::sanitizeIP( $this->mSearchUser ); - } else { - $this->mSearchUser = null; - } - + $this->mSearchPeriodStart = $request->getText( 'wpSearchPeriodStart' ); + $this->mSearchPeriodEnd = $request->getText( 'wpSearchPeriodEnd' ); $this->mSearchTitle = $request->getText( 'wpSearchTitle' ); $this->mSearchFilter = null; + $this->mSearchAction = $request->getText( 'wpSearchAction' ); + $this->mSearchActionTaken = $request->getText( 'wpSearchActionTaken' ); if ( self::canSeeDetails() ) { $this->mSearchFilter = $request->getText( 'wpSearchFilter' ); } $this->mSearchEntries = $request->getText( 'wpSearchEntries' ); + $this->mSearchImpact = $request->getText( 'wpSearchImpact' ); } - function searchForm() { - global $wgAbuseFilterIsCentral; + /** + * @return string[] + */ + private function getAllActions() { + $config = $this->getConfig(); + return array_unique( + array_merge( + array_keys( $config->get( 'AbuseFilterActions' ) ), + array_keys( $config->get( 'AbuseFilterCustomActionsHandlers' ) ) + ) + ); + } + + /** + * @return string[] + */ + private function getAllFilterableActions() { + return [ + 'edit', + 'move', + 'upload', + 'stashupload', + 'delete', + 'createaccount', + 'autocreateaccount', + ]; + } + /** + * Builds the search form + */ + public function searchForm() { $formDescriptor = [ 'SearchUser' => [ 'label-message' => 'abusefilter-log-search-user', 'type' => 'user', + 'ipallowed' => true, 'default' => $this->mSearchUser, ], + 'SearchPeriodStart' => [ + 'label-message' => 'abusefilter-test-period-start', + 'type' => 'datetime', + 'default' => $this->mSearchPeriodStart + ], + 'SearchPeriodEnd' => [ + 'label-message' => 'abusefilter-test-period-end', + 'type' => 'datetime', + 'default' => $this->mSearchPeriodEnd + ], 'SearchTitle' => [ 'label-message' => 'abusefilter-log-search-title', 'type' => 'title', 'default' => $this->mSearchTitle, - ] + 'required' => false + ], + 'SearchImpact' => [ + 'label-message' => 'abusefilter-log-search-impact', + 'type' => 'select', + 'options' => [ + $this->msg( 'abusefilter-log-search-impact-all' )->text() => 0, + $this->msg( 'abusefilter-log-search-impact-saved' )->text() => 1, + $this->msg( 'abusefilter-log-search-impact-not-saved' )->text() => 2, + ], + ], + ]; + $filterableActions = $this->getAllFilterableActions(); + $actions = array_combine( $filterableActions, $filterableActions ); + $actions[ $this->msg( 'abusefilter-log-search-action-other' )->text() ] = 'other'; + $actions[ $this->msg( 'abusefilter-log-search-action-any' )->text() ] = 'any'; + $formDescriptor['SearchAction'] = [ + 'label-message' => 'abusefilter-log-search-action-label', + 'type' => 'select', + 'options' => $actions, + 'default' => 'any', + ]; + $options = [ + $this->msg( 'abusefilter-log-noactions' )->text() => 'noactions', + $this->msg( 'abusefilter-log-search-action-taken-any' )->text() => '', ]; + foreach ( $this->getAllActions() as $action ) { + $key = AbuseFilter::getActionDisplay( $action ); + $options[$key] = $action; + } + ksort( $options ); + $formDescriptor['SearchActionTaken'] = [ + 'label-message' => 'abusefilter-log-search-action-taken-label', + 'type' => 'select', + 'options' => $options, + ]; + if ( self::canSeeHidden() ) { + $formDescriptor['SearchEntries'] = [ + 'type' => 'select', + 'label-message' => 'abusefilter-log-search-entries-label', + 'options' => [ + $this->msg( 'abusefilter-log-search-entries-all' )->text() => 0, + $this->msg( 'abusefilter-log-search-entries-hidden' )->text() => 1, + $this->msg( 'abusefilter-log-search-entries-visible' )->text() => 2, + ], + ]; + } if ( self::canSeeDetails() ) { $formDescriptor['SearchFilter'] = [ 'label-message' => 'abusefilter-log-search-filter', @@ -123,7 +251,7 @@ class SpecialAbuseLog extends SpecialPage { 'default' => $this->mSearchFilter, ]; } - if ( $wgAbuseFilterIsCentral ) { + if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) { // Add free form input for wiki name. Would be nice to generate // a select with unique names in the db at some point. $formDescriptor['SearchWiki'] = [ @@ -132,19 +260,8 @@ class SpecialAbuseLog extends SpecialPage { 'default' => $this->mSearchWiki, ]; } - if ( self::canSeeHidden() ) { - $formDescriptor['SearchEntries'] = [ - 'type' => 'select', - 'label-message' => 'abusefilter-log-search-entries-label', - 'options' => [ - $this->msg( 'abusefilter-log-search-entries-all' )->text() => 0, - $this->msg( 'abusefilter-log-search-entries-hidden' )->text() => 1, - $this->msg( 'abusefilter-log-search-entries-visible' )->text() => 2, - ], - ]; - } - $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) ->setWrapperLegendMsg( 'abusefilter-log-search' ) ->setSubmitTextMsg( 'abusefilter-log-search-submit' ) ->setMethod( 'get' ) @@ -153,12 +270,12 @@ class SpecialAbuseLog extends SpecialPage { } /** - * @param $id - * @return mixed + * @param string $id */ - function showHideForm( $id ) { + public function showHideForm( $id ) { + $output = $this->getOutput(); if ( !$this->getUser()->isAllowed( 'abusefilter-hide-log' ) ) { - $this->getOutput()->addWikiMsg( 'abusefilter-log-hide-forbidden' ); + $output->addWikiMsg( 'abusefilter-log-hide-forbidden' ); return; } @@ -167,7 +284,7 @@ class SpecialAbuseLog extends SpecialPage { $row = $dbr->selectRow( [ 'abuse_filter_log', 'abuse_filter' ], - '*', + 'afl_deleted', [ 'afl_id' => $id ], __METHOD__, [], @@ -178,15 +295,24 @@ class SpecialAbuseLog extends SpecialPage { return; } + $hideReasonsOther = $this->msg( 'revdelete-reasonotherlist' )->text(); + $hideReasons = $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->text(); + $hideReasons = Xml::listDropDownOptions( $hideReasons, [ 'other' => $hideReasonsOther ] ); + $formInfo = [ 'logid' => [ 'type' => 'info', - 'default' => $id, + 'default' => (string)$id, 'label-message' => 'abusefilter-log-hide-id', ], + 'dropdownreason' => [ + 'type' => 'select', + 'options' => $hideReasons, + 'label-message' => 'abusefilter-log-hide-reason' + ], 'reason' => [ 'type' => 'text', - 'label-message' => 'abusefilter-log-hide-reason', + 'label-message' => 'abusefilter-log-hide-reason-other', ], 'hidden' => [ 'type' => 'toggle', @@ -195,19 +321,24 @@ class SpecialAbuseLog extends SpecialPage { ], ]; - $form = new HTMLForm( $formInfo, $this->getContext() ); - $form->setTitle( $this->getPageTitle() ); - $form->setWrapperLegend( $this->msg( 'abusefilter-log-hide-legend' )->text() ); - $form->addHiddenField( 'hide', $id ); - $form->setSubmitCallback( [ $this, 'saveHideForm' ] ); - $form->show(); + HTMLForm::factory( 'ooui', $formInfo, $this->getContext() ) + ->setTitle( $this->getPageTitle() ) + ->setWrapperLegend( $this->msg( 'abusefilter-log-hide-legend' )->text() ) + ->addHiddenField( 'hide', $id ) + ->setSubmitCallback( [ $this, 'saveHideForm' ] ) + ->show(); + + // Show suppress log for this entry + $suppressLogPage = new LogPage( 'suppress' ); + $output->addHTML( "<h2>" . $suppressLogPage->getName()->escaped() . "</h2>\n" ); + LogEventsList::showLogExtract( $output, 'suppress', $this->getPageTitle( $id ) ); } /** - * @param $fields + * @param array $fields * @return bool */ - function saveHideForm( $fields ) { + public function saveHideForm( $fields ) { $logid = $this->getRequest()->getVal( 'hide' ); $dbw = wfGetDB( DB_MASTER ); @@ -219,17 +350,30 @@ class SpecialAbuseLog extends SpecialPage { __METHOD__ ); - $logPage = new LogPage( 'suppress' ); - $action = $fields['hidden'] ? 'hide-afl' : 'unhide-afl'; + $reason = $fields['dropdownreason']; + if ( $reason === 'other' ) { + $reason = $fields['reason']; + } elseif ( $fields['reason'] !== '' ) { + $reason .= + $this->msg( 'colon-separator' )->inContentLanguage()->text() . $fields['reason']; + } - $logPage->addEntry( $action, $this->getPageTitle( $logid ), $fields['reason'] ); + $action = $fields['hidden'] ? 'hide-afl' : 'unhide-afl'; + $logEntry = new ManualLogEntry( 'suppress', $action ); + $logEntry->setPerformer( $this->getUser() ); + $logEntry->setTarget( $this->getPageTitle( $logid ) ); + $logEntry->setComment( $reason ); + $logEntry->insert(); $this->getOutput()->redirect( SpecialPage::getTitleFor( 'AbuseLog' )->getFullURL() ); return true; } - function showList() { + /** + * Shows the results list + */ + public function showList() { $out = $this->getOutput(); // Generate conditions list. @@ -247,6 +391,17 @@ class SpecialAbuseLog extends SpecialPage { } } + $dbr = wfGetDB( DB_REPLICA ); + if ( $this->mSearchPeriodStart ) { + $conds[] = 'afl_timestamp >= ' . + $dbr->addQuotes( $dbr->timestamp( strtotime( $this->mSearchPeriodStart ) ) ); + } + + if ( $this->mSearchPeriodEnd ) { + $conds[] = 'afl_timestamp <= ' . + $dbr->addQuotes( $dbr->timestamp( strtotime( $this->mSearchPeriodEnd ) ) ); + } + if ( $this->mSearchWiki ) { if ( $this->mSearchWiki == wfWikiID() ) { $conds['afl_wiki'] = null; @@ -291,7 +446,47 @@ class SpecialAbuseLog extends SpecialPage { if ( $this->mSearchEntries == '1' ) { $conds['afl_deleted'] = 1; } elseif ( $this->mSearchEntries == '2' ) { - $conds[] = self::getNotDeletedCond( wfGetDB( DB_REPLICA ) ); + $conds[] = self::getNotDeletedCond( $dbr ); + } + } + + if ( in_array( $this->mSearchImpact, [ '1', '2' ] ) ) { + $unsuccessfulActionConds = $dbr->makeList( [ + 'afl_rev_id' => null, + 'afl_log_id' => null, + ], LIST_AND ); + if ( $this->mSearchImpact == '1' ) { + $conds[] = "NOT ( $unsuccessfulActionConds )"; + } else { + $conds[] = $unsuccessfulActionConds; + } + } + + if ( $this->mSearchActionTaken ) { + if ( in_array( $this->mSearchActionTaken, $this->getAllActions() ) ) { + $list = [ 'afl_actions' => $this->mSearchActionTaken ]; + $list[] = 'afl_actions' . $dbr->buildLike( + $this->mSearchActionTaken, ',', $dbr->anyString() ); + $list[] = 'afl_actions' . $dbr->buildLike( + $dbr->anyString(), ',', $this->mSearchActionTaken ); + $list[] = 'afl_actions' . $dbr->buildLike( + $dbr->anyString(), + ',', $this->mSearchActionTaken, ',', + $dbr->anyString() + ); + $conds[] = $dbr->makeList( $list, LIST_OR ); + } elseif ( $this->mSearchActionTaken === 'noactions' ) { + $conds['afl_actions'] = ''; + } + } + + if ( $this->mSearchAction ) { + $filterableActions = $this->getAllFilterableActions(); + if ( in_array( $this->mSearchAction, $filterableActions ) ) { + $conds['afl_action'] = $this->mSearchAction; + } elseif ( $this->mSearchAction === 'other' ) { + $list = $dbr->makeList( [ 'afl_action' => $filterableActions ], LIST_OR ); + $conds[] = "NOT ( $list )"; } } @@ -308,10 +503,9 @@ class SpecialAbuseLog extends SpecialPage { } /** - * @param $id - * @return mixed + * @param string $id */ - function showDetails( $id ) { + public function showDetails( $id ) { $out = $this->getOutput(); $dbr = wfGetDB( DB_REPLICA ); @@ -343,7 +537,7 @@ class SpecialAbuseLog extends SpecialPage { return; } - if ( self::isHidden( $row ) && !self::canSeeHidden() ) { + if ( self::isHidden( $row ) === true && !self::canSeeHidden() ) { $out->addWikiMsg( 'abusefilter-log-details-hidden' ); return; @@ -359,7 +553,9 @@ class SpecialAbuseLog extends SpecialPage { $output = Xml::element( 'legend', null, - $this->msg( 'abusefilter-log-details-legend', $id )->text() + $this->msg( 'abusefilter-log-details-legend' ) + ->numParams( $id ) + ->text() ); $output .= Xml::tags( 'p', null, $this->formatRow( $row, false ) ); @@ -376,8 +572,10 @@ class SpecialAbuseLog extends SpecialPage { $diffEngine->showDiffStyle(); - $formattedDiff = $diffEngine->generateTextDiffBody( $old_wikitext, $new_wikitext ); - $formattedDiff = $diffEngine->addHeader( $formattedDiff, '', '' ); + $formattedDiff = $diffEngine->addHeader( + $diffEngine->generateTextDiffBody( $old_wikitext, $new_wikitext ), + '', '' + ); $output .= Xml::tags( @@ -395,45 +593,276 @@ class SpecialAbuseLog extends SpecialPage { $output .= AbuseFilter::buildVarDumpTable( $vars, $this->getContext() ); if ( self::canSeePrivate() ) { - // Private stuff, like IPs. - $header = - Xml::element( 'th', null, $this->msg( 'abusefilter-log-details-var' )->text() ) . - Xml::element( 'th', null, $this->msg( 'abusefilter-log-details-val' )->text() ); - $output .= Xml::element( 'h3', null, $this->msg( 'abusefilter-log-details-private' )->text() ); - $output .= - Xml::openElement( 'table', - [ - 'class' => 'wikitable mw-abuselog-private', - 'style' => 'width: 80%;' - ] + $formDescriptor = [ + 'Reason' => [ + 'label-message' => 'abusefilter-view-private-reason', + 'type' => 'text', + 'size' => 45, + ], + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm->setWrapperLegendMsg( 'abusefilter-view-private' ) + ->setAction( $this->getPageTitle( 'private/' . $id )->getLocalURL() ) + ->setSubmitTextMsg( 'abusefilter-view-private-submit' ) + ->setMethod( 'post' ) + ->prepareForm(); + + $output .= $htmlForm->getHTML( false ); + } + + $out->addHTML( Xml::tags( 'fieldset', null, $output ) ); + } + + /** + * @param string $id + * @return void + */ + public function showPrivateDetails( $id ) { + $lang = $this->getLanguage(); + $out = $this->getOutput(); + $request = $this->getRequest(); + + $dbr = wfGetDB( DB_REPLICA ); + + $reason = $request->getText( 'wpReason' ); + + // Make sure it is a valid request + $token = $request->getVal( 'wpEditToken' ); + if ( !$request->wasPosted() || !$this->getUser()->matchEditToken( $token ) ) { + $out->addHTML( + Xml::tags( + 'p', + null, + Html::errorBox( $this->msg( 'abusefilter-invalid-request' )->params( $id )->parse() ) + ) + ); + + return; + } + + if ( !$this->checkReason( $reason ) ) { + $out->addWikiMsg( 'abusefilter-noreason' ); + $this->showDetails( $id ); + return; + } + + $row = $dbr->selectRow( + [ 'abuse_filter_log', 'abuse_filter' ], + [ 'afl_id', 'afl_filter', 'afl_user_text', 'afl_timestamp', 'afl_ip', 'af_id', + 'af_public_comments', 'af_hidden' ], + [ 'afl_id' => $id ], + __METHOD__, + [], + [ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ] + ); + + if ( !$row ) { + $out->addWikiMsg( 'abusefilter-log-nonexistent' ); + + return; + } + + if ( AbuseFilter::decodeGlobalName( $row->afl_filter ) ) { + $filter_hidden = null; + } else { + $filter_hidden = $row->af_hidden; + } + + if ( !self::canSeeDetails( $row->afl_filter, $filter_hidden ) ) { + $out->addWikiMsg( 'abusefilter-log-cannot-see-details' ); + + return; + } + + if ( !self::canSeePrivate() ) { + $out->addWikiMsg( 'abusefilter-log-cannot-see-private-details' ); + + return; + } + + // Log accessing private details + if ( $this->getConfig()->get( 'AbuseFilterPrivateLog' ) ) { + $user = $this->getUser(); + self::addLogEntry( $id, $reason, $user ); + } + + // Show private details (IP). + $output = Xml::element( + 'legend', + null, + $this->msg( 'abusefilter-log-details-private' )->text() + ); + + $header = + Xml::element( 'th', null, $this->msg( 'abusefilter-log-details-var' )->text() ) . + Xml::element( 'th', null, $this->msg( 'abusefilter-log-details-val' )->text() ); + + $output .= + Xml::openElement( 'table', + [ + 'class' => 'wikitable mw-abuselog-private', + 'style' => 'width: 80%;' + ] + ) . + Xml::openElement( 'tbody' ); + $output .= $header; + + // Log ID + $linkRenderer = $this->getLinkRenderer(); + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-log-details-id' )->text() ) . - Xml::openElement( 'tbody' ); - $output .= $header; + Xml::openElement( 'td' ) . + $linkRenderer->makeKnownLink( + $this->getPageTitle( $row->afl_id ), + $lang->formatNum( $row->afl_id ) + ) . + Xml::closeElement( 'td' ) + ); + + // Timestamp + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-edit-builder-vars-timestamp-expanded' )->text() + ) . + Xml::element( 'td', + null, + $lang->timeanddate( $row->afl_timestamp, true ) + ) + ); + + // User + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-edit-builder-vars-user-name' )->text() + ) . + Xml::element( 'td', + null, + $row->afl_user_text + ) + ); + + // Filter ID + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-list-id' )->text() + ) . + Xml::openElement( 'td' ) . + $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ), + $lang->formatNum( $row->af_id ) + ) . + Xml::closeElement( 'td' ) + ); + + // Filter description + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-list-public' )->text() + ) . + Xml::element( 'td', + null, + $row->af_public_comments + ) + ); - // IP address + // IP address + if ( $row->afl_ip !== '' ) { + if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) && + $this->getUser()->isAllowed( 'checkuser' ) ) { + $CULink = ' · ' . $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( + 'CheckUser', + $row->afl_ip + ), + $this->msg( 'abusefilter-log-details-checkuser' )->text() + ); + } else { + $CULink = ''; + } $output .= Xml::tags( 'tr', null, Xml::element( 'td', [ 'style' => 'width: 30%;' ], $this->msg( 'abusefilter-log-details-ip' )->text() ) . - Xml::element( 'td', null, $row->afl_ip ) + Xml::tags( + 'td', + null, + self::getUserLinks( 0, $row->afl_ip ) . $CULink + ) + ); + } else { + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-log-details-ip' )->text() + ) . + Xml::element( + 'td', + null, + $this->msg( 'abusefilter-log-ip-not-available' )->text() + ) ); - - $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); } + $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); + $output = Xml::tags( 'fieldset', null, $output ); $out->addHTML( $output ); } /** - * @param $filter_id null - * @param $filter_hidden null + * If specifying a reason for viewing private details of abuse log is required + * then it makes sure that a reason is provided. + * + * @param string $reason * @return bool */ - static function canSeeDetails( $filter_id = null, $filter_hidden = null ) { + protected function checkReason( $reason ) { + return ( !$this->getConfig()->get( 'AbuseFilterForceSummary' ) || strlen( $reason ) > 0 ); + } + + /** + * @param int $logID int The ID of the AbuseFilter log that was accessed + * @param string $reason The reason provided for accessing private details + * @param User $user The user who accessed the private details + * @return void + */ + public static function addLogEntry( $logID, $reason, $user ) { + $target = self::getTitleFor( 'AbuseLog', $logID ); + + $logEntry = new ManualLogEntry( 'abusefilterprivatedetails', 'access' ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $target ); + $logEntry->setParameters( [ + '4::logid' => $logID, + ] ); + $logEntry->setComment( $reason ); + + $logEntry->insert(); + } + + /** + * @param string|null $filter_id + * @param bool|int|null $filter_hidden + * @return bool + */ + public static function canSeeDetails( $filter_id = null, $filter_hidden = null ) { global $wgUser; if ( $filter_id !== null ) { @@ -453,7 +882,7 @@ class SpecialAbuseLog extends SpecialPage { /** * @return bool */ - static function canSeePrivate() { + public static function canSeePrivate() { global $wgUser; return $wgUser->isAllowed( 'abusefilter-private' ); @@ -462,18 +891,18 @@ class SpecialAbuseLog extends SpecialPage { /** * @return bool */ - static function canSeeHidden() { + public static function canSeeHidden() { global $wgUser; return $wgUser->isAllowed( 'abusefilter-hidden-log' ); } /** - * @param $row - * @param $isListItem bool + * @param stdClass $row + * @param bool $isListItem * @return String */ - function formatRow( $row, $isListItem = true ) { + public function formatRow( $row, $isListItem = true ) { $user = $this->getUser(); $lang = $this->getLanguage(); @@ -484,7 +913,7 @@ class SpecialAbuseLog extends SpecialPage { $diffLink = false; $isHidden = self::isHidden( $row ); - if ( !self::canSeeHidden() && $isHidden ) { + if ( !self::canSeeHidden() && $isHidden === true ) { return ''; } @@ -524,7 +953,7 @@ class SpecialAbuseLog extends SpecialPage { $actions_taken = $row->afl_actions; if ( !strlen( trim( $actions_taken ) ) ) { - $actions_taken = $this->msg( 'abusefilter-log-noactions' )->text(); + $actions_taken = $this->msg( 'abusefilter-log-noactions' )->escaped(); } else { $actions = explode( ',', $actions_taken ); $displayActions = []; @@ -539,11 +968,12 @@ class SpecialAbuseLog extends SpecialPage { if ( $globalIndex ) { // Pull global filter description - $parsed_comments = - $this->getOutput()->parseInline( AbuseFilter::getGlobalFilterDescription( $globalIndex ) ); + $escaped_comments = Sanitizer::escapeHtmlAllowEntities( + AbuseFilter::getGlobalFilterDescription( $globalIndex ) ); $filter_hidden = null; } else { - $parsed_comments = $this->getOutput()->parseInline( $row->af_public_comments ); + $escaped_comments = Sanitizer::escapeHtmlAllowEntities( + $row->af_public_comments ); $filter_hidden = $row->af_hidden; } @@ -579,11 +1009,10 @@ class SpecialAbuseLog extends SpecialPage { } if ( $globalIndex ) { - global $wgAbuseFilterCentralDB; - $globalURL = - WikiMap::getForeignURL( $wgAbuseFilterCentralDB, - 'Special:AbuseFilter/' . $globalIndex ); - + $globalURL = WikiMap::getForeignURL( + $this->getConfig()->get( 'AbuseFilterCentralDB' ), + 'Special:AbuseFilter/' . $globalIndex + ); $linkText = $this->msg( 'abusefilter-log-detailedentry-global' ) ->numParams( $globalIndex )->escaped(); $filterLink = Linker::makeExternalLink( $globalURL, $linkText ); @@ -600,7 +1029,7 @@ class SpecialAbuseLog extends SpecialPage { $row->afl_action, $pageLink, $actions_taken, - $parsed_comments, + $escaped_comments, $lang->pipeList( $actionLinks ) )->params( $row->afl_user_text )->parse(); } else { @@ -615,8 +1044,9 @@ class SpecialAbuseLog extends SpecialPage { $row->afl_action, $pageLink, $actions_taken, - $parsed_comments, - $diffLink // Passing $7 to 'abusefilter-log-entry' will do nothing, as it's not used. + $escaped_comments, + // Passing $7 to 'abusefilter-log-entry' will do nothing, as it's not used. + $diffLink )->params( $row->afl_user_text )->parse(); } @@ -636,6 +1066,11 @@ class SpecialAbuseLog extends SpecialPage { } } + /** + * @param int $userId + * @param string $userName + * @return string + */ protected static function getUserLinks( $userId, $userName ) { static $cache = []; @@ -648,7 +1083,7 @@ class SpecialAbuseLog extends SpecialPage { } /** - * @param $db DatabaseBase + * @param \Wikimedia\Rdbms\IDatabase $db * @return string */ public static function getNotDeletedCond( $db ) { @@ -665,12 +1100,17 @@ class SpecialAbuseLog extends SpecialPage { /** * Given a log entry row, decides whether or not it can be viewed by the public. * - * @param $row stdClass The abuse_filter_log row object. + * @param stdClass $row The abuse_filter_log row object. * * @return bool|string true if the item is explicitly hidden, false if it is not. * The string 'implicit' if it is hidden because the corresponding revision is hidden. */ public static function isHidden( $row ) { + // First, check if the entry is hidden. Since this is an oversight-level deletion, + // it's more important than the associated revision being deleted. + if ( $row->afl_deleted ) { + return true; + } if ( $row->afl_rev_id ) { $revision = Revision::newFromId( $row->afl_rev_id ); if ( $revision && $revision->getVisibility() != 0 ) { @@ -678,87 +1118,13 @@ class SpecialAbuseLog extends SpecialPage { } } - return (bool)$row->afl_deleted; - } - - protected function getGroupName() { - return 'changes'; - } -} - -class AbuseLogPager extends ReverseChronologicalPager { - /** - * @var SpecialAbuseLog - */ - public $mForm; - - /** - * @var array - */ - public $mConds; - - /** - * @param SpecialAbuseLog $form - * @param array $conds - * @param bool $details - */ - function __construct( $form, $conds = [], $details = false ) { - $this->mForm = $form; - $this->mConds = $conds; - parent::__construct(); - } - - function formatRow( $row ) { - return $this->mForm->formatRow( $row ); - } - - function getQueryInfo() { - $conds = $this->mConds; - - $info = [ - 'tables' => [ 'abuse_filter_log', 'abuse_filter' ], - 'fields' => '*', - 'conds' => $conds, - 'join_conds' => - [ 'abuse_filter' => - [ - 'LEFT JOIN', - 'af_id=afl_filter', - ], - ], - ]; - - if ( !$this->mForm->canSeeHidden() ) { - $db = $this->mDb; - $info['conds'][] = SpecialAbuseLog::getNotDeletedCond( $db ); - } - - return $info; + return false; } /** - * @param ResultWrapper $result + * @return string */ - protected function preprocessResults( $result ) { - if ( $this->getNumRows() === 0 ) { - return; - } - - $lb = new LinkBatch(); - $lb->setCaller( __METHOD__ ); - foreach ( $result as $row ) { - // Only for local wiki results - if ( !$row->afl_wiki ) { - $lb->add( $row->afl_namespace, $row->afl_title ); - $lb->add( NS_USER, $row->afl_user ); - $lb->add( NS_USER_TALK, $row->afl_user_text ); - } - } - $lb->execute(); - $result->seek( 0 ); - } - - function getIndexField() { - return 'afl_timestamp'; + protected function getGroupName() { + return 'changes'; } } |