diff options
Diffstat (limited to 'www/wiki/extensions/AbuseFilter/includes/AbuseFilter.php')
-rw-r--r-- | www/wiki/extensions/AbuseFilter/includes/AbuseFilter.php | 2796 |
1 files changed, 2796 insertions, 0 deletions
diff --git a/www/wiki/extensions/AbuseFilter/includes/AbuseFilter.php b/www/wiki/extensions/AbuseFilter/includes/AbuseFilter.php new file mode 100644 index 00000000..c9afd66f --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/AbuseFilter.php @@ -0,0 +1,2796 @@ +<?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 + * static functions that handle activities such as parsing edits, applying filters, + * logging actions, etc. + */ +class AbuseFilter { + public static $statsStoragePeriod = 86400; + public static $condLimitEnabled = true; + + /** @var array Map of (filter ID => stdClass) */ + private static $filterCache = []; + + public static $condCount = 0; + + /** @var array Map of (action ID => string[]) */ + public static $tagsToSet = []; // FIXME: avoid global state here + + public static $history_mappings = [ + 'af_pattern' => 'afh_pattern', + 'af_user' => 'afh_user', + 'af_user_text' => 'afh_user_text', + 'af_timestamp' => 'afh_timestamp', + 'af_comments' => 'afh_comments', + 'af_public_comments' => 'afh_public_comments', + 'af_deleted' => 'afh_deleted', + 'af_id' => 'afh_filter', + 'af_group' => 'afh_group', + ]; + public static $builderValues = [ + 'op-arithmetic' => [ + '+' => 'addition', + '-' => 'subtraction', + '*' => 'multiplication', + '/' => 'divide', + '%' => 'modulo', + '**' => 'pow' + ], + 'op-comparison' => [ + '==' => 'equal', + '===' => 'equal-strict', + '!=' => 'notequal', + '!==' => 'notequal-strict', + '<' => 'lt', + '>' => 'gt', + '<=' => 'lte', + '>=' => 'gte' + ], + 'op-bool' => [ + '!' => 'not', + '&' => 'and', + '|' => 'or', + '^' => 'xor' + ], + 'misc' => [ + 'in' => 'in', + 'contains' => 'contains', + 'like' => 'like', + '""' => 'stringlit', + 'rlike' => 'rlike', + 'irlike' => 'irlike', + 'cond ? iftrue : iffalse' => 'tern', + 'if cond then iftrue elseiffalse end' => 'cond', + ], + 'funcs' => [ + 'length(string)' => 'length', + '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,...)' => 'contains-any', + 'contains_all(haystack,needle1,needle2,...)' => 'contains-all', + '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', + ], + 'vars' => [ + 'timestamp' => 'timestamp', + 'accountname' => 'accountname', + 'action' => 'action', + 'added_lines' => 'addedlines', + 'edit_delta' => 'delta', + 'edit_diff' => 'diff', + 'new_size' => 'newsize', + 'old_size' => 'oldsize', + 'new_content_model' => 'new-content-model', + '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', + 'moved_from_namespace' => 'movedfrom-ns', + 'moved_from_text' => 'movedfrom-text', + 'moved_from_prefixedtext' => 'movedfrom-prefixedtext', + 'moved_to_articleid' => 'movedto-id', + 'moved_to_namespace' => 'movedto-ns', + 'moved_to_text' => 'movedto-text', + 'moved_to_prefixedtext' => 'movedto-prefixedtext', + 'user_editcount' => 'user-editcount', + 'user_age' => 'user-age', + 'user_name' => 'user-name', + 'user_groups' => 'user-groups', + 'user_rights' => 'user-rights', + 'user_blocked' => 'user-blocked', + 'user_emailconfirm' => 'user-emailconfirm', + 'old_wikitext' => 'old-text', + 'new_wikitext' => 'new-text', + 'added_links' => 'added-links', + 'removed_links' => 'removed-links', + 'all_links' => 'all-links', + 'new_pst' => 'new-pst', + 'edit_diff_pst' => 'diff-pst', + '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', + '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_text' => 'old-text-stripped', # Disabled, performance + // 'old_html' => 'old-html', # Disabled, performance + 'old_links' => 'old-links', + 'minor_edit' => 'minor-edit', + 'file_sha1' => 'file-sha1', + 'file_size' => 'file-size', + 'file_mime' => 'file-mime', + 'file_mediatype' => 'file-mediatype', + 'file_width' => 'file-width', + 'file_height' => 'file-height', + 'file_bits_per_channel' => 'file-bits-per-channel', + ], + ]; + + public static $editboxName = null; + + /** + * @param IContextSource $context + * @param string $pageType + * @param LinkRenderer $linkRenderer + */ + public static function addNavigationLinks( + IContextSource $context, + $pageType, + LinkRenderer $linkRenderer + ) { + $linkDefs = [ + 'home' => 'Special:AbuseFilter', + 'recentchanges' => 'Special:AbuseFilter/history', + 'examine' => 'Special:AbuseFilter/examine', + 'log' => 'Special:AbuseLog', + ]; + + if ( $context->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $linkDefs = array_merge( $linkDefs, [ + 'test' => 'Special:AbuseFilter/test', + 'tools' => 'Special:AbuseFilter/tools', + 'import' => 'Special:AbuseFilter/import', + ] ); + } + + // Save some translator work + $msgOverrides = [ + 'recentchanges' => 'abusefilter-filter-log', + ]; + + $links = []; + + foreach ( $linkDefs as $name => $page ) { + // Give grep a chance to find the usages: + // abusefilter-topnav-home, abusefilter-topnav-test, abusefilter-topnav-examine + // abusefilter-topnav-log, abusefilter-topnav-tools, abusefilter-topnav-import + $msgName = "abusefilter-topnav-$name"; + + if ( isset( $msgOverrides[$name] ) ) { + $msgName = $msgOverrides[$name]; + } + + $msg = $context->msg( $msgName )->parse(); + $title = Title::newFromText( $page ); + + if ( $name == $pageType ) { + $links[] = Xml::tags( 'strong', null, $msg ); + } else { + $links[] = $linkRenderer->makeLink( $title, new HtmlArmor( $msg ) ); + } + } + + $linkStr = $context->msg( 'parentheses', $context->getLanguage()->pipeList( $links ) )->text(); + $linkStr = $context->msg( 'abusefilter-topnav' )->parse() . " $linkStr"; + + $linkStr = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-navigation' ], $linkStr ); + + $context->getOutput()->setSubtitle( $linkStr ); + } + + /** + * @static + * @param User $user + * @return AbuseFilterVariableHolder + */ + public static function generateUserVars( $user ) { + $vars = new AbuseFilterVariableHolder; + + $vars->setLazyLoadVar( + 'user_editcount', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getEditCount' ] + ); + + $vars->setVar( 'user_name', $user->getName() ); + + $vars->setLazyLoadVar( + 'user_emailconfirm', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getEmailAuthenticationTimestamp' ] + ); + + $vars->setLazyLoadVar( + 'user_age', + 'user-age', + [ 'user' => $user, 'asof' => wfTimestampNow() ] + ); + + $vars->setLazyLoadVar( + 'user_groups', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getEffectiveGroups' ] + ); + + $vars->setLazyLoadVar( + 'user_rights', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getRights' ] + ); + + $vars->setLazyLoadVar( + 'user_blocked', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'isBlocked' ] + ); + + Hooks::run( 'AbuseFilter-generateUserVars', [ $vars, $user ] ); + + return $vars; + } + + /** + * @return array + */ + public static function getBuilderValues() { + static $realValues = null; + + if ( $realValues ) { + return $realValues; + } + + $realValues = self::$builderValues; + global $wgDisableCounters; + if ( !$wgDisableCounters ) { + $realValues['vars']['article_views'] = 'article-views'; + } + Hooks::run( 'AbuseFilter-builder', [ &$realValues ] ); + + return $realValues; + } + + /** + * @param string $filter + * @return bool + */ + public static function filterHidden( $filter ) { + $globalIndex = self::decodeGlobalName( $filter ); + if ( $globalIndex ) { + global $wgAbuseFilterCentralDB; + if ( !$wgAbuseFilterCentralDB ) { + return false; + } + $dbr = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + $filter = $globalIndex; + } else { + $dbr = wfGetDB( DB_REPLICA ); + } + if ( $filter === 'new' ) { + return false; + } + $hidden = $dbr->selectField( + 'abuse_filter', + 'af_hidden', + [ 'af_id' => $filter ], + __METHOD__ + ); + + return (bool)$hidden; + } + + /** + * @param int $val + * @throws MWException + */ + public static function triggerLimiter( $val = 1 ) { + self::$condCount += $val; + + global $wgAbuseFilterConditionLimit; + + if ( self::$condLimitEnabled && self::$condCount > $wgAbuseFilterConditionLimit ) { + throw new MWException( 'Condition limit reached.' ); + } + } + + public static function disableConditionLimit() { + // For use in batch scripts and the like + self::$condLimitEnabled = false; + } + + /** + * @param Title|null $title + * @param string $prefix + * @return AbuseFilterVariableHolder + */ + public static function generateTitleVars( $title, $prefix ) { + $vars = new AbuseFilterVariableHolder; + + if ( !$title ) { + return $vars; + } + + $vars->setVar( $prefix . '_ARTICLEID', $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 ) ); + } + } + + // Use restrictions. + global $wgRestrictionTypes; + foreach ( $wgRestrictionTypes as $action ) { + $vars->setLazyLoadVar( "{$prefix}_restrictions_$action", 'get-page-restrictions', + [ 'title' => $title->getText(), + 'namespace' => $title->getNamespace(), + 'action' => $action + ] + ); + } + + $vars->setLazyLoadVar( "{$prefix}_recent_contributors", 'load-recent-authors', + [ + 'title' => $title->getText(), + 'namespace' => $title->getNamespace() + ] ); + + $vars->setLazyLoadVar( "{$prefix}_first_contributor", 'load-first-author', + [ + 'title' => $title->getText(), + 'namespace' => $title->getNamespace() + ] ); + + Hooks::run( 'AbuseFilter-generateTitleVars', [ $vars, $title, $prefix ] ); + + return $vars; + } + + /** + * @param string $filter + * @return mixed + */ + public static function checkSyntax( $filter ) { + global $wgAbuseFilterParserClass; + + /** @var $parser AbuseFilterParser */ + $parser = new $wgAbuseFilterParserClass; + + return $parser->checkSyntax( $filter ); + } + + /** + * @param string $expr + * @param array $vars + * @return string + */ + public static function evaluateExpression( $expr, $vars = [] ) { + global $wgAbuseFilterParserClass; + + if ( self::checkSyntax( $expr ) !== true ) { + return 'BADSYNTAX'; + } + + /** @var $parser AbuseFilterParser */ + $parser = new $wgAbuseFilterParserClass( $vars ); + + return $parser->evaluateExpression( $expr ); + } + + /** + * @param string $conds + * @param AbuseFilterVariableHolder $vars + * @param bool $ignoreError + * @return bool + * @throws Exception + */ + public static function checkConditions( + $conds, $vars, $ignoreError = true + ) { + global $wgAbuseFilterParserClass; + + static $parser, $lastVars; + + if ( is_null( $parser ) || $vars !== $lastVars ) { + /** @var $parser AbuseFilterParser */ + $parser = new $wgAbuseFilterParserClass( $vars ); + $lastVars = $vars; + } + + try { + $result = $parser->parse( $conds, self::$condCount ); + } catch ( Exception $excep ) { + // Sigh. + $result = false; + + wfDebugLog( 'AbuseFilter', 'AbuseFilter parser error: ' . $excep->getMessage() . "\n" ); + + if ( !$ignoreError ) { + throw $excep; + } + } + + return $result; + } + + /** + * Returns an associative array of filters which were tripped + * + * @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', + Title $title = null, + $mode = 'execute' + ) { + global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; + global $wgAbuseFilterConditionLimit; + + // Fetch from the database. + $filter_matched = []; + + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( + 'abuse_filter', + '*', + [ + 'af_enabled' => 1, + 'af_deleted' => 0, + 'af_group' => $group, + ], + __METHOD__ + ); + + foreach ( $res as $row ) { + $filter_matched[$row->af_id] = self::checkFilter( $row, $vars, $title, '', $mode ); + } + + if ( $wgAbuseFilterCentralDB && !$wgAbuseFilterIsCentral ) { + // Global filters + $globalRulesKey = self::getGlobalRulesKey( $group ); + + $fname = __METHOD__; + $res = ObjectCache::getMainWANInstance()->getWithSetCallback( + $globalRulesKey, + WANObjectCache::TTL_INDEFINITE, + function () use ( $group, $fname ) { + global $wgAbuseFilterCentralDB; + + $fdb = wfGetLB( $wgAbuseFilterCentralDB )->getConnectionRef( + DB_REPLICA, [], $wgAbuseFilterCentralDB + ); + + return iterator_to_array( $fdb->select( + 'abuse_filter', + '*', + [ + 'af_enabled' => 1, + 'af_deleted' => 0, + 'af_global' => 1, + 'af_group' => $group, + ], + $fname + ) ); + }, + [ + 'checkKeys' => [ $globalRulesKey ], + 'lockTSE' => 300 + ] + ); + + foreach ( $res as $row ) { + $filter_matched['global-' . $row->af_id] = + self::checkFilter( $row, $vars, $title, 'global-', $mode ); + } + } + + 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 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, Title $title = null, $prefix = '', $mode ) { + global $wgAbuseFilterProfile, $wgAbuseFilterRuntimeProfile, $wgAbuseFilterSlowFilterRuntimeLimit; + + $filterID = $prefix . $row->af_id; + + // 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 */ + ) + ) { + // Record match. + $result = true; + } else { + // Record non-match. + $result = false; + } + + $timeTaken = microtime( true ) - $startTime; + $condsUsed = self::$condCount - $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 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 ) { + $stash = ObjectCache::getMainStashInstance(); + $countKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'count' ); + $totalKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'total' ); + $condsKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'conds' ); + + $stash->delete( $countKey ); + $stash->delete( $totalKey ); + $stash->delete( $condsKey ); + } + + /** + * @param int $filter + * @param float $time + * @param int $conds + */ + public static function recordProfilingResult( $filter, $time, $conds ) { + // Defer updates to avoid massive (~1 second) edit time increases + DeferredUpdates::addCallableUpdate( function () use ( $filter, $time, $conds ) { + $stash = ObjectCache::getMainStashInstance(); + $countKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'count' ); + $totalKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'total' ); + $condsKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'conds' ); + + $curCount = $stash->get( $countKey ); + $curTotal = $stash->get( $totalKey ); + $curConds = $stash->get( $condsKey ); + + if ( $curCount ) { + $stash->set( $condsKey, $curConds + $conds, 3600 ); + $stash->set( $totalKey, $curTotal + $time, 3600 ); + $stash->incr( $countKey ); + } else { + $stash->set( $countKey, 1, 3600 ); + $stash->set( $totalKey, $time, 3600 ); + $stash->set( $condsKey, $conds, 3600 ); + } + } ); + } + + /** + * @param string $filter + * @return array + */ + public static function getFilterProfile( $filter ) { + $stash = ObjectCache::getMainStashInstance(); + $countKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'count' ); + $totalKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'total' ); + $condsKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'conds' ); + + $curCount = $stash->get( $countKey ); + $curTotal = $stash->get( $totalKey ); + $curConds = $stash->get( $condsKey ); + + if ( !$curCount ) { + return [ 0, 0 ]; + } + + $timeProfile = ( $curTotal / $curCount ) * 1000; // 1000 ms in a sec + $timeProfile = round( $timeProfile, 2 ); // Return in ms, rounded to 2dp + + $condProfile = ( $curConds / $curCount ); + $condProfile = round( $condProfile, 0 ); + + return [ $timeProfile, $condProfile ]; + } + + /** + * Utility function to decode global-$index to $index. Returns false if not global + * + * @param string $filter + * + * @return string|bool + */ + public static function decodeGlobalName( $filter ) { + if ( strpos( $filter, 'global-' ) == 0 ) { + return substr( $filter, strlen( 'global-' ) ); + } + + return false; + } + + /** + * @param string[] $filters + * @return array[] + */ + public static function getConsequencesForFilters( $filters ) { + $globalFilters = []; + $localFilters = []; + + foreach ( $filters as $filter ) { + $globalIndex = self::decodeGlobalName( $filter ); + + if ( $globalIndex ) { + $globalFilters[] = $globalIndex; + } else { + $localFilters[] = $filter; + } + } + + global $wgAbuseFilterCentralDB; + // Load local filter info + $dbr = wfGetDB( DB_REPLICA ); + // Retrieve the consequences. + $consequences = []; + + if ( count( $localFilters ) ) { + $consequences = self::loadConsequencesFromDB( $dbr, $localFilters ); + } + + if ( count( $globalFilters ) ) { + $fdb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + $consequences = $consequences + self::loadConsequencesFromDB( $fdb, $globalFilters, 'global-' ); + } + + return $consequences; + } + + /** + * @param IDatabase $dbr + * @param string[] $filters + * @param string $prefix + * @return array[] + */ + public static function loadConsequencesFromDB( $dbr, $filters, $prefix = '' ) { + $actionsByFilter = []; + foreach ( $filters as $filter ) { + $actionsByFilter[$prefix . $filter] = []; + } + + $res = $dbr->select( + [ 'abuse_filter_action', 'abuse_filter' ], + '*', + [ 'af_id' => $filters ], + __METHOD__, + [], + [ 'abuse_filter_action' => [ 'LEFT JOIN', 'afa_filter=af_id' ] ] + ); + + // Categorise consequences by filter. + global $wgAbuseFilterRestrictions; + foreach ( $res as $row ) { + if ( $row->af_throttled + && !empty( $wgAbuseFilterRestrictions[$row->afa_consequence] ) + ) { + # 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. + } else { + $actionsByFilter[$prefix . $row->afa_filter][$row->afa_consequence] = [ + 'action' => $row->afa_consequence, + 'parameters' => array_filter( explode( "\n", $row->afa_parameters ) ) + ]; + } + } + + return $actionsByFilter; + } + + /** + * Executes a list of actions. + * + * @param string[] $filters + * @param Title $title + * @param AbuseFilterVariableHolder $vars + * @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 ) { + global $wgMainCacheType; + + $actionsByFilter = self::getConsequencesForFilters( $filters ); + $actionsTaken = array_fill_keys( $filters, [] ); + + $messages = []; + // Accumulator to track max block to issue + $maxExpiry = -1; + + global $wgAbuseFilterDisallowGlobalLocalBlocks, $wgAbuseFilterRestrictions, + $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration; + foreach ( $actionsByFilter as $filter => $actions ) { + // Special-case handling for warnings. + $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 + // caching, check to see if the user has hit the throttle. + if ( !empty( $actions['throttle'] ) && $wgMainCacheType !== CACHE_NONE ) { + $parameters = $actions['throttle']['parameters']; + $throttleId = array_shift( $parameters ); + list( $rateCount, $ratePeriod ) = explode( ',', array_shift( $parameters ) ); + + $hitThrottle = false; + + // The rest are throttle-types. + foreach ( $parameters as $throttleType ) { + $hitThrottle = $hitThrottle || self::isThrottled( + $throttleId, $throttleType, $title, $rateCount, $ratePeriod, $global_filter ); + } + + unset( $actions['throttle'] ); + if ( !$hitThrottle ) { + $actionsTaken[$filter][] = 'throttle'; + continue; + } + } + + if ( $wgAbuseFilterDisallowGlobalLocalBlocks && $global_filter ) { + $actions = array_diff_key( $actions, array_filter( $wgAbuseFilterRestrictions ) ); + } + + if ( !empty( $actions['warn'] ) ) { + $parameters = $actions['warn']['parameters']; + $warnKey = 'abusefilter-warned-' . md5( $title->getPrefixedText() ) . '-' . $filter; + + // Make sure the session is started prior to using it + $session = SessionManager::getGlobalSession(); + $session->persist(); + + if ( !isset( $session[$warnKey] ) || !$session[$warnKey] ) { + $session[$warnKey] = true; + + // Threaten them a little bit + if ( !empty( $parameters[0] ) && strlen( $parameters[0] ) ) { + $msg = $parameters[0]; + } else { + $msg = 'abusefilter-warning'; + } + $messages[] = [ $msg, $filter_public_comments, $filter ]; + + $actionsTaken[$filter][] = 'warn'; + + continue; // Don't do anything else. + } else { + // We already warned them + $session[$warnKey] = false; + } + + unset( $actions['warn'] ); + } + + // 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'] ) ) { + global $wgUser; + $parameters = $actions['block']['parameters']; + + if ( count( $parameters ) === 3 ) { + // New type of filters with custom block + if ( $wgUser->isAnon() ) { + $expiry = $parameters[1]; + } else { + $expiry = $parameters[2]; + } + } else { + // Old type with fixed expiry + if ( $wgUser->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( + $action, + $info['parameters'], + $title, + $vars, + self::getFilter( $filter )->af_public_comments, + $filter + ); + + if ( $newMsg !== null ) { + $messages[] = $newMsg; + } + $actionsTaken[$filter][] = $action; + } + } + + // 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] + ], + $wgUser->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 ); + } + + /** + * Constructs a Status object as returned by executeFilterActions() from the list of + * actions taken and the corresponding list of messages. + * + * @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 + * followed by any message parameters. + * + * @return Status + */ + protected static function buildStatus( array $actionsTaken, array $messages ) { + $status = Status::newGood( $actionsTaken ); + + foreach ( $messages as $msg ) { + call_user_func_array( [ $status, 'fatal' ], $msg ); + } + + return $status; + } + + /** + * @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 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' + ) { + global $wgUser, $wgTitle, $wgRequest, $wgAbuseFilterRuntimeProfile, $wgAbuseFilterLogIP; + + $context = RequestContext::getMain(); + $oldContextTitle = $context->getTitle(); + + $oldWgTitle = $wgTitle; + + if ( !$wgTitle ) { + $wgTitle = SpecialPage::getTitleFor( 'AbuseFilter' ); + } + + if ( !$user ) { + $user = $wgUser; + } + + $logger = LoggerFactory::getInstance( 'StashEdit' ); + $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); + + // Add vars from extensions + Hooks::run( 'AbuseFilter-filterAction', [ &$vars, $title ] ); + $vars->setVar( 'context', 'filter' ); + $vars->setVar( 'timestamp', time() ); + + // Get the stash key based on the relevant "input" variables + $cache = ObjectCache::getLocalClusterInstance(); + $stashKey = self::getStashKey( $cache, $vars, $group ); + $isForEdit = ( $vars->getVar( 'action' )->toString() === 'edit' ); + + if ( $wgAbuseFilterRuntimeProfile ) { + $startTime = microtime( true ); + } + + $filter_matched = false; + if ( $mode === 'execute' && $isForEdit ) { + // Check the filter edit stash results first + $cacheData = $cache->get( $stashKey ); + if ( $cacheData ) { + $filter_matched = $cacheData['matches']; + // Merge in any tags to apply to recent changes entries + self::bufferTagsToSetByAction( $cacheData['tags'] ); + } + } + + if ( is_array( $filter_matched ) ) { + if ( $isForEdit && $mode !== 'stash' ) { + $logger->info( __METHOD__ . ": cache hit for '$title' (key $stashKey)." ); + $statsd->increment( 'abusefilter.check-stash.hit' ); + } + } else { + $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' ); + } + } + + if ( $mode === 'stash' ) { + // Save the filter stash result and do nothing further + $cacheData = [ 'matches' => $filter_matched, 'tags' => self::$tagsToSet ]; + + // Add runtime metrics in cache for later use + if ( $wgAbuseFilterRuntimeProfile ) { + $cacheData['condCount'] = self::$condCount; + $cacheData['runtime'] = ( microtime( true ) - $startTime ) * 1000; + } + + $cache->set( $stashKey, $cacheData, $cache::TTL_MINUTE ); + $logger->debug( __METHOD__ . ": cache store for '$title' (key $stashKey)." ); + $statsd->increment( 'abusefilter.check-stash.store' ); + + return Status::newGood(); + } + + $matched_filters = array_keys( array_filter( $filter_matched ) ); + + // Save runtime metrics only on edits + if ( $wgAbuseFilterRuntimeProfile && $mode === 'execute' && $isForEdit ) { + if ( $cacheData ) { + $runtime = $cacheData['runtime']; + $condCount = $cacheData['condCount']; + } else { + $runtime = ( microtime( true ) - $startTime ) * 1000; + $condCount = self::$condCount; + } + + self::recordRuntimeProfilingResult( count( $matched_filters ), $condCount, $runtime ); + } + + if ( count( $matched_filters ) == 0 ) { + $status = Status::newGood(); + } else { + $status = self::executeFilterActions( $matched_filters, $title, $vars ); + $actions_taken = $status->getValue(); + $action = $vars->getVar( 'ACTION' )->toString(); + + // If $wgUser isn't safe to load (e.g. a failure during + // AbortAutoAccount), create a dummy anonymous user instead. + $user = $user->isSafeToLoad() ? $user : new User; + + // Create a template + $log_template = [ + 'afl_user' => $user->getId(), + 'afl_user_text' => $user->getName(), + 'afl_timestamp' => wfGetDB( DB_REPLICA )->timestamp( wfTimestampNow() ), + 'afl_namespace' => $title->getNamespace(), + 'afl_title' => $title->getDBkey(), + // DB field is not null, so nothing + 'afl_ip' => ( $wgAbuseFilterLogIP ) ? $wgRequest->getIP() : "" + ]; + + // Hack to avoid revealing IPs of people creating accounts + if ( !$user->getId() && ( $action == 'createaccount' || $action == 'autocreateaccount' ) ) { + $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 ); + } + + return $status; + } + + /** + * @param string $id Filter ID (integer or "global-<integer>") + * @return stdClass|null DB row + */ + public static function getFilter( $id ) { + global $wgAbuseFilterCentralDB; + + if ( !isset( self::$filterCache[$id] ) ) { + $globalIndex = self::decodeGlobalName( $id ); + if ( $globalIndex ) { + // Global wiki filter + if ( !$wgAbuseFilterCentralDB ) { + return null; // not enabled + } + + $id = $globalIndex; + $lb = wfGetLB( $wgAbuseFilterCentralDB ); + $dbr = $lb->getConnectionRef( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + } else { + // Local wiki filter + $dbr = wfGetDB( DB_REPLICA ); + } + + $row = $dbr->selectRow( 'abuse_filter', '*', [ 'af_id' => $id ], __METHOD__ ); + self::$filterCache[$id] = $row ?: null; + } + + return self::$filterCache[$id]; + } + + /** + * @param BagOStuff $cache + * @param AbuseFilterVariableHolder $vars + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * + * @return string + */ + private static function getStashKey( + BagOStuff $cache, AbuseFilterVariableHolder $vars, $group + ) { + $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'] ); + ksort( $inputVars ); + $hash = md5( serialize( $inputVars ) ); + + return $cache->makeKey( + 'abusefilter', + 'check-stash', + $group, + $hash, + 'v1' + ); + } + + /** + * @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' + ) { + $dbw = wfGetDB( DB_MASTER ); + + $central_log_template = [ + 'afl_wiki' => wfWikiID(), + ]; + + $log_rows = []; + $central_log_rows = []; + $logged_local_filters = []; + $logged_global_filters = []; + + foreach ( $actions_taken as $filter => $actions ) { + $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'] ); + $centralLog = $thisLog + $central_log_template; + $centralLog['afl_filter'] = $globalIndex; + $centralLog['afl_title'] = $title->getPrefixedText(); + $centralLog['afl_namespace'] = 0; + + $central_log_rows[] = $centralLog; + $logged_global_filters[] = $globalIndex; + } + } + } + + if ( !count( $log_rows ) ) { + return; + } + + // 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 + + $stash = ObjectCache::getMainStashInstance(); + + // Increment trigger counter + $stash->incr( self::filterMatchesKey() ); + + $local_log_ids = []; + global $wgAbuseFilterNotifications, $wgAbuseFilterNotificationsPrivate; + foreach ( $log_rows as $data ) { + $data['afl_var_dump'] = $var_dump; + $data['afl_id'] = $dbw->nextSequenceValue( 'abuse_filter_log_afl_id_seq' ); + $dbw->insert( 'abuse_filter_log', $data, __METHOD__ ); + $local_log_ids[] = $data['afl_id'] = $dbw->insertId(); + // Give grep a chance to find the usages: + // logentry-abusefilter-hit + $entry = new ManualLogEntry( 'abusefilter', 'hit' ); + // Construct a user object + $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( [ + 'action' => $data['afl_action'], + 'filter' => $data['afl_filter'], + 'actions' => $data['afl_actions'], + 'log' => $data['afl_id'], + ] ); + + // Send data to CheckUser if installed and we + // aren't already sending a notification to recentchanges + if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) + && strpos( $wgAbuseFilterNotifications, 'rc' ) === false + ) { + $rc = $entry->getRecentChange(); + CheckUserHooks::updateCheckUserData( $rc ); + } + + if ( $wgAbuseFilterNotifications !== false ) { + if ( self::filterHidden( $data['afl_filter'] ) && !$wgAbuseFilterNotificationsPrivate ) { + continue; + } + $entry->publish( 0, $wgAbuseFilterNotifications ); + } + } + + $method = __METHOD__; + + if ( count( $logged_local_filters ) ) { + // Update hit-counter. + $dbw->onTransactionPreCommitOrIdle( + function () use ( $dbw, $logged_local_filters, $method ) { + $dbw->update( 'abuse_filter', + [ 'af_hit_count=af_hit_count+1' ], + [ 'af_id' => $logged_local_filters ], + $method + ); + } + ); + } + + $global_log_ids = []; + + // Global stuff + if ( count( $logged_global_filters ) ) { + $vars->computeDBVars(); + $global_var_dump = self::storeVarDump( $vars, true ); + $global_var_dump = "stored-text:$global_var_dump"; + foreach ( $central_log_rows as $index => $data ) { + $central_log_rows[$index]['afl_var_dump'] = $global_var_dump; + } + + global $wgAbuseFilterCentralDB; + $fdb = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB ); + + foreach ( $central_log_rows as $row ) { + $fdb->insert( 'abuse_filter_log', $row, __METHOD__ ); + $global_log_ids[] = $dbw->insertId(); + } + + $fdb->onTransactionPreCommitOrIdle( + function () use ( $fdb, $logged_global_filters, $method ) { + $fdb->update( 'abuse_filter', + [ 'af_hit_count=af_hit_count+1' ], + [ 'af_id' => $logged_global_filters ], + $method + ); + } + ); + } + + $vars->setVar( 'global_log_ids', $global_log_ids ); + $vars->setVar( 'local_log_ids', $local_log_ids ); + + // Check for emergency disabling. + $total = $stash->get( self::filterUsedKey( $group ) ); + self::checkEmergencyDisable( $group, $logged_local_filters, $total ); + } + + /** + * Store a var dump to External Storage or the text table + * Some of this code is stolen from Revision::insertOn and friends + * + * @param AbuseFilterVariableHolder $vars + * @param bool $global + * + * @return int|null + */ + public static function storeVarDump( $vars, $global = false ) { + global $wgCompressRevisions; + + // Get all variables yet set and compute old and new wikitext if not yet done + // as those are needed for the diff view on top of the abuse log pages + $vars = $vars->dumpAllVars( [ 'old_wikitext', 'new_wikitext' ] ); + + // Vars is an array with native PHP data types (non-objects) now + $text = serialize( $vars ); + $flags = [ 'nativeDataArray' ]; + + if ( $wgCompressRevisions ) { + if ( function_exists( 'gzdeflate' ) ) { + $text = gzdeflate( $text ); + $flags[] = 'gzip'; + } + } + + // Store to ES if applicable + global $wgDefaultExternalStore, $wgAbuseFilterCentralDB; + if ( $wgDefaultExternalStore ) { + if ( $global ) { + $text = ExternalStore::insertToForeignDefault( $text, $wgAbuseFilterCentralDB ); + } else { + $text = ExternalStore::insertToDefault( $text ); + } + $flags[] = 'external'; + + if ( !$text ) { + // Not mission-critical, just return nothing + return null; + } + } + + // Store to text table + if ( $global ) { + $dbw = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB ); + } else { + $dbw = wfGetDB( DB_MASTER ); + } + $old_id = $dbw->nextSequenceValue( 'text_old_id_seq' ); + $dbw->insert( 'text', + [ + 'old_id' => $old_id, + 'old_text' => $text, + 'old_flags' => implode( ',', $flags ), + ], __METHOD__ + ); + + return $dbw->insertId(); + } + + /** + * Retrieve a var dump from External Storage or the text table + * Some of this code is stolen from Revision::loadText et al + * + * @param string $stored_dump + * + * @return object|AbuseFilterVariableHolder|bool + */ + public static function loadVarDump( $stored_dump ) { + // Back-compat + if ( substr( $stored_dump, 0, strlen( 'stored-text:' ) ) !== 'stored-text:' ) { + $data = unserialize( $stored_dump ); + if ( is_array( $data ) ) { + $vh = new AbuseFilterVariableHolder; + foreach ( $data as $name => $value ) { + $vh->setVar( $name, $value ); + } + + return $vh; + } else { + return $data; + } + } + + $text_id = substr( $stored_dump, strlen( 'stored-text:' ) ); + + $dbr = wfGetDB( DB_REPLICA ); + + $text_row = $dbr->selectRow( + 'text', + [ 'old_text', 'old_flags' ], + [ 'old_id' => $text_id ], + __METHOD__ + ); + + if ( !$text_row ) { + return new AbuseFilterVariableHolder; + } + + $flags = explode( ',', $text_row->old_flags ); + $text = $text_row->old_text; + + if ( in_array( 'external', $flags ) ) { + $text = ExternalStore::fetchFromURL( $text ); + } + + if ( in_array( 'gzip', $flags ) ) { + $text = gzinflate( $text ); + } + + $obj = unserialize( $text ); + + if ( in_array( 'nativeDataArray', $flags ) ) { + $vars = $obj; + $obj = new AbuseFilterVariableHolder(); + foreach ( $vars as $key => $value ) { + $obj->setVar( $key, $value ); + } + } + + return $obj; + } + + /** + * @param string $action + * @param array $parameters + * @param Title $title + * @param AbuseFilterVariableHolder $vars + * @param string $rule_desc + * @param int|string $rule_number + * + * @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. + */ + public static function takeConsequenceAction( $action, $parameters, $title, + $vars, $rule_desc, $rule_number ) { + global $wgAbuseFilterCustomActionsHandlers, $wgRequest; + + $message = null; + + switch ( $action ) { + case 'disallow': + if ( !empty( $parameters[0] ) && strlen( $parameters[0] ) ) { + $message = [ $parameters[0], $rule_desc, $rule_number ]; + } else { + // Generic message. + $message = [ + 'abusefilter-disallowed', + $rule_desc, + $rule_number + ]; + } + break; + case 'rangeblock': + global $wgAbuseFilterRangeBlockSize, $wgBlockCIDRLimit; + + $ip = $wgRequest->getIP(); + if ( IP::isIPv6( $ip ) ) { + $CIDRsize = max( $wgAbuseFilterRangeBlockSize['IPv6'], $wgBlockCIDRLimit['IPv6'] ); + } else { + $CIDRsize = max( $wgAbuseFilterRangeBlockSize['IPv4'], $wgBlockCIDRLimit['IPv4'] ); + } + $blockCIDR = $ip . '/' . $CIDRsize; + self::doAbuseFilterBlock( + [ + 'desc' => $rule_desc, + 'number' => $rule_number + ], + IP::sanitizeRange( $blockCIDR ), + '1 week', + false + ); + + $message = [ + 'abusefilter-blocked-display', + $rule_desc, + $rule_number + ]; + break; + case 'degroup': + global $wgUser; + if ( !$wgUser->isAnon() ) { + // Remove all groups from the user. Ouch. + $groups = $wgUser->getGroups(); + + foreach ( $groups as $group ) { + $wgUser->removeGroup( $group ); + } + + $message = [ + 'abusefilter-degrouped', + $rule_desc, + $rule_number + ]; + + // Don't log it if there aren't any groups being removed! + if ( !count( $groups ) ) { + break; + } + + // Log it. + $log = new LogPage( 'rights' ); + + $log->addEntry( 'rights', + $wgUser->getUserPage(), + wfMessage( + 'abusefilter-degroupreason', + $rule_desc, + $rule_number + )->inContentLanguage()->text(), + [ + implode( ', ', $groups ), + '' + ], + self::getFilterUser() + ); + } + + break; + case 'blockautopromote': + global $wgUser; + if ( !$wgUser->isAnon() ) { + $blockPeriod = (int)mt_rand( 3 * 86400, 7 * 86400 ); // Block for 3-7 days. + ObjectCache::getMainStashInstance()->set( + self::autoPromoteBlockKey( $wgUser ), true, $blockPeriod + ); + + $message = [ + 'abusefilter-autopromote-blocked', + $rule_desc, + $rule_number + ]; + } + 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(), + $vars->getVar( 'ACTION' )->toString() + ] ); + + self::bufferTagsToSetByAction( [ $actionID => $parameters ] ); + break; + default: + if ( isset( $wgAbuseFilterCustomActionsHandlers[$action] ) ) { + $custom_function = $wgAbuseFilterCustomActionsHandlers[$action]; + if ( is_callable( $custom_function ) ) { + $msg = call_user_func( + $custom_function, + $action, + $parameters, + $title, + $vars, + $rule_desc, + $rule_number + ); + } + if ( isset( $msg ) ) { + $message = [ $msg ]; + } + } else { + wfDebugLog( 'AbuseFilter', "Unrecognised action $action" ); + } + } + + return $message; + } + + /** + * @param array[] $tagsByAction Map of (integer => string[]) + */ + private static function bufferTagsToSetByAction( array $tagsByAction ) { + 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 ); + } + } + } + } + + /** + * Perform a block by the AbuseFilter system user + * @param array $rule should have 'desc' and 'number' + * @param string $target + * @param string $expiry + * @param bool $isAutoBlock + * @param bool $preventEditOwnUserTalk + */ + protected static function doAbuseFilterBlock( + array $rule, + $target, + $expiry, + $isAutoBlock, + $preventEditOwnUserTalk = false + ) { + $filterUser = self::getFilterUser(); + $reason = wfMessage( + 'abusefilter-blockreason', + $rule['desc'], $rule['number'] + )->inContentLanguage()->text(); + + $block = new Block(); + $block->setTarget( $target ); + $block->setBlocker( $filterUser ); + $block->mReason = $reason; + $block->isHardblock( false ); + $block->isAutoblocking( $isAutoBlock ); + $block->prevents( 'createaccount', true ); + $block->prevents( 'editownusertalk', $preventEditOwnUserTalk ); + $block->mExpiry = SpecialBlock::parseExpiryInput( $expiry ); + + $success = $block->insert(); + + if ( $success ) { + // Log it only if the block was successful + $logParams = []; + $logParams['5::duration'] = ( $block->mExpiry === 'infinity' ) + ? 'indefinite' + : $expiry; + $flags = [ 'nocreate' ]; + if ( !$block->isAutoblocking() && !IP::isIPAddress( $target ) ) { + // Conditionally added same as SpecialBlock + $flags[] = 'noautoblock'; + } + if ( $preventEditOwnUserTalk === true ) { + $flags[] = 'nousertalk'; + } + $logParams['6::flags'] = implode( ',', $flags ); + + $logEntry = new ManualLogEntry( 'block', 'block' ); + $logEntry->setTarget( Title::makeTitle( NS_USER, $target ) ); + $logEntry->setComment( $reason ); + $logEntry->setPerformer( $filterUser ); + $logEntry->setParameters( $logParams ); + $blockIds = array_merge( [ $success['id'] ], $success['autoIds'] ); + $logEntry->setRelations( [ 'ipb_id' => $blockIds ] ); + $logEntry->publish( $logEntry->insert() ); + } + } + + /** + * @param string $throttleId + * @param array $types + * @param Title $title + * @param string $rateCount + * @param string $ratePeriod + * @param bool $global + * @return bool + */ + public static function isThrottled( $throttleId, $types, $title, $rateCount, + $ratePeriod, $global = false + ) { + $stash = ObjectCache::getMainStashInstance(); + $key = self::throttleKey( $throttleId, $types, $title, $global ); + $count = intval( $stash->get( $key ) ); + + wfDebugLog( 'AbuseFilter', "Got value $count for throttle key $key\n" ); + + if ( $count > 0 ) { + $stash->incr( $key ); + $count++; + wfDebugLog( 'AbuseFilter', "Incremented throttle key $key" ); + } else { + wfDebugLog( 'AbuseFilter', "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." ); + + return true; // THROTTLED + } + + wfDebugLog( 'AbuseFilter', "Throttle $key not hit!" ); + + return false; // NOT THROTTLED + } + + /** + * @param string $type + * @param Title $title + * @return int|string + */ + public static function throttleIdentifier( $type, $title ) { + global $wgUser, $wgRequest; + + switch ( $type ) { + case 'ip': + $identifier = $wgRequest->getIP(); + break; + case 'user': + $identifier = $wgUser->getId(); + break; + case 'range': + $identifier = substr( IP::toHex( $wgRequest->getIP() ), 0, 4 ); + break; + case 'creationdate': + $reg = $wgUser->getRegistration(); + $identifier = $reg - ( $reg % 86400 ); + break; + case 'editcount': + // Hack for detecting different single-purpose accounts. + $identifier = $wgUser->getEditCount(); + break; + case 'site': + $identifier = 1; + break; + case 'page': + $identifier = $title->getPrefixedText(); + break; + default: + $identifier = 0; + } + + return $identifier; + } + + /** + * @param string $throttleId + * @param string $type + * @param Title $title + * @param bool $global + * @return string + */ + public static function throttleKey( $throttleId, $type, $title, $global = false ) { + $types = explode( ',', $type ); + + $identifiers = []; + + foreach ( $types as $subtype ) { + $identifiers[] = self::throttleIdentifier( $subtype, $title ); + } + + $identifier = sha1( implode( ':', $identifiers ) ); + + global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB; + + if ( $global && !$wgAbuseFilterIsCentral ) { + list( $globalSite, $globalPrefix ) = wfSplitWikiID( $wgAbuseFilterCentralDB ); + + return wfForeignMemcKey( + $globalSite, $globalPrefix, + 'abusefilter', 'throttle', $throttleId, $type, $identifier ); + } + + return wfMemcKey( 'abusefilter', 'throttle', $throttleId, $type, $identifier ); + } + + /** + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @return string + */ + public static function getGlobalRulesKey( $group ) { + global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB; + + if ( !$wgAbuseFilterIsCentral ) { + list( $globalSite, $globalPrefix ) = wfSplitWikiID( $wgAbuseFilterCentralDB ); + + return wfForeignMemcKey( + $globalSite, $globalPrefix, + 'abusefilter', 'rules', $group + ); + } + + return wfMemcKey( 'abusefilter', 'rules', $group ); + } + + /** + * @param User $user + * @return string + */ + public static function autoPromoteBlockKey( $user ) { + return wfMemcKey( 'abusefilter', 'block-autopromote', $user->getId() ); + } + + /** + * Update statistics, and disable filters which are over-blocking. + * @param bool[] $filters + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + */ + public static function recordStats( $filters, $group = 'default' ) { + 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 ); + + $total = $stash->get( $total_key ); + + $storage_period = self::$statsStoragePeriod; + + 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 ); + $stash->set( $overflow_key, 0, $storage_period ); + + foreach ( $filters as $filter => $matched ) { + $stash->set( self::filterMatchesKey( $filter ), 0, $storage_period ); + } + $stash->set( self::filterMatchesKey(), 0, $storage_period ); + } + + // Increment total + $stash->incr( $total_key ); + + // Increment overflow counter, if our condition limit overflowed + if ( $overflow_triggered ) { + $stash->incr( $overflow_key ); + } + } + + /** + * Record runtime profiling data + * + * @param int $totalFilters + * @param int $totalConditions + * @param float $runtime + */ + private static function recordRuntimeProfilingResult( $totalFilters, $totalConditions, $runtime ) { + $keyPrefix = 'abusefilter.runtime-profile.' . wfWikiID() . '.'; + + $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $statsd->timing( $keyPrefix . 'runtime', $runtime ); + $statsd->timing( $keyPrefix . 'total_filters', $totalFilters ); + $statsd->timing( $keyPrefix . 'total_conditions', $totalConditions ); + } + + /** + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @param string[] $filters + * @param int $total + */ + public static function checkEmergencyDisable( $group, $filters, $total ) { + global $wgAbuseFilterEmergencyDisableThreshold, $wgAbuseFilterEmergencyDisableCount, + $wgAbuseFilterEmergencyDisableAge; + + $stash = ObjectCache::getMainStashInstance(); + foreach ( $filters as $filter ) { + // determine emergency disable values for this action + $emergencyDisableThreshold = + self::getEmergencyValue( $wgAbuseFilterEmergencyDisableThreshold, $group ); + $filterEmergencyDisableCount = + self::getEmergencyValue( $wgAbuseFilterEmergencyDisableCount, $group ); + $emergencyDisableAge = + self::getEmergencyValue( $wgAbuseFilterEmergencyDisableAge, $group ); + + // Increment counter + $matchCount = $stash->get( self::filterMatchesKey( $filter ) ); + + // Handle missing keys... + if ( !$matchCount ) { + $stash->set( self::filterMatchesKey( $filter ), 1, self::$statsStoragePeriod ); + } else { + $stash->incr( self::filterMatchesKey( $filter ) ); + } + $matchCount++; + + // Figure out if the filter is subject to being deleted. + $filter_age = wfTimestamp( TS_UNIX, self::getFilter( $filter )->af_timestamp ); + $throttle_exempt_time = $filter_age + $emergencyDisableAge; + + if ( $total && $throttle_exempt_time > time() + && $matchCount > $filterEmergencyDisableCount + && ( $matchCount / $total ) > $emergencyDisableThreshold + ) { + // More than $wgAbuseFilterEmergencyDisableCount matches, + // constituting more than $emergencyDisableThreshold + // (a fraction) of last few edits. Disable it. + DeferredUpdates::addUpdate( + new AutoCommitUpdate( + wfGetDB( DB_MASTER ), + __METHOD__, + function ( IDatabase $dbw, $fname ) use ( $filter ) { + $dbw->update( 'abuse_filter', + [ 'af_throttled' => 1 ], + [ 'af_id' => $filter ], + $fname + ); + } + ) + ); + } + } + } + + /** + * @param array $emergencyValue + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @return mixed + */ + public static function getEmergencyValue( array $emergencyValue, $group ) { + return isset( $emergencyValue[$group] ) ? $emergencyValue[$group] : $emergencyValue['default']; + } + + /** + * @return string + */ + public static function filterLimitReachedKey() { + return wfMemcKey( 'abusefilter', 'stats', 'overflow' ); + } + + /** + * @param string|null $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @return string + */ + public static function filterUsedKey( $group = null ) { + return wfMemcKey( 'abusefilter', 'stats', 'total', $group ); + } + + /** + * @param string|null $filter + * @return string + */ + public static function filterMatchesKey( $filter = null ) { + return wfMemcKey( 'abusefilter', 'stats', 'matches', $filter ); + } + + /** + * @return User + */ + public static function getFilterUser() { + $username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text(); + $user = User::newSystemUser( $username, [ 'steal' => true ] ); + + // Promote user to 'sysop' so it doesn't look + // like an unprivileged account is blocking users + if ( !in_array( 'sysop', $user->getGroups() ) ) { + $user->addGroup( 'sysop' ); + } + + return $user; + } + + /** + * Extract values for syntax highlight + * + * @param bool $canEdit + * @return array + */ + public static function getAceConfig( $canEdit ) { + $values = self::getBuilderValues(); + $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 ); + + return [ + 'variables' => $builderVariables, + 'functions' => $builderFunctions, + 'keywords' => $builderKeywords, + 'aceReadOnly' => !$canEdit + ]; + } + + /** + * @param string $rules + * @param string $textName + * @param bool $addResultDiv + * @param bool $canEdit + * @param bool $externalForm + * @return string + */ + static function buildEditBox( $rules, $textName = 'wpFilterRules', $addResultDiv = true, + $canEdit = true, $externalForm = false ) { + global $wgOut; + + $wgOut->enableOOUI(); + $editorAttrib = [ 'dir' => 'ltr' ]; # Rules are in English + + global $wgUser; + $noTestAttrib = []; + if ( !$wgUser->isAllowed( 'abusefilter-modify' ) ) { + $noTestAttrib['disabled'] = 'disabled'; + $addResultDiv = false; + } + + $rules = rtrim( $rules ) . "\n"; + + if ( ExtensionRegistry::getInstance()->isLoaded( 'CodeEditor' ) ) { + $editorAttrib['name'] = 'wpAceFilterEditor'; + $editorAttrib['id'] = 'wpAceFilterEditor'; + $editorAttrib['class'] = 'mw-abusefilter-editor'; + + $switchEditor = + new OOUI\ButtonWidget( + [ + 'label' => wfMessage( '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 ( $externalForm ) { + $textareaAttribs['form'] = 'wpFilterForm'; + } + $rulesContainer .= Xml::textarea( $textName, $rules, 40, 15, $textareaAttribs ); + + $editorConfig = self::getAceConfig( $canEdit ); + + // Add Ace configuration variable + $wgOut->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 + $dropDown = self::getBuilderValues(); + + // The array needs to be rearranged to be understood by OOUI + 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 + $localisedLabel = wfMessage( "abusefilter-edit-builder-group-$group" )->text(); + $dropDown[ $localisedLabel ] = $dropDown[ $group ]; + unset( $dropDown[ $group ] ); + $dropDown[ $localisedLabel ] = array_flip( $dropDown[ $localisedLabel ] ); + foreach ( $values as $content => $name ) { + $localisedInnerLabel = wfMessage( "abusefilter-edit-builder-$group-$name" )->text(); + $dropDown[ $localisedLabel ][ $localisedInnerLabel ] = $dropDown[ $localisedLabel ][ $name ]; + unset( $dropDown[ $localisedLabel ][ $name ] ); + } + } + + $dropDown = [ wfMessage( 'abusefilter-edit-builder-select' )->text() => 'other' ] + $dropDown; + $dropDown = Xml::listDropDownOptionsOoui( $dropDown ); + $dropDown = new OOUI\DropdownInputWidget( [ + 'name' => 'wpFilterBuilder', + 'inputId' => 'wpFilterBuilder', + 'options' => $dropDown + ] ); + + $dropDown = new OOUI\FieldLayout( $dropDown ); + $formElements = [ $dropDown ]; + + // Button for syntax check + $syntaxCheck = + new OOUI\ButtonWidget( + [ + 'label' => wfMessage( 'abusefilter-edit-check' )->text(), + 'id' => 'mw-abusefilter-syntaxcheck' + ] + $noTestAttrib + ); + $group = $syntaxCheck; + + // Button for switching editor (if Ace is used) + if ( isset( $switchEditor ) ) { + $group = + new OOUI\Widget( [ + 'content' => new OOUI\HorizontalLayout( [ + 'items' => [ $switchEditor, $syntaxCheck ] + ] ) + ] ); + } + $group = new OOUI\FieldLayout( $group ); + $formElements[] = $group; + + $fieldSet = new OOUI\FieldsetLayout( [ + 'items' => $formElements, + 'classes' => [ 'mw-abusefilter-edit-buttons' ] + ] ); + + $rulesContainer .= $fieldSet; + } + + if ( $addResultDiv ) { + $rulesContainer .= Xml::element( 'div', + [ 'id' => 'mw-abusefilter-syntaxresult', 'style' => 'display: none;' ], + ' ' ); + } + + // Add script + $wgOut->addModules( 'ext.abuseFilter.edit' ); + self::$editboxName = $textName; + + return $rulesContainer; + } + + /** + * Build input and button for loading a filter + * + * @return string + */ + static function buildFilterLoader() { + $loadText = + new OOUI\TextInputWidget( + [ + 'type' => 'number', + 'name' => 'wpInsertFilter', + 'id' => 'mw-abusefilter-load-filter' + ] + ); + $loadButton = + new OOUI\ButtonInputWidget( + [ + '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; + } + + /** + * Each version is expected to be an array( $row, $actions ) + * Returns an array of fields that are different. + * + * @param array $version_1 + * @param array $version_2 + * + * @return array + */ + static function compareVersions( $version_1, $version_2 ) { + $compareFields = [ + 'af_public_comments', + 'af_pattern', + 'af_comments', + 'af_deleted', + 'af_enabled', + 'af_hidden', + 'af_global', + 'af_group', + ]; + $differences = []; + + list( $row1, $actions1 ) = $version_1; + list( $row2, $actions2 ) = $version_2; + + foreach ( $compareFields as $field ) { + if ( !isset( $row2->$field ) || $row1->$field != $row2->$field ) { + $differences[] = $field; + } + } + + global $wgAbuseFilterActions; + foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { + if ( !isset( $actions1[$action] ) && !isset( $actions2[$action] ) ) { + // They're both unset + } elseif ( isset( $actions1[$action] ) && isset( $actions2[$action] ) ) { + // They're both set. + // Double check needed, e.g. per T180194 + if ( array_diff( $actions1[$action]['parameters'], + $actions2[$action]['parameters'] ) || + array_diff( $actions2[$action]['parameters'], + $actions1[$action]['parameters'] ) ) { + // Different parameters + $differences[] = 'actions'; + } + } else { + // One's unset, one's set. + $differences[] = 'actions'; + } + } + + return array_unique( $differences ); + } + + /** + * @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! + $af_row = new stdClass; + + foreach ( self::$history_mappings as $af_col => $afh_col ) { + $af_row->$af_col = $row->$afh_col; + } + + # 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; + } + + # Process actions + $actions_raw = unserialize( $row->afh_actions ); + $actions_output = []; + if ( is_array( $actions_raw ) ) { + foreach ( $actions_raw as $action => $parameters ) { + $actions_output[$action] = [ + 'action' => $action, + 'parameters' => $parameters + ]; + } + } + + return [ $af_row, $actions_output ]; + } + + /** + * @param string $action + * @return string + */ + 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" )->escaped(); + $display = wfMessage( "abusefilter-action-$action", $display )->isDisabled() ? $action : $display; + + return $display; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder|null + */ + public static function getVarsFromRCRow( $row ) { + if ( $row->rc_log_type == 'move' ) { + $vars = self::getMoveVarsFromRCRow( $row ); + } elseif ( $row->rc_log_type == 'newusers' ) { + $vars = self::getCreateVarsFromRCRow( $row ); + } elseif ( $row->rc_log_type == 'delete' ) { + $vars = self::getDeleteVarsFromRCRow( $row ); + } elseif ( $row->rc_this_oldid ) { + // It's an edit. + $vars = self::getEditVarsFromRCRow( $row ); + } else { + return null; + } + if ( $vars ) { + $vars->setVar( 'context', 'generated' ); + $vars->setVar( 'timestamp', wfTimestamp( TS_UNIX, $row->rc_timestamp ) ); + } + + return $vars; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder + */ + public static function getCreateVarsFromRCRow( $row ) { + $vars = new AbuseFilterVariableHolder; + + $vars->setVar( 'ACTION', ( $row->rc_log_action == 'autocreate' ) ? + 'autocreateaccount' : + 'createaccount' ); + + $name = Title::makeTitle( $row->rc_namespace, $row->rc_title )->getText(); + // Add user data if the account was created by a registered user + if ( $row->rc_user && $name != $row->rc_user_text ) { + $user = User::newFromName( $row->rc_user_text ); + $vars->addHolders( self::generateUserVars( $user ) ); + } + + $vars->setVar( 'accountname', $name ); + + return $vars; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder + */ + public static function getDeleteVarsFromRCRow( $row ) { + $vars = new AbuseFilterVariableHolder; + $title = Title::makeTitle( $row->rc_namespace, $row->rc_title ); + + if ( $row->rc_user ) { + $user = User::newFromName( $row->rc_user_text ); + } else { + $user = new User; + $user->setName( $row->rc_user_text ); + } + + $vars->addHolders( + self::generateUserVars( $user ), + self::generateTitleVars( $title, 'ARTICLE' ) + ); + + $vars->setVar( 'ACTION', 'delete' ); + $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text ); + + return $vars; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder + */ + public static function getEditVarsFromRCRow( $row ) { + $vars = new AbuseFilterVariableHolder; + $title = Title::makeTitle( $row->rc_namespace, $row->rc_title ); + + if ( $row->rc_user ) { + $user = User::newFromName( $row->rc_user_text ); + } else { + $user = new User; + $user->setName( $row->rc_user_text ); + } + + $vars->addHolders( + self::generateUserVars( $user ), + self::generateTitleVars( $title, 'ARTICLE' ) + ); + + $vars->setVar( 'ACTION', 'edit' ); + $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text ); + + $vars->setLazyLoadVar( 'new_wikitext', 'revision-text-by-id', + [ 'revid' => $row->rc_this_oldid ] ); + + if ( $row->rc_last_oldid ) { + $vars->setLazyLoadVar( 'old_wikitext', 'revision-text-by-id', + [ 'revid' => $row->rc_last_oldid ] ); + } else { + $vars->setVar( 'old_wikitext', '' ); + } + + $vars->addHolders( self::getEditVars( $title ) ); + + return $vars; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder + */ + public static function getMoveVarsFromRCRow( $row ) { + if ( $row->rc_user ) { + $user = User::newFromId( $row->rc_user ); + } else { + $user = new User; + $user->setName( $row->rc_user_text ); + } + + $params = array_values( DatabaseLogEntry::newFromRow( $row )->getParameters() ); + + $oldTitle = Title::makeTitle( $row->rc_namespace, $row->rc_title ); + $newTitle = Title::newFromText( $params[0] ); + + $vars = AbuseFilterVariableHolder::merge( + self::generateUserVars( $user ), + self::generateTitleVars( $oldTitle, 'MOVED_FROM' ), + self::generateTitleVars( $newTitle, 'MOVED_TO' ) + ); + + $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text ); + $vars->setVar( 'ACTION', 'move' ); + + return $vars; + } + + /** + * @param Title $title + * @param Page|null $page + * @return AbuseFilterVariableHolder + */ + public static function getEditVars( $title, Page $page = null ) { + $vars = new AbuseFilterVariableHolder; + + // NOTE: $page may end up remaining null, e.g. if $title points to a special page. + if ( !$page && $title instanceof Title && $title->canExist() ) { + $page = WikiPage::factory( $title ); + } + + $vars->setLazyLoadVar( 'edit_diff', 'diff', + [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_wikitext' ] ); + $vars->setLazyLoadVar( 'edit_diff_pst', 'diff', + [ '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-int', + [ 'val1-var' => 'new_size', 'val2-var' => 'old_size' ] ); + + // Some more specific/useful details about the changes. + $vars->setLazyLoadVar( 'added_lines', 'diff-split', + [ 'diff-var' => 'edit_diff', 'line-prefix' => '+' ] ); + $vars->setLazyLoadVar( 'removed_lines', 'diff-split', + [ 'diff-var' => 'edit_diff', 'line-prefix' => '-' ] ); + $vars->setLazyLoadVar( 'added_lines_pst', 'diff-split', + [ 'diff-var' => 'edit_diff_pst', 'line-prefix' => '+' ] ); + + // Links + $vars->setLazyLoadVar( 'added_links', 'link-diff-added', + [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] ); + $vars->setLazyLoadVar( 'removed_links', 'link-diff-removed', + [ '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', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'text-var' => 'new_wikitext', + 'article' => $page + ] ); + $vars->setLazyLoadVar( 'old_links', 'links-from-wikitext-or-database', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'text-var' => 'old_wikitext' + ] ); + $vars->setLazyLoadVar( 'new_pst', 'parse-wikitext', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'wikitext-var' => 'new_wikitext', + 'article' => $page, + 'pst' => true, + ] ); + $vars->setLazyLoadVar( 'new_html', 'parse-wikitext', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + '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; + } + + /** + * @param AbuseFilterVariableHolder|array $vars + * @param IContextSource $context + * @return string + */ + public static function buildVarDumpTable( $vars, IContextSource $context ) { + // Export all values + if ( $vars instanceof AbuseFilterVariableHolder ) { + $vars = $vars->exportAllVars(); + } + + $output = ''; + + // I don't want to change the names of the pre-existing messages + // describing the variables, nor do I want to rewrite them, so I'm just + // mapping the variable names to builder messages with a pre-existing array. + $variableMessageMappings = self::getBuilderValues(); + $variableMessageMappings = $variableMessageMappings['vars']; + + $output .= + Xml::openElement( 'table', [ 'class' => 'mw-abuselog-details' ] ) . + Xml::openElement( 'tbody' ) . + "\n"; + + $header = + Xml::element( 'th', null, $context->msg( 'abusefilter-log-details-var' )->text() ) . + Xml::element( 'th', null, $context->msg( 'abusefilter-log-details-val' )->text() ); + $output .= Xml::tags( 'tr', null, $header ) . "\n"; + + if ( !count( $vars ) ) { + $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); + + return $output; + } + + // Now, build the body of the table. + foreach ( $vars as $key => $value ) { + $key = strtolower( $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() ); + } else { + $keyDisplay = Xml::element( 'code', null, $key ); + } + + if ( is_null( $value ) ) { + $value = ''; + } + $value = Xml::element( 'div', [ 'class' => 'mw-abuselog-var-value' ], $value, false ); + + $trow = + Xml::tags( 'td', [ 'class' => 'mw-abuselog-var' ], $keyDisplay ) . + Xml::tags( 'td', [ 'class' => 'mw-abuselog-var-value' ], $value ); + $output .= + Xml::tags( 'tr', + [ 'class' => "mw-abuselog-details-$key mw-abuselog-value" ], $trow + ) . "\n"; + } + + $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); + + return $output; + } + + /** + * @param string $action + * @param string[] $parameters + * @return string + */ + static function formatAction( $action, $parameters ) { + /** @var $wgLang Language */ + global $wgLang; + if ( count( $parameters ) === 0 || + ( $action === 'block' && count( $parameters ) !== 3 ) ) { + $displayAction = self::getActionDisplay( $action ); + } else { + if ( $action === 'block' ) { + // Needs to be treated separately since the message is more complex + $displayAction = self::getActionDisplay( 'block' ) . + ' ' . + wfMessage( 'abusefilter-block-anon' ) . + wfMessage( 'colon-separator' )->escaped() . + $wgLang->translateBlockExpiry( $parameters[1] ) . + wfMessage( 'comma-separator' )->escaped() . + $wgLang->lcfirst( self::getActionDisplay( 'block' ) ) . + ' ' . + wfMessage( 'abusefilter-block-user' ) . + wfMessage( 'colon-separator' )->escaped() . + $wgLang->translateBlockExpiry( $parameters[2] ); + } else { + $displayAction = self::getActionDisplay( $action ) . + wfMessage( 'colon-separator' )->escaped() . + htmlspecialchars( $wgLang->semicolonList( $parameters ) ); + } + } + + return $displayAction; + } + + /** + * @param string $value + * @return string + */ + 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" )->escaped(); + } + + return $wgLang->commaList( $flags_display ); + } + + /** + * @param string $filterID + * @return string + */ + static function getGlobalFilterDescription( $filterID ) { + global $wgAbuseFilterCentralDB; + + if ( !$wgAbuseFilterCentralDB ) { + return ''; + } + + static $cache = []; + if ( isset( $cache[$filterID] ) ) { + return $cache[$filterID]; + } + + $fdb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + + $cache[$filterID] = $fdb->selectField( + 'abuse_filter', + 'af_public_comments', + [ 'af_id' => $filterID ], + __METHOD__ + ); + + return $cache[$filterID]; + } + + /** + * Gives either the user-specified name for a group, + * or spits the input back out + * @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 ) { + // Give grep a chance to find the usages: abusefilter-group-default + $msg = "abusefilter-group-$group"; + + return wfMessage( $msg )->exists() ? wfMessage( $msg )->escaped() : $group; + } + + /** + * Look up some text of a revision from its revision id + * + * Note that this is really *some* text, we do not make *any* guarantee + * that this text will be even close to what the user actually sees, or + * that the form is fit for any intended purpose. + * + * 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 int $audience one of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @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 ) { + if ( !$revision instanceof Revision ) { + return ''; + } + + $content = $revision->getContent( $audience ); + if ( $content === null ) { + return ''; + } + $result = self::contentToString( $content ); + + return $result; + } + + /** + * Converts the given Content object to a string. + * + * This uses Content::getNativeData() if $content is an instance of TextContent, + * or Content::getTextForSearchIndex() otherwise. + * + * The hook 'AbuseFilter::contentToString' can be used to override this + * behavior. + * + * @param Content $content + * + * @return string a suitable string representation of the content. + */ + static function contentToString( Content $content ) { + $text = null; + + if ( Hooks::run( 'AbuseFilter-contentToString', [ $content, &$text ] ) ) { + $text = $content instanceof TextContent + ? $content->getNativeData() + : $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 = ''; + } + + return $text; + } + + /** + * Get the history ID of the first change to a given filter + * + * @param int $filterID Filter id + * @return int + */ + public static function getFirstFilterChange( $filterID ) { + static $firstChanges = []; + + if ( !isset( $firstChanges[$filterID] ) ) { + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'abuse_filter_history', + 'afh_id', + [ + 'afh_filter' => $filterID, + ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp ASC' ] + ); + $firstChanges[$filterID] = $row->afh_id; + } + + return $firstChanges[$filterID]; + } +} |