diff options
author | Brian Evans <grknight@gentoo.org> | 2020-10-02 14:32:39 -0400 |
---|---|---|
committer | Brian Evans <grknight@gentoo.org> | 2020-10-02 14:32:39 -0400 |
commit | 1f029fca0e032ee20673003d136f8603984b0841 (patch) | |
tree | 50d3a1748543abec2e7bbc3d94a7290cb57a78d2 /AbuseFilter/includes | |
parent | Update Echo to 1.35 (diff) | |
download | extensions-1f029fca0e032ee20673003d136f8603984b0841.tar.gz extensions-1f029fca0e032ee20673003d136f8603984b0841.tar.bz2 extensions-1f029fca0e032ee20673003d136f8603984b0841.zip |
Update AbuseFilter to 1.35
Signed-off-by: Brian Evans <grknight@gentoo.org>
Diffstat (limited to 'AbuseFilter/includes')
64 files changed, 8359 insertions, 4855 deletions
diff --git a/AbuseFilter/includes/AFComputedVariable.php b/AbuseFilter/includes/AFComputedVariable.php index 15847598..4a3a5c55 100644 --- a/AbuseFilter/includes/AFComputedVariable.php +++ b/AbuseFilter/includes/AFComputedVariable.php @@ -1,14 +1,32 @@ <?php -use Wikimedia\Rdbms\Database; -use MediaWiki\MediaWikiServices; +use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; +use Wikimedia\IPUtils; +use Wikimedia\Rdbms\Database; class AFComputedVariable { - public $mMethod, $mParameters; + /** + * @var string The method used to compute the variable + */ + public $mMethod; + /** + * @var array Parameters to be used with the specified method + */ + public $mParameters; + /** + * @var User[] Cache containing User objects already constructed + */ public static $userCache = []; + /** + * @var WikiPage[] Cache containing Page objects already constructed + */ public static $articleCache = []; + /** @var float The amount of time to subtract from profiling */ + public static $profilingExtraTime = 0; + /** * @param string $method * @param array $parameters @@ -23,11 +41,12 @@ class AFComputedVariable { * * * @param string $wikitext - * @param Article $article + * @param WikiPage $article + * @param User $user Context user * * @return object */ - public function parseNonEditWikitext( $wikitext, $article ) { + public function parseNonEditWikitext( $wikitext, WikiPage $article, User $user ) { static $cache = []; $cacheKey = md5( $wikitext ) . ':' . $article->getTitle()->getPrefixedText(); @@ -36,11 +55,10 @@ class AFComputedVariable { return $cache[$cacheKey]; } - global $wgParser; $edit = (object)[]; - $options = new ParserOptions; - $options->setTidy( true ); - $edit->output = $wgParser->parse( $wikitext, $article->getTitle(), $options ); + $options = ParserOptions::newFromUser( $user ); + $parser = MediaWikiServices::getInstance()->getParser(); + $edit->output = $parser->parse( $wikitext, $article->getTitle(), $options ); $cache[$cacheKey] = $edit; return $edit; @@ -72,30 +90,25 @@ class AFComputedVariable { } if ( $user instanceof User ) { - self::$userCache[$username] = $user; - return $user; - } - - if ( IP::isIPAddress( $username ) ) { - $u = new User; - $u->setName( $username ); - self::$userCache[$username] = $u; - return $u; + $ret = $user; + } elseif ( IPUtils::isIPAddress( $username ) ) { + $ret = new User; + $ret->setName( $username ); + } else { + $ret = User::newFromName( $username ); + $ret->load(); } + self::$userCache[$username] = $ret; - $user = User::newFromName( $username ); - $user->load(); - self::$userCache[$username] = $user; - - return $user; + return $ret; } /** * @param int $namespace * @param string $title - * @return Article + * @return WikiPage */ - public static function articleFromTitle( $namespace, $title ) { + public function pageFromTitle( $namespace, $title ) { if ( isset( self::$articleCache["$namespace:$title"] ) ) { return self::$articleCache["$namespace:$title"]; } @@ -105,20 +118,30 @@ class AFComputedVariable { } $logger = LoggerFactory::getInstance( 'AbuseFilter' ); - $logger->debug( "Creating article object for $namespace:$title in cache" ); + $logger->debug( "Creating wikipage object for $namespace:$title in cache" ); - // TODO: use WikiPage instead! - $t = Title::makeTitle( $namespace, $title ); - self::$articleCache["$namespace:$title"] = new Article( $t ); + $t = $this->buildTitle( $namespace, $title ); + self::$articleCache["$namespace:$title"] = WikiPage::factory( $t ); return self::$articleCache["$namespace:$title"]; } /** - * @param Article $article + * Mockable wrapper + * + * @param int $namespace + * @param string $title + * @return Title + */ + protected function buildTitle( $namespace, $title ) : Title { + return Title::makeTitle( $namespace, $title ); + } + + /** + * @param WikiPage $article * @return array */ - public static function getLinksFromDB( $article ) { + public static function getLinksFromDB( WikiPage $article ) { // Stolen from ConfirmEdit, SimpleCaptcha::getLinksFromTracker $id = $article->getId(); if ( !$id ) { @@ -126,35 +149,46 @@ class AFComputedVariable { } $dbr = wfGetDB( DB_REPLICA ); - $res = $dbr->select( + return $dbr->selectFieldValues( 'externallinks', - [ 'el_to' ], + 'el_to', [ 'el_from' => $id ], __METHOD__ ); - $links = []; - foreach ( $res as $row ) { - $links[] = $row->el_to; - } - return $links; } /** * @param AbuseFilterVariableHolder $vars - * @return AFPData|array|int|mixed|null|string + * @return AFPData * @throws MWException * @throws AFPException */ - public function compute( $vars ) { + public function compute( AbuseFilterVariableHolder $vars ) { + // TODO: find a way to inject the User object from hook parameters. + global $wgUser; + + // Used for parsing wikitext from saved revisions and checking for + // whether to show fields. Do not use $wgUser below here, in preparation + // for eventually injecting. See T246733 + $computeForUser = $wgUser; + + $vars->setLogger( LoggerFactory::getInstance( 'AbuseFilter' ) ); $parameters = $this->mParameters; $result = null; - if ( !Hooks::run( 'AbuseFilter-interceptVariable', - [ $this->mMethod, $vars, $parameters, &$result ] ) ) { + $hookRunner = AbuseFilterHookRunner::getRunner(); + + if ( !$hookRunner->onAbuseFilterInterceptVariable( + $this->mMethod, + $vars, + $parameters, + $result + ) ) { return $result instanceof AFPData ? $result : AFPData::newFromPHPVar( $result ); } + $services = MediaWikiServices::getInstance(); switch ( $this->mMethod ) { case 'diff': // Currently unused. Kept for backwards compatibility since it remains @@ -186,13 +220,12 @@ class AFComputedVariable { $diff = $vars->getVar( $parameters['diff-var'] )->toString(); $line_prefix = $parameters['line-prefix']; $diff_lines = explode( "\n", $diff ); - $interest_lines = []; + $result = []; foreach ( $diff_lines as $line ) { if ( substr( $line, 0, 1 ) === $line_prefix ) { - $interest_lines[] = substr( $line, strlen( $line_prefix ) ); + $result[] = substr( $line, strlen( $line_prefix ) ); } } - $result = $interest_lines; break; case 'links-from-wikitext': // This should ONLY be used when sharing a parse operation with the edit. @@ -201,32 +234,42 @@ class AFComputedVariable { if ( isset( $parameters['article'] ) ) { $article = $parameters['article']; } else { - $article = self::articleFromTitle( + $article = $this->pageFromTitle( $parameters['namespace'], $parameters['title'] ); } if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { + // Shared with the edit, don't count it in profiling + $startTime = microtime( true ); $textVar = $parameters['text-var']; $new_text = $vars->getVar( $textVar )->toString(); $content = ContentHandler::makeContent( $new_text, $article->getTitle() ); - $editInfo = $article->prepareContentForEdit( $content ); - $links = array_keys( $editInfo->output->getExternalLinks() ); + try { + // @fixme TEMPORARY WORKAROUND FOR T187153 + $editInfo = $article->prepareContentForEdit( $content ); + $links = array_keys( $editInfo->output->getExternalLinks() ); + } catch ( Error $e ) { + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->warning( 'Caught Error, case 1 - T187153' ); + $links = []; + } $result = $links; + self::$profilingExtraTime += ( microtime( true ) - $startTime ); break; } // Otherwise fall back to database case 'links-from-wikitext-nonedit': case 'links-from-wikitext-or-database': - // TODO: use Content object instead, if available! In any case, use WikiPage, not Article. - $article = self::articleFromTitle( + // TODO: use Content object instead, if available! + $article = $this->pageFromTitle( $parameters['namespace'], $parameters['title'] ); $logger = LoggerFactory::getInstance( 'AbuseFilter' ); - if ( $vars->getVar( 'context' )->toString() == 'filter' ) { + if ( $vars->forFilter ) { $links = $this->getLinksFromDB( $article ); $logger->debug( 'Loading old links from DB' ); } elseif ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { @@ -234,7 +277,11 @@ class AFComputedVariable { $textVar = $parameters['text-var']; $wikitext = $vars->getVar( $textVar )->toString(); - $editInfo = $this->parseNonEditWikitext( $wikitext, $article ); + $editInfo = $this->parseNonEditWikitext( + $wikitext, + $article, + $computeForUser + ); $links = array_keys( $editInfo->output->getExternalLinks() ); } else { // TODO: Get links from Content object. But we don't have the content object. @@ -256,10 +303,10 @@ class AFComputedVariable { $oldLinks = explode( "\n", $oldLinks ); $newLinks = explode( "\n", $newLinks ); - if ( $this->mMethod == 'link-diff-added' ) { + if ( $this->mMethod === 'link-diff-added' ) { $result = array_diff( $newLinks, $oldLinks ); } - if ( $this->mMethod == 'link-diff-removed' ) { + if ( $this->mMethod === 'link-diff-removed' ) { $result = array_diff( $oldLinks, $newLinks ); } break; @@ -268,31 +315,44 @@ class AFComputedVariable { if ( isset( $parameters['article'] ) ) { $article = $parameters['article']; } else { - $article = self::articleFromTitle( + $article = $this->pageFromTitle( $parameters['namespace'], $parameters['title'] ); } if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { + // Shared with the edit, don't count it in profiling + $startTime = microtime( true ); $textVar = $parameters['wikitext-var']; $new_text = $vars->getVar( $textVar )->toString(); $content = ContentHandler::makeContent( $new_text, $article->getTitle() ); - $editInfo = $article->prepareContentForEdit( $content ); + try { + // @fixme TEMPORARY WORKAROUND FOR T187153 + $editInfo = $article->prepareContentForEdit( $content ); + } catch ( Error $e ) { + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->warning( 'Caught Error, case 2 - T187153' ); + $result = ''; + break; + } if ( isset( $parameters['pst'] ) && $parameters['pst'] ) { $result = $editInfo->pstContent->serialize( $editInfo->format ); } else { $newHTML = $editInfo->output->getText(); // Kill the PP limit comments. Ideally we'd just remove these by not setting the // parser option, but then we can't share a parse operation with the edit, which is bad. - $result = preg_replace( '/<!--\s*NewPP limit report[^>]*-->\s*$/si', '', $newHTML ); + // @fixme No awfulness scale can measure how awful this hack is. + $re = '/<!--\s*NewPP limit [^>]*-->\s*(?:<!--\s*Transclusion [^>]+-->\s*)?(?:<\/div>\s*)?$/i'; + $result = preg_replace( $re, '', $newHTML ); } + self::$profilingExtraTime += ( microtime( true ) - $startTime ); break; } // Otherwise fall back to database case 'parse-wikitext-nonedit': - // TODO: use Content object instead, if available! In any case, use WikiPage, not Article. - $article = self::articleFromTitle( $parameters['namespace'], $parameters['title'] ); + // TODO: use Content object instead, if available! + $article = $this->pageFromTitle( $parameters['namespace'], $parameters['title'] ); $textVar = $parameters['wikitext-var']; if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { @@ -301,7 +361,11 @@ class AFComputedVariable { $result = $vars->getVar( $textVar )->toString(); } else { $text = $vars->getVar( $textVar )->toString(); - $editInfo = $this->parseNonEditWikitext( $text, $article ); + $editInfo = $this->parseNonEditWikitext( + $text, + $article, + $computeForUser + ); $result = $editInfo->output->getText(); } } else { @@ -315,10 +379,14 @@ class AFComputedVariable { case 'strip-html': $htmlVar = $parameters['html-var']; $html = $vars->getVar( $htmlVar )->toString(); - $result = StringUtils::delimiterReplace( '<', '>', '', $html ); + $stripped = StringUtils::delimiterReplace( '<', '>', '', $html ); + // We strip extra spaces to the right because the stripping above + // could leave a lot of whitespace. + // @fixme Find a better way to do this. + $result = TextContent::normalizeLineEndings( $stripped ); break; case 'load-recent-authors': - $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] ); + $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] ); if ( !$title->exists() ) { $result = ''; break; @@ -327,11 +395,12 @@ class AFComputedVariable { $result = self::getLastPageAuthors( $title ); break; case 'load-first-author': - $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] ); + $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] ); - $revision = $title->getFirstRevision(); + $revision = $services->getRevisionLookup()->getFirstRevision( $title ); if ( $revision ) { - $result = $revision->getUserText(); + $user = $revision->getUser(); + $result = $user === null ? '' : $user->getName(); } else { $result = ''; } @@ -339,11 +408,9 @@ class AFComputedVariable { break; case 'get-page-restrictions': $action = $parameters['action']; - $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] ); + $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] ); - $rights = $title->getRestrictions( $action ); - $rights = count( $rights ) ? $rights : []; - $result = $rights; + $result = $title->getRestrictions( $action ); break; case 'simple-user-accessor': $user = $parameters['user']; @@ -361,21 +428,31 @@ class AFComputedVariable { $result = call_user_func( [ $obj, $method ] ); break; + case 'user-block': + // @todo Support partial blocks + $user = $parameters['user']; + $result = (bool)$user->getBlock(); + break; case 'user-age': $user = $parameters['user']; $asOf = $parameters['asof']; $obj = self::getUserObject( $user ); - if ( $obj->getId() == 0 ) { + $registration = $obj->getRegistration(); + + if ( $obj->getId() === 0 ) { $result = 0; - break; + } else { + // HACK: If there's no registration date, assume 2008-01-15, Wikipedia Day + // in the year before the new user log was created. See T243469. + if ( $registration === null ) { + $registration = "20080115000000"; + } + $result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $registration ); } - - $registration = $obj->getRegistration(); - $result = wfTimestamp( TS_UNIX, $asOf ) - wfTimestampOrNull( TS_UNIX, $registration ); break; case 'page-age': - $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] ); + $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] ); $firstRevisionTime = $title->getEarliestRevTime(); if ( !$firstRevisionTime ) { @@ -384,7 +461,7 @@ class AFComputedVariable { } $asOf = $parameters['asof']; - $result = wfTimestamp( TS_UNIX, $asOf ) - wfTimestampOrNull( TS_UNIX, $firstRevisionTime ); + $result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $firstRevisionTime ); break; case 'user-groups': // Deprecated but needed by old log entries @@ -408,19 +485,37 @@ class AFComputedVariable { $result = $v1 - $v2; break; case 'revision-text-by-id': - $rev = Revision::newFromId( $parameters['revid'] ); - $result = AbuseFilter::revisionToString( $rev ); + $revRec = $services + ->getRevisionLookup() + ->getRevisionById( $parameters['revid'] ); + $result = AbuseFilter::revisionToString( $revRec, $computeForUser ); break; case 'revision-text-by-timestamp': $timestamp = $parameters['timestamp']; - $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] ); - $dbr = wfGetDB( DB_REPLICA ); - $rev = Revision::loadFromTimestamp( $dbr, $title, $timestamp ); - $result = AbuseFilter::revisionToString( $rev ); + if ( $timestamp === null ) { + // Temporary BC for T246539#6388362 + $result = '[Revision text not available]'; + break; + } + $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] ); + $revRec = $services + ->getRevisionStore() + ->getRevisionByTimestamp( $title, $timestamp ); + $result = AbuseFilter::revisionToString( $revRec, $computeForUser ); + break; + case 'get-wiki-name': + $result = WikiMap::getCurrentWikiDbDomain()->getId(); + break; + case 'get-wiki-language': + $result = $services->getContentLanguage()->getCode(); break; default: - if ( Hooks::run( 'AbuseFilter-computeVariable', - [ $this->mMethod, $vars, $parameters, &$result ] ) ) { + if ( $hookRunner->onAbuseFilterComputeVariable( + $this->mMethod, + $vars, + $parameters, + $result + ) ) { throw new AFPException( 'Unknown variable compute type ' . $this->mMethod ); } } @@ -448,14 +543,14 @@ class AFComputedVariable { $dbr = wfGetDB( DB_REPLICA ); $setOpts += Database::getCacheSetOptions( $dbr ); // Get the last 100 edit authors with a trivial query (avoid T116557) - $revQuery = Revision::getQueryInfo(); + $revQuery = MediaWikiServices::getInstance()->getRevisionStore()->getQueryInfo(); $revAuthors = $dbr->selectFieldValues( $revQuery['tables'], $revQuery['fields']['rev_user_text'], [ 'rev_page' => $title->getArticleID() ], $fname, // Some pages have < 10 authors but many revisions (e.g. bot pages) - [ 'ORDER BY' => 'rev_timestamp DESC', + [ 'ORDER BY' => 'rev_timestamp DESC, rev_id DESC', 'LIMIT' => 100, // Force index per T116557 'USE INDEX' => [ 'revision' => 'page_timestamp' ], diff --git a/AbuseFilter/includes/AbuseFilter.php b/AbuseFilter/includes/AbuseFilter.php index 2566c25e..f76b4df6 100644 --- a/AbuseFilter/includes/AbuseFilter.php +++ b/AbuseFilter/includes/AbuseFilter.php @@ -1,30 +1,68 @@ <?php -use MediaWiki\Linker\LinkRenderer; +use MediaWiki\Extension\AbuseFilter\AbuseFilterServices; +use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; +use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator; use MediaWiki\Logger\LoggerFactory; -use MediaWiki\Session\SessionManager; use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\RevisionRecord; +use Wikimedia\Rdbms\DBError; 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. + * This class contains most of the business logic of AbuseFilter. It consists of + * static functions for generic use (mostly utility functions). */ class AbuseFilter { + /** + * @var int How long to keep profiling data in cache (in seconds) + */ public static $statsStoragePeriod = 86400; - public static $condLimitEnabled = true; - /** @var array Map of (filter ID => stdClass) */ + /** + * @var array [filter ID => stdClass|null] as retrieved from self::getFilter. ID could be either + * an integer or "<GLOBAL_FILTER_PREFIX><integer>" + */ private static $filterCache = []; - public static $condCount = 0; + /** @var string The prefix to use for global filters */ + public const GLOBAL_FILTER_PREFIX = 'global-'; - /** @var array Map of (action ID => string[]) */ - // FIXME: avoid global state here + /** + * @var array Map of (action ID => string[]) + * @fixme avoid global state here + */ public static $tagsToSet = []; - public static $history_mappings = [ + /** + * @var array IDs of logged filters like [ page title => [ 'local' => [ids], 'global' => [ids] ] ]. + * @fixme avoid global state + */ + public static $logIds = []; + + /** + * @var string[] The FULL list of fields in the abuse_filter table + * @internal + */ + public const ALL_ABUSE_FILTER_FIELDS = [ + 'af_id', + 'af_pattern', + 'af_user', + 'af_user_text', + 'af_timestamp', + 'af_enabled', + 'af_comments', + 'af_public_comments', + 'af_hidden', + 'af_hit_count', + 'af_throttled', + 'af_deleted', + 'af_actions', + 'af_global', + 'af_group' + ]; + + public const HISTORY_MAPPINGS = [ 'af_pattern' => 'afh_pattern', 'af_user' => 'afh_user', 'af_user_text' => 'afh_user_text', @@ -35,343 +73,41 @@ class AbuseFilter { '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', - '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', - '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', - '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_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_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', - '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', - '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', - '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', - ], - ]; - - /** @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, - LinkRenderer $linkRenderer - ) { - $linkDefs = [ - 'home' => 'Special:AbuseFilter', - 'recentchanges' => 'Special:AbuseFilter/history', - 'examine' => 'Special:AbuseFilter/examine', - 'log' => 'Special:AbuseLog', - ]; - - if ( $context->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) { - $linkDefs = array_merge( $linkDefs, [ - 'test' => 'Special:AbuseFilter/test', - 'tools' => 'Special:AbuseFilter/tools' - ] ); - } - - if ( $context->getUser()->isAllowed( 'abusefilter-modify' ) ) { - $linkDefs = array_merge( $linkDefs, [ - 'import' => 'Special:AbuseFilter/import' - ] ); - } - - // Re-use the message - $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' ) - ->rawParams( $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 ); - } /** + * @deprecated Since 1.35 Use VariableGenerator::addUserVars() * @param User $user + * @param RecentChange|null $entry * @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; - - 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; + public static function generateUserVars( User $user, RecentChange $entry = null ) { + wfDeprecated( __METHOD__, '1.35' ); + $vars = new AbuseFilterVariableHolder(); + $generator = new VariableGenerator( $vars ); + return $generator->addUserVars( $user, $entry )->getVariableHolder(); } /** - * @param string $filter + * @param int $filterID The ID of the filter + * @param bool|int $global Whether the filter is global * @return bool */ - public static function filterHidden( $filter ) { - $globalIndex = self::decodeGlobalName( $filter ); - if ( $globalIndex ) { - global $wgAbuseFilterCentralDB; + public static function filterHidden( $filterID, $global = false ) { + global $wgAbuseFilterCentralDB; + + if ( $global ) { if ( !$wgAbuseFilterCentralDB ) { return false; } - $dbr = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); - $filter = $globalIndex; + $dbr = self::getCentralDB( DB_REPLICA ); } else { $dbr = wfGetDB( DB_REPLICA ); } - if ( $filter === 'new' ) { - return false; - } + $hidden = $dbr->selectField( 'abuse_filter', 'af_hidden', - [ 'af_id' => $filter ], + [ 'af_id' => $filterID ], __METHOD__ ); @@ -379,441 +115,150 @@ class AbuseFilter { } /** - * @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.' ); - } - } - - /** - * For use in batch scripts and the like - */ - public static function disableConditionLimit() { - self::$condLimitEnabled = false; - } - - /** + * @deprecated Since 1.35 Use VariableGenerator::addTitleVars * @param Title|null $title * @param string $prefix + * @param RecentChange|null $entry * @return AbuseFilterVariableHolder */ - public static function generateTitleVars( $title, $prefix ) { - $vars = new AbuseFilterVariableHolder; - - if ( !$title ) { + public static function generateTitleVars( $title, $prefix, RecentChange $entry = null ) { + wfDeprecated( __METHOD__, '1.35' ); + $vars = new AbuseFilterVariableHolder(); + if ( !( $title instanceof Title ) ) { return $vars; } - - $vars->setVar( $prefix . '_ID', $title->getArticleID() ); - $vars->setVar( $prefix . '_NAMESPACE', $title->getNamespace() ); - $vars->setVar( $prefix . '_TITLE', $title->getText() ); - $vars->setVar( $prefix . '_PREFIXEDTITLE', $title->getPrefixedText() ); - - 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}_age", 'page-age', - [ - 'title' => $title->getText(), - 'namespace' => $title->getNamespace(), - 'asof' => wfTimestampNow() - ] ); - - $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 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; - - /** @var $parser AbuseFilterParser */ - $parser = new $wgAbuseFilterParserClass; - - return $parser->checkSyntax( $filter ); + $generator = new VariableGenerator( $vars ); + return $generator->addTitleVars( $title, $prefix, $entry )->getVariableHolder(); } /** - * @param string $expr - * @return string + * @deprecated Since 1.35 Use VariableGenerator::addGenericVars + * @return AbuseFilterVariableHolder */ - public static function evaluateExpression( $expr ) { - global $wgAbuseFilterParserClass; - - if ( self::checkSyntax( $expr ) !== true ) { - return 'BADSYNTAX'; - } - - /** @var $parser AbuseFilterParser */ - $parser = new $wgAbuseFilterParserClass; - - 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 ) { - $result = false; - - $logger = LoggerFactory::getInstance( 'AbuseFilter' ); - $logger->debug( 'AbuseFilter parser error: ' . $excep->getMessage() ); - - if ( !$ignoreError ) { - throw $excep; - } - } - - return $result; + public static function generateGenericVars() { + wfDeprecated( __METHOD__, '1.35' ); + $vars = new AbuseFilterVariableHolder(); + $generator = new VariableGenerator( $vars ); + return $generator->addGenericVars()->getVariableHolder(); } /** * Returns an associative array of filters which were tripped * * @param AbuseFilterVariableHolder $vars + * @param Title $title * @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) + * @deprecated Since 1.34 See comment on AbuseFilterRunner::checkAllFilters */ public static function checkAllFilters( - $vars, + AbuseFilterVariableHolder $vars, + Title $title, $group = 'default', - Title $title = null, $mode = 'execute' ) { - global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; - global $wgAbuseFilterConditionLimit; - - // 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, - '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 ); + $parser = self::getDefaultParser( $vars ); + $user = RequestContext::getMain()->getUser(); - $fname = __METHOD__; - $res = ObjectCache::getMainWANInstance()->getWithSetCallback( - $globalRulesKey, - WANObjectCache::TTL_INDEFINITE, - function () use ( $group, $fname, $fields ) { - global $wgAbuseFilterCentralDB; - - $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, - '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; + $runner = new AbuseFilterRunner( $user, $title, $vars, $group ); + $runner->parser = $parser; + return $runner->checkAllFilters(); } /** - * @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 = 'execute' - ) { - 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; - - $pattern = trim( $row->af_pattern ); - if ( - self::checkConditions( - $pattern, - $vars, - // Ignore errors - true - ) - ) { - // 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 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 + * @param int|string $filter + * @internal */ 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 ); - } + $stash = MediaWikiServices::getInstance()->getMainObjectStash(); + $profileKey = self::filterProfileKey( $filter ); - /** - * @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 ); - } - } ); + $stash->delete( $profileKey ); } /** + * Retrieve per-filter statistics. + * * @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 ); + $stash = MediaWikiServices::getInstance()->getMainObjectStash(); + $profile = $stash->get( self::filterProfileKey( $filter ) ); - if ( !$curCount ) { - return [ 0, 0 ]; + if ( $profile !== false ) { + $curCount = $profile['count']; + $curTotalTime = $profile['total-time']; + $curTotalConds = $profile['total-cond']; + } else { + return [ 0, 0, 0, 0 ]; } - // 1000 ms in a sec - $timeProfile = ( $curTotal / $curCount ) * 1000; - // Return in ms, rounded to 2dp - $timeProfile = round( $timeProfile, 2 ); + // Return in milliseconds, rounded to 2dp + $avgTime = round( $curTotalTime / $curCount, 2 ); + $avgCond = round( $curTotalConds / $curCount, 1 ); - $condProfile = ( $curConds / $curCount ); - $condProfile = round( $condProfile, 0 ); - - return [ $timeProfile, $condProfile ]; + return [ $curCount, $profile['matches'], $avgTime, $avgCond ]; } /** - * Utility function to decode global-$index to $index. Returns false if not global - * - * @param string $filter + * Utility function to split "<GLOBAL_FILTER_PREFIX>$index" to an array [ $id, $global ], where + * $id is $index casted to int, and $global is a boolean: true if the filter is global, + * false otherwise (i.e. if the $filter === $index). Note that the $index + * is always casted to int. Passing anything which isn't an integer-like value or a string + * in the shape "<GLOBAL_FILTER_PREFIX>integer" will throw. + * This reverses self::buildGlobalName * - * @return string|bool - */ - public static function decodeGlobalName( $filter ) { - if ( strpos( $filter, 'global-' ) === 0 ) { - return substr( $filter, strlen( 'global-' ) ); + * @param string|int $filter + * @return array + * @throws InvalidArgumentException + */ + public static function splitGlobalName( $filter ) { + if ( preg_match( '/^' . self::GLOBAL_FILTER_PREFIX . '\d+$/', $filter ) === 1 ) { + $id = intval( substr( $filter, strlen( self::GLOBAL_FILTER_PREFIX ) ) ); + return [ $id, true ]; + } elseif ( is_numeric( $filter ) ) { + return [ (int)$filter, false ]; + } else { + throw new InvalidArgumentException( "Invalid filter name: $filter" ); } + } - return false; + /** + * Given a filter ID and a boolean indicating whether it's global, build a string like + * "<GLOBAL_FILTER_PREFIX>$ID". Note that, with global = false, $id is casted to string. + * This reverses self::splitGlobalName. + * + * @param int $id The filter ID + * @param bool $global Whether the filter is global + * @return string + * @todo Calling this method should be avoided wherever possible + */ + public static function buildGlobalName( $id, $global = true ) { + $prefix = $global ? self::GLOBAL_FILTER_PREFIX : ''; + return "$prefix$id"; } /** * @param string[] $filters - * @return array[] + * @return (string|array)[][][] + * @phan-return array<string,array<string,array{action:string,parameters:string[]}>> */ public static function getConsequencesForFilters( $filters ) { $globalFilters = []; $localFilters = []; foreach ( $filters as $filter ) { - $globalIndex = self::decodeGlobalName( $filter ); + list( $filterID, $global ) = self::splitGlobalName( $filter ); - if ( $globalIndex ) { - $globalFilters[] = $globalIndex; + if ( $global ) { + $globalFilters[] = $filterID; } else { $localFilters[] = $filter; } } - global $wgAbuseFilterCentralDB; // Load local filter info $dbr = wfGetDB( DB_REPLICA ); // Retrieve the consequences. @@ -824,8 +269,11 @@ class AbuseFilter { } if ( count( $globalFilters ) ) { - $fdb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); - $consequences = $consequences + self::loadConsequencesFromDB( $fdb, $globalFilters, 'global-' ); + $consequences += self::loadConsequencesFromDB( + self::getCentralDB( DB_REPLICA ), + $globalFilters, + self::GLOBAL_FILTER_PREFIX + ); } return $consequences; @@ -835,9 +283,10 @@ class AbuseFilter { * @param IDatabase $dbr * @param string[] $filters * @param string $prefix - * @return array[] + * @return (string|array)[][][] + * @phan-return array<string,array<string,array{action:string,parameters:string[]}>> */ - public static function loadConsequencesFromDB( $dbr, $filters, $prefix = '' ) { + public static function loadConsequencesFromDB( IDatabase $dbr, $filters, $prefix = '' ) { $actionsByFilter = []; foreach ( $filters as $filter ) { $actionsByFilter[$prefix . $filter] = []; @@ -858,8 +307,16 @@ class AbuseFilter { if ( $row->af_throttled && !empty( $wgAbuseFilterRestrictions[$row->afa_consequence] ) ) { - // Don't do the action - } elseif ( $row->afa_filter != $row->af_id ) { + // Don't do the action, just log + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->info( + 'Filter {filter_id} is throttled, skipping action: {action}', + [ + 'filter_id' => $row->af_id, + 'action' => $row->afa_consequence + ] + ); + } 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] = [ @@ -873,552 +330,82 @@ class AbuseFilter { } /** - * 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, User $user ) { - 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 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']; - $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']; - $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 - $session = SessionManager::getGlobalSession(); - $session->persist(); - - if ( !isset( $session[$warnKey] ) || !$session[$warnKey] ) { - $session[$warnKey] = true; - - // Threaten them a little bit - if ( isset( $parameters[0] ) ) { - $msg = $parameters[0]; - } else { - $msg = 'abusefilter-warning'; - } - $messages[] = [ $msg, $filter_public_comments, $filter ]; - - $actionsTaken[$filter][] = 'warn'; - - // Don't do anything else. - continue; - } 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'] ) ) { - $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( - $action, - $info['parameters'], - $title, - $vars, - self::getFilter( $filter )->af_public_comments, - $filter, - $user - ); - - 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] - ], - $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 ); - } - - /** - * 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 of 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 ) { - $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 - * @param string $mode Use 'execute' to run filters and log or 'stash' to only cache matches * @return Status + * @deprecated Since 1.34 Build an AbuseFilterRunner instance and call run() on that. */ public static function filterAction( - AbuseFilterVariableHolder $vars, Title $title, $group, User $user, $mode = 'execute' + AbuseFilterVariableHolder $vars, Title $title, $group, User $user ) { - global $wgRequest, $wgAbuseFilterRuntimeProfile, $wgAbuseFilterLogIP; - - $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, $user ); - $actions_taken = $status->getValue(); - $action = $vars->getVar( 'ACTION' )->toString(); - - // 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; - - // Create a template - $log_template = [ - 'afl_user' => $user->getId(), - 'afl_user_text' => $user->getName(), - 'afl_timestamp' => wfGetDB( DB_REPLICA )->timestamp(), - 'afl_namespace' => $title->getNamespace(), - 'afl_title' => $title->getDBkey(), - 'afl_action' => $action, - // 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, $vars, $group ); - } - - return $status; + $runner = new AbuseFilterRunner( $user, $title, $vars, $group ); + return $runner->run(); } /** - * @param string $id Filter ID (integer or "global-<integer>") - * @return stdClass|null DB row + * @param string $filter Filter ID (integer or "<GLOBAL_FILTER_PREFIX><integer>") + * @return stdClass|null DB row on success, null on failure */ - public static function getFilter( $id ) { + public static function getFilter( $filter ) { global $wgAbuseFilterCentralDB; - if ( !isset( self::$filterCache[$id] ) ) { - $globalIndex = self::decodeGlobalName( $id ); - if ( $globalIndex ) { + if ( !isset( self::$filterCache[$filter] ) ) { + list( $filterID, $global ) = self::splitGlobalName( $filter ); + if ( $global ) { // Global wiki filter if ( !$wgAbuseFilterCentralDB ) { return null; } - - $id = $globalIndex; - $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); - $lb = $lbFactory->getMainLB( $wgAbuseFilterCentralDB ); - $dbr = $lb->getConnectionRef( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + $dbr = self::getCentralDB( DB_REPLICA ); } else { // Local wiki filter $dbr = wfGetDB( DB_REPLICA ); } - $fields = [ - 'af_id', - 'af_pattern', - 'af_public_comments', - 'af_timestamp' - ]; - $row = $dbr->selectRow( 'abuse_filter', - $fields, - [ 'af_id' => $id ], + self::ALL_ABUSE_FILTER_FIELDS, + [ 'af_id' => $filterID ], __METHOD__ ); - self::$filterCache[$id] = $row ?: null; + self::$filterCache[$filter] = $row ?: null; } - return self::$filterCache[$id]; + return self::$filterCache[$filter]; } /** - * @param BagOStuff $cache - * @param AbuseFilterVariableHolder $vars - * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) - * - * @return string + * Checks whether the given object represents a full abuse_filter DB row + * @param stdClass $row + * @return bool */ - private static function getStashKey( - BagOStuff $cache, AbuseFilterVariableHolder $vars, $group - ) { - $inputVars = $vars->exportNonLazyVars(); - // Exclude noisy fields that have superficial changes - $excludedVars = [ - 'old_html' => true, - 'new_html' => true, - 'user_age' => true, - 'timestamp' => true, - 'page_age' => true, - 'moved_from_age' => true, - 'moved_to_age' => true - ]; + public static function isFullAbuseFilterRow( stdClass $row ) { + $actual = array_keys( get_object_vars( $row ) ); - $inputVars = array_diff_key( $inputVars, $excludedVars ); - ksort( $inputVars ); - $hash = md5( serialize( $inputVars ) ); - - return $cache->makeKey( - 'abusefilter', - 'check-stash', - $group, - $hash, - 'v1' - ); + if ( + count( $actual ) !== count( self::ALL_ABUSE_FILTER_FIELDS ) + || array_diff( self::ALL_ABUSE_FILTER_FIELDS, $actual ) + ) { + return false; + } + return true; } /** - * @param array[] $actions_taken - * @param array $log_template - * @param AbuseFilterVariableHolder $vars - * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * Saves an abuse_filter row in cache + * @param string $id Filter ID (integer or "<GLOBAL_FILTER_PREFIX><integer>") + * @param stdClass $row A full abuse_filter row to save + * @throws UnexpectedValueException if the row is not full */ - public static function addLogEntries( $actions_taken, $log_template, $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_actions'] = implode( ',', $actions ); - - // Don't log if we were only throttling. - if ( $thisLog['afl_actions'] != 'throttle' ) { - $log_rows[] = $thisLog; - // 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; - } else { - $logged_local_filters[] = $filter; - } - } + public static function cacheFilter( $id, $row ) { + // Check that all fields have been passed, otherwise using self::getFilter for this + // row will return partial data. + if ( !self::isFullAbuseFilterRow( $row ) ) { + throw new UnexpectedValueException( 'The specified row must be a full abuse_filter row.' ); } - - if ( !count( $log_rows ) ) { - return; - } - - // Only store the var dump if we're actually going to add log rows. - $var_dump = self::storeVarDump( $vars ); - // To distinguish from stuff stored directly - $var_dump = "stored-text:$var_dump"; - - $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 ); - $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[] = $fdb->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 ); + self::$filterCache[$id] = $row; } /** @@ -1428,9 +415,9 @@ class AbuseFilter { * @param AbuseFilterVariableHolder $vars * @param bool $global * - * @return int|null + * @return int The insert ID. */ - public static function storeVarDump( $vars, $global = false ) { + public static function storeVarDump( AbuseFilterVariableHolder $vars, $global = false ) { global $wgCompressRevisions; // Get all variables yet set and compute old and new wikitext if not yet done @@ -1438,14 +425,12 @@ class AbuseFilter { $vars = $vars->dumpAllVars( [ 'old_wikitext', 'new_wikitext' ] ); // Vars is an array with native PHP data types (non-objects) now - $text = serialize( $vars ); - $flags = [ 'nativeDataArray' ]; + $text = FormatJson::encode( $vars ); + $flags = [ 'utf-8' ]; - if ( $wgCompressRevisions ) { - if ( function_exists( 'gzdeflate' ) ) { - $text = gzdeflate( $text ); - $flags[] = 'gzip'; - } + if ( $wgCompressRevisions && function_exists( 'gzdeflate' ) ) { + $text = gzdeflate( $text ); + $flags[] = 'gzip'; } // Store to ExternalStore if applicable @@ -1456,24 +441,18 @@ class AbuseFilter { } else { $text = ExternalStore::insertToDefault( $text ); } - $flags[] = 'external'; - if ( !$text ) { - // Not mission-critical, just return nothing - return null; - } + $flags[] = 'external'; } // Store to text table if ( $global ) { - $dbw = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB ); + $dbw = self::getCentralDB( DB_MASTER ); } 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__ @@ -1488,25 +467,27 @@ class AbuseFilter { * * @param string $stored_dump * - * @return array|object|AbuseFilterVariableHolder|bool + * @return AbuseFilterVariableHolder */ - public static function loadVarDump( $stored_dump ) { - // Backward compatibility - if ( substr( $stored_dump, 0, strlen( 'stored-text:' ) ) !== 'stored-text:' ) { + public static function loadVarDump( $stored_dump ) : AbuseFilterVariableHolder { + // Backward compatibility for (old) blobs stored in the abuse_filter_log table + if ( !is_numeric( $stored_dump ) && + substr( $stored_dump, 0, strlen( 'stored-text:' ) ) !== 'stored-text:' && + substr( $stored_dump, 0, strlen( 'tt:' ) ) !== 'tt:' + ) { $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; - } + return is_array( $data ) ? AbuseFilterVariableHolder::newFromArray( $data ) : $data; } - $text_id = substr( $stored_dump, strlen( 'stored-text:' ) ); + if ( is_numeric( $stored_dump ) ) { + $text_id = (int)$stored_dump; + } elseif ( strpos( $stored_dump, 'stored-text:' ) !== false ) { + $text_id = (int)str_replace( 'stored-text:', '', $stored_dump ); + } elseif ( strpos( $stored_dump, 'tt:' ) !== false ) { + $text_id = (int)str_replace( 'tt:', '', $stored_dump ); + } else { + throw new LogicException( "Cannot understand format: $stored_dump" ); + } $dbr = wfGetDB( DB_REPLICA ); @@ -1518,10 +499,12 @@ class AbuseFilter { ); if ( !$text_row ) { + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->warning( __METHOD__ . ": no text row found for input $stored_dump." ); return new AbuseFilterVariableHolder; } - $flags = explode( ',', $text_row->old_flags ); + $flags = $text_row->old_flags === '' ? [] : explode( ',', $text_row->old_flags ); $text = $text_row->old_text; if ( in_array( 'external', $flags ) ) { @@ -1532,366 +515,60 @@ class AbuseFilter { $text = gzinflate( $text ); } - $obj = unserialize( $text ); + $obj = FormatJson::decode( $text, true ); + if ( $obj === null ) { + // Temporary code until all rows will be JSON-encoded + $obj = unserialize( $text ); + } - if ( in_array( 'nativeDataArray', $flags ) ) { + if ( in_array( 'nativeDataArray', $flags ) || + // Temporary condition: we don't add the flag anymore, but the updateVarDump + // script could be still running and we cannot assume that this branch is the default. + ( is_array( $obj ) && array_key_exists( 'action', $obj ) ) + ) { $vars = $obj; - $obj = new AbuseFilterVariableHolder(); - 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; - } + $obj = AbuseFilterVariableHolder::newFromArray( $vars ); + $obj->translateDeprecatedVars(); } return $obj; } /** - * @param string $action - * @param array $parameters - * @param Title $title - * @param AbuseFilterVariableHolder $vars - * @param string $rule_desc - * @param int|string $rule_number - * @param User $user + * Get an identifier for the given action to be used in self::$tagsToSet * - * @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. + * @param string $action The name of the current action, as used by AbuseFilter (e.g. 'edit' + * or 'createaccount') + * @param Title $title The title where the current action is executed on. This is the user page + * for account creations. + * @param string $username Of the user executing the action (as returned by User::getName()). + * For account creation, this is the name of the new account. + * @return string */ - public static function takeConsequenceAction( $action, $parameters, $title, - $vars, $rule_desc, $rule_number, User $user ) { - global $wgAbuseFilterCustomActionsHandlers, $wgRequest; - - $message = null; - - switch ( $action ) { - case 'disallow': - if ( isset( $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': - 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 ) { - $user->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; - } - - $logEntry = new ManualLogEntry( 'rights', 'rights' ); - $logEntry->setPerformer( self::getFilterUser() ); - $logEntry->setTarget( $user->getUserPage() ); - $logEntry->setComment( - wfMessage( - 'abusefilter-degroupreason', - $rule_desc, - $rule_number - )->inContentLanguage()->text() - ); - $logEntry->setParameters( [ - '4::oldgroups' => $groups, - '5::newgroups' => [] - ] ); - $logEntry->publish( $logEntry->insert() ); - } - - break; - case 'blockautopromote': - if ( !$user->isAnon() ) { - // Block for 3-7 days. - $blockPeriod = (int)mt_rand( 3 * 86400, 7 * 86400 ); - ObjectCache::getMainStashInstance()->set( - self::autoPromoteBlockKey( $user ), 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. - $actionID = implode( '-', [ - $title->getPrefixedText(), $user->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 { - $logger = LoggerFactory::getInstance( 'AbuseFilter' ); - $logger->debug( "Unrecognised action $action" ); - } - } - - return $message; + public static function getTaggingActionId( $action, Title $title, $username ) { + return implode( + '-', + [ + $title->getPrefixedText(), + $username, + $action + ] + ); } /** * @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'; + public static function bufferTagsToSetByAction( array $tagsByAction ) { + foreach ( $tagsByAction as $actionID => $tags ) { + if ( !isset( self::$tagsToSet[ $actionID ] ) ) { + self::$tagsToSet[ $actionID ] = $tags; + } else { + self::$tagsToSet[ $actionID ] = array_unique( + array_merge( self::$tagsToSet[ $actionID ], $tags ) + ); } - $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 string $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 ) ); - - $logger = LoggerFactory::getInstance( 'AbuseFilter' ); - $logger->debug( "Got value $count for throttle key $key" ); - - if ( $count > 0 ) { - $stash->incr( $key ); - $count++; - $logger->debug( "Incremented throttle key $key" ); - } else { - $logger->debug( "Added throttle key $key with value 1" ); - $stash->add( $key, 1, $ratePeriod ); - $count = 1; - } - - if ( $count > $rateCount ) { - $logger->debug( "Throttle $key hit value $count -- maximum is $rateCount." ); - - // THROTTLED - return true; - } - - $logger->debug( "Throttle $key not hit!" ); - - // NOT THROTTLED - return false; - } - - /** - * @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 ); } /** @@ -1901,176 +578,159 @@ class AbuseFilter { public static function getGlobalRulesKey( $group ) { global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB; + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); if ( !$wgAbuseFilterIsCentral ) { - list( $globalSite, $globalPrefix ) = wfSplitWikiID( $wgAbuseFilterCentralDB ); - - return wfForeignMemcKey( - $globalSite, $globalPrefix, - 'abusefilter', 'rules', $group - ); + return $cache->makeGlobalKey( 'abusefilter', 'rules', $wgAbuseFilterCentralDB, $group ); } - return wfMemcKey( 'abusefilter', 'rules', $group ); + return $cache->makeKey( 'abusefilter', 'rules', $group ); } /** - * @param User $user - * @return string + * Gets the autopromotion block status for the given user + * + * @param User $target + * @return int */ - public static function autoPromoteBlockKey( $user ) { - return wfMemcKey( 'abusefilter', 'block-autopromote', $user->getId() ); + public static function getAutoPromoteBlockStatus( User $target ) { + $store = ObjectCache::getInstance( 'db-replicated' ); + + return (int)$store->get( self::autoPromoteBlockKey( $store, $target ) ); } /** - * 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 ); - - $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 ); + * Blocks autopromotion for the given user + * + * @param User $target + * @param string $msg The message to show in the log + * @param int $duration Duration for which autopromotion is blocked, in seconds + * @return bool True on success, false on failure + */ + public static function blockAutoPromote( User $target, $msg, int $duration ) { + $store = ObjectCache::getInstance( 'db-replicated' ); + if ( !$store->set( + self::autoPromoteBlockKey( $store, $target ), + 1, + $duration + ) ) { + // Failed to set key + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->warning( + "Failed to block autopromotion for $target. Error: " . $store->getLastError() + ); + return false; } - $stash->incr( $total_key ); + $logEntry = new ManualLogEntry( 'rights', 'blockautopromote' ); + $performer = self::getFilterUser(); + $logEntry->setPerformer( $performer ); + $logEntry->setTarget( $target->getUserPage() ); - // Increment overflow counter, if our condition limit overflowed - if ( $overflow_triggered ) { - $stash->incr( $overflow_key ); - } + $logEntry->setParameters( [ + '7::duration' => $duration, + // These parameters are unused in our message, but some parts of the code check for them + '4::oldgroups' => [], + '5::newgroups' => [] + ] ); + $logEntry->setComment( $msg ); + $logEntry->publish( $logEntry->insert() ); + + return true; } /** - * Record runtime profiling data + * Unblocks autopromotion for the given user * - * @param int $totalFilters - * @param int $totalConditions - * @param float $runtime - */ - private static function recordRuntimeProfilingResult( $totalFilters, $totalConditions, $runtime ) { - $keyPrefix = 'abusefilter.runtime-profile.' . wfWikiID() . '.'; + * @param User $target + * @param User $performer + * @param string $msg The message to show in the log + * @return bool True on success, false on failure + */ + public static function unblockAutopromote( User $target, User $performer, $msg ) { + // Immediately expire (delete) the key, failing if it does not exist + $store = ObjectCache::getInstance( 'db-replicated' ); + $expireAt = time() - $store::TTL_HOUR; + if ( !$store->changeTTL( self::autoPromoteBlockKey( $store, $target ), $expireAt ) ) { + // Key did not exist to begin with; nothing to do + return false; + } - $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); - $statsd->timing( $keyPrefix . 'runtime', $runtime ); - $statsd->timing( $keyPrefix . 'total_filters', $totalFilters ); - $statsd->timing( $keyPrefix . 'total_conditions', $totalConditions ); + $logEntry = new ManualLogEntry( 'rights', 'restoreautopromote' ); + $logEntry->setTarget( Title::makeTitle( NS_USER, $target->getName() ) ); + $logEntry->setComment( $msg ); + // These parameters are unused in our message, but some parts of the code check for them + $logEntry->setParameters( [ + '4::oldgroups' => [], + '5::newgroups' => [] + ] ); + $logEntry->setPerformer( $performer ); + $logEntry->publish( $logEntry->insert() ); + + return true; } /** - * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) - * @param string[] $filters - * @param int $total + * @param BagOStuff $store + * @param User $target + * @return string + * @internal */ - 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 - ); - } - ) - ); - } - } + public static function autoPromoteBlockKey( BagOStuff $store, User $target ) { + return $store->makeKey( 'abusefilter', 'block-autopromote', $target->getId() ); } /** - * @param array $emergencyValue + * @param string $type The value to get, either "threshold", "count" or "age" * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) * @return mixed */ - public static function getEmergencyValue( array $emergencyValue, $group ) { - return $emergencyValue[$group] ?? $emergencyValue['default']; - } + public static function getEmergencyValue( $type, $group ) { + switch ( $type ) { + case 'threshold': + global $wgAbuseFilterEmergencyDisableThreshold; + $value = $wgAbuseFilterEmergencyDisableThreshold; + break; + case 'count': + global $wgAbuseFilterEmergencyDisableCount; + $value = $wgAbuseFilterEmergencyDisableCount; + break; + case 'age': + global $wgAbuseFilterEmergencyDisableAge; + $value = $wgAbuseFilterEmergencyDisableAge; + break; + default: + throw new InvalidArgumentException( '$type must be either "threshold", "count" or "age"' ); + } - /** - * @return string - */ - public static function filterLimitReachedKey() { - return wfMemcKey( 'abusefilter', 'stats', 'overflow' ); + return $value[$group] ?? $value['default']; } /** - * @param string|null $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * Get the memcache access key used to store per-filter profiling data. + * + * @param string|int $filter * @return string */ - public static function filterUsedKey( $group = null ) { - return wfMemcKey( 'abusefilter', 'stats', 'total', $group ); + public static function filterProfileKey( $filter ) { + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + return $cache->makeKey( 'abusefilter-profile', 'v3', $filter ); } /** - * @param string|null $filter + * Memcache access key used to store overall profiling data for rule groups + * + * @param string $group * @return string */ - public static function filterMatchesKey( $filter = null ) { - return wfMemcKey( 'abusefilter', 'stats', 'matches', $filter ); + public static function filterProfileGroupKey( $group ) { + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + return $cache->makeKey( 'abusefilter-profile', 'group', $group ); } /** * @return User */ - public static function getFilterUser() { + public static function getFilterUser() : User { $username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text(); $user = User::newSystemUser( $username, [ 'steal' => true ] ); @@ -2086,6 +746,7 @@ class AbuseFilter { $defaultName = wfMessage( 'abusefilter-blocker' )->inLanguage( 'en' )->text(); $user = User::newSystemUser( $defaultName, [ 'steal' => true ] ); } + '@phan-var User $user'; // Promote user to 'sysop' so it doesn't look // like an unprivileged account is blocking users @@ -2102,21 +763,22 @@ class AbuseFilter { * @param bool $canEdit * @return array */ - public static function getAceConfig( $canEdit ) { - $values = self::getBuilderValues(); - $deprecatedVars = self::getDeprecatedVariables(); + public static function getAceConfig( bool $canEdit ): array { + $keywordsManager = AbuseFilterServices::getKeywordsManager(); + $values = $keywordsManager->getBuilderValues(); + $deprecatedVars = $keywordsManager->getDeprecatedVariables(); $builderVariables = implode( '|', array_keys( $values['vars'] ) ); - $builderFunctions = implode( '|', array_keys( AbuseFilterParser::$mFunctions ) ); - // AbuseFilterTokenizer::$keywords also includes constants (true, false and null), + $builderFunctions = implode( '|', array_keys( AbuseFilterParser::FUNCTIONS ) ); + // 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 ); + $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 ) ); + }, AbuseFilterTokenizer::OPERATORS ) ); $deprecatedVariables = implode( '|', array_keys( $deprecatedVars ) ); - $disabledVariables = implode( '|', array_keys( self::$disabledVars ) ); + $disabledVariables = implode( '|', array_keys( $keywordsManager->getDisabledVariables() ) ); return [ 'variables' => $builderVariables, @@ -2130,45 +792,6 @@ class AbuseFilter { } /** - * Build input and button for loading a filter - * - * @return string - */ - 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 @@ -2220,9 +843,7 @@ class AbuseFilter { * @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]; + list( $throttleCount, $throttlePeriod ) = explode( ',', $params[1], 2 ); $throttleGroups = array_slice( $params, 2 ); $validGroups = [ 'ip', @@ -2284,20 +905,33 @@ class AbuseFilter { } /** - * Checks whether user input for the filter editing form is valid and if so saves the filter + * Checks whether user input for the filter editing form is valid and if so saves the filter. + * Returns a Status object which can be: + * - Good with [ new_filter_id, history_id ] as value if the filter was successfully saved + * - Good with value = false if everything went fine but the filter is unchanged + * - OK with errors if a validation error occurred + * - Fatal in case of a permission-related error * - * @param AbuseFilterViewEdit $page + * @param ContextSource $context * @param int|string $filter - * @param WebRequest $request * @param stdClass $newRow * @param array $actions + * @param IDatabase $dbw DB_MASTER Where the filter should be saved * @return Status */ - public static function saveFilter( $page, $filter, $request, $newRow, $actions ) { + public static function saveFilter( + ContextSource $context, + $filter, + $newRow, + $actions, + IDatabase $dbw + ) { $validationStatus = Status::newGood(); + $request = $context->getRequest(); + $user = $context->getUser(); // Check the syntax - $syntaxerr = self::checkSyntax( $request->getVal( 'wpFilterRules' ) ); + $syntaxerr = self::getDefaultParser()->checkSyntax( $request->getVal( 'wpFilterRules' ) ); if ( $syntaxerr !== true ) { $validationStatus->error( 'abusefilter-edit-badsyntax', $syntaxerr[0] ); return $validationStatus; @@ -2306,27 +940,30 @@ class AbuseFilter { $missing = []; if ( !$request->getVal( 'wpFilterRules' ) || trim( $request->getVal( 'wpFilterRules' ) ) === '' ) { - $missing[] = wfMessage( 'abusefilter-edit-field-conditions' )->escaped(); + $missing[] = $context->msg( 'abusefilter-edit-field-conditions' )->escaped(); } if ( !$request->getVal( 'wpFilterDescription' ) ) { - $missing[] = wfMessage( 'abusefilter-edit-field-description' )->escaped(); + $missing[] = $context->msg( 'abusefilter-edit-field-description' )->escaped(); } if ( count( $missing ) !== 0 ) { - $missing = $page->getLanguage()->commaList( $missing ); + $missing = $context->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 ) { + if ( $request->getCheck( 'wpFilterEnabled' ) && $request->getCheck( 'wpFilterDeleted' ) ) { $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 ) { + if ( isset( $actions['tag'] ) ) { + if ( count( $actions['tag'] ) === 0 ) { + $validationStatus->error( 'tags-create-no-name' ); + return $validationStatus; + } + foreach ( $actions['tag'] as $tag ) { $status = self::isAllowedTag( $tag ); if ( !$status->isGood() ) { @@ -2338,23 +975,39 @@ class AbuseFilter { } } + // Warning and disallow message cannot be empty + if ( isset( $actions['warn'] ) && $actions['warn'][0] === '' ) { + $validationStatus->error( 'abusefilter-edit-invalid-warn-message' ); + return $validationStatus; + } elseif ( isset( $actions['disallow'] ) && $actions['disallow'][0] === '' ) { + $validationStatus->error( 'abusefilter-edit-invalid-disallow-message' ); + return $validationStatus; + } + // If 'throttle' is selected, check its parameters - if ( !empty( $actions['throttle'] ) ) { - $throttleCheck = self::checkThrottleParameters( $actions['throttle']['parameters'] ); + if ( isset( $actions['throttle'] ) ) { + $throttleCheck = self::checkThrottleParameters( $actions['throttle'] ); if ( $throttleCheck !== null ) { $validationStatus->error( $throttleCheck ); return $validationStatus; } } + $availableActions = array_keys( + array_filter( $context->getConfig()->get( 'AbuseFilterActions' ) ) + ); $differences = self::compareVersions( [ $newRow, $actions ], - [ $newRow->mOriginalRow, $newRow->mOriginalActions ] + [ $newRow->mOriginalRow, $newRow->mOriginalActions ], + $availableActions ); // 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 ) ) { + if ( + !self::canEditFilter( $user, $newRow ) || + !self::canEditFilter( $user, $newRow->mOriginalRow ) + ) { $validationStatus->fatal( 'abusefilter-edit-notallowed-global' ); return $validationStatus; } @@ -2381,15 +1034,13 @@ class AbuseFilter { } // Check for restricted actions - $restrictions = $page->getConfig()->get( 'AbuseFilterRestrictions' ); + $restrictions = $context->getConfig()->get( 'AbuseFilterRestrictions' ); if ( count( array_intersect_key( array_filter( $restrictions ), - array_merge( - array_filter( $actions ), - array_filter( $origActions ) - ) + array_merge( $actions, $origActions ) ) ) - && !$page->getUser()->isAllowed( 'abusefilter-modify-restricted' ) + && !MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $user, 'abusefilter-modify-restricted' ) ) { $validationStatus->error( 'abusefilter-edit-restricted' ); return $validationStatus; @@ -2397,7 +1048,7 @@ class AbuseFilter { // Everything went fine, so let's save the filter list( $new_id, $history_id ) = - self::doSaveFilter( $newRow, $differences, $filter, $actions, $wasGlobal, $page ); + self::doSaveFilter( $newRow, $differences, $filter, $actions, $wasGlobal, $context, $dbw ); $validationStatus->setResult( true, [ $new_id, $history_id ] ); return $validationStatus; } @@ -2406,11 +1057,12 @@ class AbuseFilter { * Saves new filter's info to DB * * @param stdClass $newRow - * @param int|string $filter * @param array $differences + * @param int|string $filter * @param array $actions * @param bool $wasGlobal - * @param AbuseFilterViewEdit $page + * @param ContextSource $context + * @param IDatabase $dbw DB_MASTER where the filter will be saved * @return int[] first element is new ID, second is history ID */ private static function doSaveFilter( @@ -2419,10 +1071,10 @@ class AbuseFilter { $filter, $actions, $wasGlobal, - $page + ContextSource $context, + IDatabase $dbw ) { - $user = $page->getUser(); - $dbw = wfGetDB( DB_MASTER ); + $user = $context->getUser(); // Convert from object to array $newRow = get_object_vars( $newRow ); @@ -2435,8 +1087,8 @@ class AbuseFilter { $dbw->startAtomic( __METHOD__ ); // Insert MAIN row. - if ( $filter == 'new' ) { - $new_id = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' ); + if ( $filter === 'new' ) { + $new_id = null; $is_new = true; } else { $new_id = $filter; @@ -2454,21 +1106,21 @@ class AbuseFilter { $newRow['af_deleted'] = (int)$newRow['af_deleted']; $newRow['af_global'] = (int)$newRow['af_global']; - $dbw->replace( 'abuse_filter', [ 'af_id' ], $newRow, __METHOD__ ); + $dbw->replace( 'abuse_filter', 'af_id', $newRow, __METHOD__ ); if ( $is_new ) { $new_id = $dbw->insertId(); } + '@phan-var int $new_id'; - // Actions - $availableActions = $page->getConfig()->get( 'AbuseFilterActions' ); + $availableActions = $context->getConfig()->get( 'AbuseFilterActions' ); $actionsRows = []; foreach ( array_filter( $availableActions ) as $action => $_ ) { // Check if it's set - $enabled = isset( $actions[$action] ) && (bool)$actions[$action]; + $enabled = isset( $actions[$action] ); if ( $enabled ) { - $parameters = $actions[$action]['parameters']; + $parameters = $actions[$action]; 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 @@ -2488,20 +1140,14 @@ class AbuseFilter { // Create a history row $afh_row = []; - foreach ( self::$history_mappings as $af_col => $afh_col ) { + 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_actions'] = serialize( $actions ); $afh_row['afh_changed_fields'] = implode( ',', $differences ); - // Flags $flags = []; if ( $newRow['af_hidden'] ) { $flags[] = 'hidden'; @@ -2519,12 +1165,11 @@ class AbuseFilter { $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' ) { + if ( $filter !== 'new' ) { $dbw->delete( 'abuse_filter_action', [ 'afa_filter' => $filter ], @@ -2538,24 +1183,26 @@ class AbuseFilter { // Invalidate cache if this was a global rule if ( $wasGlobal || $newRow['af_global'] ) { $group = 'default'; - if ( isset( $newRow['af_group'] ) && $newRow['af_group'] != '' ) { + if ( isset( $newRow['af_group'] ) && $newRow['af_group'] !== '' ) { $group = $newRow['af_group']; } $globalRulesKey = self::getGlobalRulesKey( $group ); - ObjectCache::getMainWANInstance()->touchCheckKey( $globalRulesKey ); + MediaWikiServices::getInstance()->getMainWANObjectCache()->touchCheckKey( $globalRulesKey ); } // Logging $subtype = $filter === 'new' ? 'create' : 'modify'; $logEntry = new ManualLogEntry( 'abusefilter', $subtype ); $logEntry->setPerformer( $user ); - $logEntry->setTarget( $page->getTitle( $new_id ) ); + $logEntry->setTarget( + SpecialPage::getTitleFor( SpecialAbuseFilter::PAGE_NAME, (string)$new_id ) + ); $logEntry->setParameters( [ 'historyId' => $history_id, 'newId' => $new_id ] ); - $logid = $logEntry->insert(); + $logid = $logEntry->insert( $dbw ); $logEntry->publish( $logid ); // Purge the tag list cache so the fetchAllTags hook applies tag changes @@ -2573,10 +1220,15 @@ class AbuseFilter { * * @param array $version_1 * @param array $version_2 + * @param string[] $availableActions All actions enabled in the AF config * * @return array */ - public static function compareVersions( $version_1, $version_2 ) { + public static function compareVersions( + array $version_1, + array $version_2, + array $availableActions + ) { $compareFields = [ 'af_public_comments', 'af_pattern', @@ -2598,16 +1250,13 @@ class AbuseFilter { } } - global $wgAbuseFilterActions; - foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { + foreach ( $availableActions 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'] ) ) { + if ( array_diff( $actions1[$action], $actions2[$action] ) || + array_diff( $actions2[$action], $actions1[$action] ) ) { // Different parameters $differences[] = 'actions'; } @@ -2628,7 +1277,7 @@ class AbuseFilter { // Manually translate into an abuse_filter row. $af_row = new stdClass; - foreach ( self::$history_mappings as $af_col => $afh_col ) { + foreach ( self::HISTORY_MAPPINGS as $af_col => $afh_col ) { $af_row->$af_col = $row->$afh_col; } @@ -2646,31 +1295,25 @@ class AbuseFilter { } // 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 - ]; - } - } + $actionsRaw = unserialize( $row->afh_actions ); + $actionsOutput = is_array( $actionsRaw ) ? $actionsRaw : []; - return [ $af_row, $actions_output ]; + return [ $af_row, $actionsOutput ]; } /** * @param string $action - * @return string + * @param MessageLocalizer|null $localizer + * @return string HTML */ - public static function getActionDisplay( $action ) { + public static function getActionDisplay( $action, MessageLocalizer $localizer = null ) { + $msgCallback = $localizer != null ? [ $localizer, 'msg' ] : 'wfMessage'; // 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() + $display = $msgCallback( "abusefilter-action-$action" )->escaped(); + $display = $msgCallback( "abusefilter-action-$action" )->rawParams( $display )->isDisabled() ? htmlspecialchars( $action ) : $display; @@ -2678,216 +1321,32 @@ class AbuseFilter { } /** - * @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, 'PAGE' ) - ); - - $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, 'PAGE' ) - ); - - $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|null $title - * @param Page|null $page - * @return AbuseFilterVariableHolder + * @param mixed $var + * @param string $indent + * @return string */ - 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-array', - [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_wikitext' ] ); - $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-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' ] ); - - 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 - ] ); + public static function formatVar( $var, string $indent = '' ) { + if ( $var === [] ) { + return '[]'; + } elseif ( is_array( $var ) ) { + $ret = '['; + $indent .= "\t"; + foreach ( $var as $key => $val ) { + $ret .= "\n$indent" . self::formatVar( $key, $indent ) . + ' => ' . self::formatVar( $val, $indent ) . ','; + } + // Strip trailing commas + return substr( $ret, 0, -1 ) . "\n" . substr( $indent, 0, -1 ) . ']'; + } elseif ( is_string( $var ) ) { + // Don't escape the string (specifically backslashes) to avoid displaying wrong stuff + return "'$var'"; + } elseif ( $var === null ) { + return 'null'; + } elseif ( is_float( $var ) ) { + // Don't let float precision produce weirdness + return (string)$var; } - - return $vars; + return var_export( $var, true ); } /** @@ -2903,12 +1362,6 @@ class AbuseFilter { $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' ) . @@ -2925,30 +1378,27 @@ class AbuseFilter { return $output; } + $keywordsManager = AbuseFilterServices::getKeywordsManager(); // 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' )->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() ); + $varMsgKey = $keywordsManager->getMessageKeyForVar( $key ); + if ( $varMsgKey ) { + $keyDisplay = $context->msg( $varMsgKey )->parse() . + ' ' . Html::element( 'code', [], $context->msg( 'parentheses' )->rawParams( $key )->text() ); } else { - $keyDisplay = Xml::element( 'code', null, $key ); + $keyDisplay = Html::element( 'code', [], $key ); } - if ( is_null( $value ) ) { + if ( $value === null ) { $value = ''; } - $value = Xml::element( 'div', [ 'class' => 'mw-abuselog-var-value' ], $value, false ); + $value = Html::element( + 'div', + [ 'class' => 'mw-abuselog-var-value' ], + self::formatVar( $value ) + ); $trow = Xml::tags( 'td', [ 'class' => 'mw-abuselog-var' ], $keyDisplay ) . @@ -2967,11 +1417,10 @@ class AbuseFilter { /** * @param string $action * @param string[] $parameters + * @param Language $lang * @return string */ - public static function formatAction( $action, $parameters ) { - /** @var $wgLang Language */ - global $wgLang; + public static function formatAction( $action, $parameters, $lang ) { if ( count( $parameters ) === 0 || ( $action === 'block' && count( $parameters ) !== 3 ) ) { $displayAction = self::getActionDisplay( $action ); @@ -2981,57 +1430,49 @@ class AbuseFilter { $messages = [ wfMessage( 'abusefilter-block-anon' )->escaped() . wfMessage( 'colon-separator' )->escaped() . - $wgLang->translateBlockExpiry( $parameters[1] ), + $lang->translateBlockExpiry( $parameters[1] ), wfMessage( 'abusefilter-block-user' )->escaped() . wfMessage( 'colon-separator' )->escaped() . - $wgLang->translateBlockExpiry( $parameters[2] ) + $lang->translateBlockExpiry( $parameters[2] ) ]; if ( $parameters[0] === 'blocktalk' ) { $messages[] = wfMessage( 'abusefilter-block-talk' )->escaped(); } - $displayAction = $wgLang->commaList( $messages ); + $displayAction = $lang->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; + // 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, abusefilter-throttle-none + 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 = $lang->listToText( $subGroups ); + } else { + $msg = wfMessage( "abusefilter-throttle-$val" ); + $val = $msg->exists() ? $msg->text() : $val; } - unset( $val ); - $groups = $wgLang->semicolonList( $parameters ); } + unset( $val ); + $groups = $lang->semicolonList( $parameters ); + $displayAction = self::getActionDisplay( $action ) . wfMessage( 'colon-separator' )->escaped() . wfMessage( 'abusefilter-throttle-details' )->params( $actions, $time, $groups )->escaped(); } else { $displayAction = self::getActionDisplay( $action ) . wfMessage( 'colon-separator' )->escaped() . - $wgLang->semicolonList( array_map( 'htmlspecialchars', $parameters ) ); + $lang->semicolonList( array_map( 'htmlspecialchars', $parameters ) ); } } @@ -3040,22 +1481,21 @@ class AbuseFilter { /** * @param string $value + * @param Language $lang * @return string */ - public static function formatFlags( $value ) { - /** @var $wgLang Language */ - global $wgLang; + public static function formatFlags( $value, $lang ) { $flags = array_filter( explode( ',', $value ) ); $flags_display = []; foreach ( $flags as $flag ) { $flags_display[] = wfMessage( "abusefilter-history-$flag" )->escaped(); } - return $wgLang->commaList( $flags_display ); + return $lang->commaList( $flags_display ); } /** - * @param string $filterID + * @param int $filterID * @return string */ public static function getGlobalFilterDescription( $filterID ) { @@ -3070,7 +1510,7 @@ class AbuseFilter { return $cache[$filterID]; } - $fdb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + $fdb = self::getCentralDB( DB_REPLICA ); $cache[$filterID] = $fdb->selectField( 'abuse_filter', @@ -3105,25 +1545,34 @@ class AbuseFilter { * Note also that if the revision for any reason is not an Revision * the function returns with an empty string. * - * @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 - * Revision::RAW get the text regardless of permissions - * @return string|null the content of the revision as some kind of string, + * For now, this returns all the revision's slots, concatenated together. + * In future, this will be replaced by a better solution. See T208769 for + * discussion. + * + * @internal + * @todo Move elsewhere. VariableGenerator is a good candidate + * + * @param RevisionRecord|null $revision a valid revision + * @param User $user the user instance to check for privileged access + * @return string the content of the revision as some kind of string, * or an empty string if it can not be found */ - public static function revisionToString( $revision, $audience = Revision::FOR_THIS_USER ) { - if ( !$revision instanceof Revision ) { + public static function revisionToString( ?RevisionRecord $revision, User $user ) { + if ( !$revision ) { return ''; } - $content = $revision->getContent( $audience ); - if ( $content === null ) { - return ''; + $strings = []; + + foreach ( $revision->getSlotRoles() as $role ) { + $content = $revision->getContent( $role, RevisionRecord::FOR_THIS_USER, $user ); + if ( $content === null ) { + continue; + } + $strings[$role] = self::contentToString( $content ); } - $result = self::contentToString( $content ); + $result = implode( "\n\n", $strings ); return $result; } @@ -3136,6 +1585,9 @@ class AbuseFilter { * The hook 'AbuseFilter::contentToString' can be used to override this * behavior. * + * @internal + * @todo Move elsewhere. VariableGenerator is a good candidate + * * @param Content $content * * @return string a suitable string representation of the content. @@ -3143,9 +1595,13 @@ class AbuseFilter { public static function contentToString( Content $content ) { $text = null; - if ( Hooks::run( 'AbuseFilter-contentToString', [ $content, &$text ] ) ) { + $hookRunner = AbuseFilterHookRunner::getRunner(); + if ( $hookRunner->onAbuseFilterContentToString( + $content, + $text + ) ) { $text = $content instanceof TextContent - ? $content->getNativeData() + ? $content->getText() : $content->getTextForSearchIndex(); } @@ -3158,14 +1614,14 @@ class AbuseFilter { * Get the history ID of the first change to a given filter * * @param int $filterID Filter id - * @return int + * @return string */ public static function getFirstFilterChange( $filterID ) { static $firstChanges = []; if ( !isset( $firstChanges[$filterID] ) ) { $dbr = wfGetDB( DB_REPLICA ); - $row = $dbr->selectRow( + $historyID = $dbr->selectField( 'abuse_filter_history', 'afh_id', [ @@ -3174,9 +1630,120 @@ class AbuseFilter { __METHOD__, [ 'ORDER BY' => 'afh_timestamp ASC' ] ); - $firstChanges[$filterID] = $row->afh_id; + $firstChanges[$filterID] = $historyID; } return $firstChanges[$filterID]; } + + /** + * @param int $index DB_MASTER/DB_REPLICA + * @return IDatabase + * @throws DBerror + * @throws RuntimeException + */ + public static function getCentralDB( $index ) { + global $wgAbuseFilterCentralDB; + + if ( !is_string( $wgAbuseFilterCentralDB ) ) { + throw new RuntimeException( '$wgAbuseFilterCentralDB is not configured' ); + } + + return MediaWikiServices::getInstance() + ->getDBLoadBalancerFactory() + ->getMainLB( $wgAbuseFilterCentralDB ) + ->getConnectionRef( $index, [], $wgAbuseFilterCentralDB ); + } + + /** + * @param User $user + * @return bool + */ + public static function canEdit( User $user ) { + $block = $user->getBlock(); + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + + return ( + !( $block && $block->isSitewide() ) && + $permissionManager->userHasRight( $user, 'abusefilter-modify' ) + ); + } + + /** + * @param User $user + * @return bool + */ + public static function canEditGlobal( User $user ) { + return MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $user, 'abusefilter-modify-global' ); + } + + /** + * Whether the user can edit the given filter. + * + * @param User $user + * @param object $row Filter row + * @return bool + */ + public static function canEditFilter( User $user, $row ) { + return ( + self::canEdit( $user ) && + !( isset( $row->af_global ) && $row->af_global == 1 && !self::canEditGlobal( $user ) ) + ); + } + + /** + * @param User $user + * @return bool + */ + public static function canViewPrivate( User $user ) { + return MediaWikiServices::getInstance()->getPermissionManager() + ->userHasAnyRight( $user, 'abusefilter-modify', 'abusefilter-view-private' ); + } + + /** + * Get a parser instance using default options. This should mostly be intended as a wrapper + * around $wgAbuseFilterParserClass and for choosing the right type of cache. It also has the + * benefit of typehinting the return value, thus making IDEs and static analysis tools happier. + * + * @param AbuseFilterVariableHolder|null $vars + * @return AbuseFilterParser + * @throws InvalidArgumentException if $wgAbuseFilterParserClass is not valid + */ + public static function getDefaultParser( + AbuseFilterVariableHolder $vars = null + ) : AbuseFilterParser { + global $wgAbuseFilterParserClass; + + $allowedValues = [ AbuseFilterParser::class, AbuseFilterCachingParser::class ]; + if ( !in_array( $wgAbuseFilterParserClass, $allowedValues ) ) { + throw new InvalidArgumentException( + "Invalid value $wgAbuseFilterParserClass for \$wgAbuseFilterParserClass." + ); + } + + $contLang = MediaWikiServices::getInstance()->getContentLanguage(); + $cache = ObjectCache::getLocalServerInstance( 'hash' ); + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $keywordsManager = AbuseFilterServices::getKeywordsManager(); + return new $wgAbuseFilterParserClass( $contLang, $cache, $logger, $keywordsManager, $vars ); + } + + /** + * Shortcut for checking whether $user can view the given revision, with mask + * SUPPRESSED_ALL. + * + * @note This assumes that a revision with the given ID exists + * + * @param RevisionRecord $revRec + * @param User $user + * @return bool + */ + public static function userCanViewRev( RevisionRecord $revRec, User $user ) : bool { + return $revRec->audienceCan( + RevisionRecord::SUPPRESSED_ALL, + RevisionRecord::FOR_THIS_USER, + $user + ); + } } diff --git a/AbuseFilter/includes/AbuseFilterChangesList.php b/AbuseFilter/includes/AbuseFilterChangesList.php index 94855f34..ba84d51b 100644 --- a/AbuseFilter/includes/AbuseFilterChangesList.php +++ b/AbuseFilter/includes/AbuseFilterChangesList.php @@ -1,5 +1,7 @@ <?php +use MediaWiki\Revision\RevisionRecord; + class AbuseFilterChangesList extends OldChangesList { /** @@ -25,17 +27,20 @@ class AbuseFilterChangesList extends OldChangesList { 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 ) ) { + if ( !$this->userCan( $rc, RevisionRecord::SUPPRESSED_ALL ) ) { return; } } $examineParams = []; - if ( $this->testFilter ) { + if ( $this->testFilter && strlen( $this->testFilter ) < 2000 ) { + // Since this is GETed, don't send it if it's too long to prevent broken URLs 2000 is taken from + // https://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url- + // in-different-browsers/417184#417184 $examineParams['testfilter'] = $this->testFilter; } - $title = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/' . $rc->mAttribs['rc_id'] ); + $title = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/' . $rc->getAttribute( 'rc_id' ) ); $examineLink = $this->linkRenderer->makeLink( $title, new HtmlArmor( $this->msg( 'abusefilter-changeslist-examine' )->parse() ), @@ -63,12 +68,12 @@ class AbuseFilterChangesList extends OldChangesList { * @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'] ); + $links = $this->getLanguage()->getDirMark() . Linker::userLink( $rc->getAttribute( 'rc_user' ), + $rc->getAttribute( 'rc_user_text' ) ) . + Linker::userToolLinks( $rc->getAttribute( 'rc_user' ), $rc->getAttribute( 'rc_user_text' ) ); - if ( $this->isDeleted( $rc, Revision::DELETED_USER ) ) { - if ( $this->userCan( $rc, Revision::DELETED_USER ) ) { + if ( $this->isDeleted( $rc, RevisionRecord::DELETED_USER ) ) { + if ( $this->userCan( $rc, RevisionRecord::DELETED_USER ) ) { $s .= ' <span class="history-deleted">' . $links . '</span>'; } else { $s .= ' <span class="history-deleted">' . @@ -85,16 +90,16 @@ class AbuseFilterChangesList extends OldChangesList { * @return string */ public function insertComment( $rc ) { - if ( $this->isDeleted( $rc, Revision::DELETED_COMMENT ) ) { - if ( $this->userCan( $rc, Revision::DELETED_COMMENT ) ) { + if ( $this->isDeleted( $rc, RevisionRecord::DELETED_COMMENT ) ) { + if ( $this->userCan( $rc, RevisionRecord::DELETED_COMMENT ) ) { return ' <span class="history-deleted">' . - Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() ) . '</span>'; + Linker::commentBlock( $rc->getAttribute( '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() ); + return Linker::commentBlock( $rc->getAttribute( 'rc_comment' ), $rc->getTitle() ); } } @@ -105,7 +110,7 @@ class AbuseFilterChangesList extends OldChangesList { * @return string */ public function insertLogEntry( $rc ) { - $formatter = LogFormatter::newFromRow( $rc->mAttribs ); + $formatter = LogFormatter::newFromRow( $rc->getAttributes() ); $formatter->setContext( $this->getContext() ); $formatter->setAudience( LogFormatter::FOR_THIS_USER ); $formatter->setShowUserToolLinks( true ); diff --git a/AbuseFilter/includes/AbuseFilterHooks.php b/AbuseFilter/includes/AbuseFilterHooks.php index 20c07e4a..0b81bc31 100644 --- a/AbuseFilter/includes/AbuseFilterHooks.php +++ b/AbuseFilter/includes/AbuseFilterHooks.php @@ -1,30 +1,72 @@ <?php +use MediaWiki\Extension\AbuseFilter\VariableGenerator\RunVariableGenerator; use MediaWiki\Linker\LinkRenderer; +use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\SlotRecord; +use MediaWiki\User\UserIdentity; +use Wikimedia\IPUtils; use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\IMaintainableDatabase; class AbuseFilterHooks { - const FETCH_ALL_TAGS_KEY = 'abusefilter-fetch-all-tags'; + private const FETCH_ALL_TAGS_KEY = 'abusefilter-fetch-all-tags'; - /** @var AbuseFilterVariableHolder|bool */ - public static $successful_action_vars = false; - /** @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. + /** @var WikiPage|null Make sure edit filter & edit save hooks match */ + private static $lastEditPage = null; /** * Called right after configuration has been loaded. */ public static function onRegistration() { - global $wgAbuseFilterAvailableActions, $wgAbuseFilterRestrictedActions, - $wgAuthManagerAutoConfig, $wgActionFilteredLogs; - - if ( isset( $wgAbuseFilterAvailableActions ) || isset( $wgAbuseFilterRestrictedActions ) ) { - wfWarn( '$wgAbuseFilterAvailableActions and $wgAbuseFilterRestrictedActions have been ' - . 'removed. Please use $wgAbuseFilterActions and $wgAbuseFilterRestrictions ' - . 'instead. The format is the same except the action names are the keys of the ' - . 'array and the values are booleans.' ); + global $wgAuthManagerAutoConfig, $wgActionFilteredLogs, $wgAbuseFilterProfile, + $wgAbuseFilterProfiling, $wgAbuseFilterPrivateLog, $wgAbuseFilterForceSummary, + $wgGroupPermissions; + + // @todo Remove this in a future release (added in 1.33) + if ( isset( $wgAbuseFilterProfile ) || isset( $wgAbuseFilterProfiling ) ) { + wfWarn( '$wgAbuseFilterProfile and $wgAbuseFilterProfiling have been removed and ' . + 'profiling is now enabled by default.' ); + } + + if ( isset( $wgAbuseFilterPrivateLog ) ) { + global $wgAbuseFilterLogPrivateDetailsAccess; + $wgAbuseFilterLogPrivateDetailsAccess = $wgAbuseFilterPrivateLog; + wfWarn( '$wgAbuseFilterPrivateLog has been renamed to $wgAbuseFilterLogPrivateDetailsAccess. ' . + 'Please make the change in your settings; the format is identical.' + ); + } + if ( isset( $wgAbuseFilterForceSummary ) ) { + global $wgAbuseFilterPrivateDetailsForceReason; + $wgAbuseFilterPrivateDetailsForceReason = $wgAbuseFilterForceSummary; + wfWarn( '$wgAbuseFilterForceSummary has been renamed to ' . + '$wgAbuseFilterPrivateDetailsForceReason. Please make the change in your settings; ' . + 'the format is identical.' + ); + } + + $found = false; + foreach ( $wgGroupPermissions as &$perms ) { + if ( array_key_exists( 'abusefilter-private', $perms ) ) { + $perms['abusefilter-privatedetails'] = $perms[ 'abusefilter-private' ]; + unset( $perms[ 'abusefilter-private' ] ); + $found = true; + } + if ( array_key_exists( 'abusefilter-private-log', $perms ) ) { + $perms['abusefilter-privatedetails-log'] = $perms[ 'abusefilter-private-log' ]; + unset( $perms[ 'abusefilter-private-log' ] ); + $found = true; + } + } + unset( $perms ); + + if ( $found ) { + wfWarn( 'The group permissions "abusefilter-private-log" and "abusefilter-private" have ' . + 'been renamed, respectively, to "abusefilter-privatedetails-log" and ' . + '"abusefilter-privatedetails". Please update the names in your settings.' + ); } $wgAuthManagerAutoConfig['preauth'][AbuseFilterPreAuthenticationProvider::class] = [ @@ -38,6 +80,15 @@ class AbuseFilterHooks { // Message: log-action-filter-suppress-abuselog [ 'abuselog' => [ 'hide-afl', 'unhide-afl' ] ] ); + $wgActionFilteredLogs['rights'] = array_merge( + $wgActionFilteredLogs['rights'], + // Messages: log-action-filter-rights-blockautopromote, + // log-action-filter-rights-restoreautopromote + [ + 'blockautopromote' => [ 'blockautopromote' ], + 'restoreautopromote' => [ 'restoreautopromote' ] + ] + ); } /** @@ -49,265 +100,178 @@ 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. + * @param string $slot slot role for the content */ public static function onEditFilterMergedContent( IContextSource $context, Content $content, - Status $status, $summary, User $user, $minoredit + Status $status, $summary, User $user, $minoredit, $slot = SlotRecord::MAIN ) { - $text = AbuseFilter::contentToString( $content ); + $startTime = microtime( true ); - $filterStatus = self::filterEdit( $context, $content, $text, $status, $summary, $minoredit ); + $filterResult = self::filterEdit( $context, $user, $content, $summary, $slot ); - if ( !$filterStatus->isOK() ) { + if ( !$filterResult->isOK() ) { // Produce a useful error message for API edits - $status->apiHookResult = self::getApiResult( $filterStatus ); + $filterResultApi = self::getApiStatus( $filterResult ); + $status->merge( $filterResultApi ); } + MediaWikiServices::getInstance()->getStatsdDataFactory() + ->timing( 'timing.editAbuseFilter', microtime( true ) - $startTime ); } /** * Implementation for EditFilterMergedContent hook. * * @param IContextSource $context the context of the edit + * @param User $user * @param Content $content the new Content generated by the edit - * @param string $text new page content (subject of filtering) - * @param Status $status Error message to return * @param string $summary Edit summary for page - * @param bool $minoredit whether this is a minor edit according to the user. + * @param string $slot slot role for the content * @return Status */ - public static function filterEdit( IContextSource $context, $content, $text, - Status $status, $summary, $minoredit - ) { - $title = $context->getTitle(); - - self::$successful_action_vars = false; - self::$last_edit_page = false; + public static function filterEdit( + IContextSource $context, + User $user, + Content $content, + $summary, $slot = SlotRecord::MAIN + ) : Status { + self::$lastEditPage = null; - $user = $context->getUser(); + // @todo is there any real point in passing this in? + $text = AbuseFilter::contentToString( $content ); - $oldcontent = null; + $title = $context->getTitle(); + if ( $title === null ) { + // T144265: This *should* never happen. + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->warning( __METHOD__ . ' received a null title.' ); + return Status::newGood(); + } - if ( ( $title instanceof Title ) && $title->canExist() && $title->exists() ) { + if ( $title->canExist() && $title->exists() ) { // Make sure we load the latest text saved in database (bug 31656) $page = $context->getWikiPage(); - $revision = $page->getRevision(); - if ( !$revision ) { - return Status::newGood(); - } - - $oldcontent = $revision->getContent( Revision::RAW ); - $oldtext = AbuseFilter::contentToString( $oldcontent ); - - // Cache article object so we can share a parse operation - $articleCacheKey = $title->getNamespace() . ':' . $title->getText(); - AFComputedVariable::$articleCache[$articleCacheKey] = $page; - - // Don't trigger for null edits. - if ( $content && $oldcontent ) { - // Compare Content objects if available - if ( $content->equals( $oldcontent ) ) { - return Status::newGood(); - } - } elseif ( strcmp( $oldtext, $text ) == 0 ) { - // Otherwise, compare strings - return Status::newGood(); - } } else { $page = null; } - // Load vars for filters to check - $vars = self::newVariableHolderForEdit( - $user, $title, $page, $summary, $content, $text, $oldcontent - ); - - $filter_result = AbuseFilter::filterAction( $vars, $title, 'default', $user ); - if ( !$filter_result->isOK() ) { - $status->merge( $filter_result ); - - return $filter_result; + $vars = new AbuseFilterVariableHolder(); + $builder = new RunVariableGenerator( $vars, $user, $title ); + $vars = $builder->getEditVars( $content, $text, $summary, $slot, $page ); + if ( $vars === null ) { + // We don't have to filter the edit + return Status::newGood(); + } + $runner = new AbuseFilterRunner( $user, $title, $vars, 'default' ); + $filterResult = $runner->run(); + if ( !$filterResult->isOK() ) { + return $filterResult; } - self::$successful_action_vars = $vars; - self::$last_edit_page = $page; + self::$lastEditPage = $page; return Status::newGood(); } /** - * @param User $user - * @param Title $title - * @param WikiPage|null $page - * @param string $summary - * @param Content $newcontent - * @param string $text - * @param Content|null $oldcontent - * @return AbuseFilterVariableHolder - * @throws MWException - */ - private static function newVariableHolderForEdit( - User $user, Title $title, $page, $summary, Content $newcontent, - $text, $oldcontent = null - ) { - $vars = new AbuseFilterVariableHolder(); - $vars->addHolders( - AbuseFilter::generateUserVars( $user ), - AbuseFilter::generateTitleVars( $title, 'PAGE' ) - ); - $vars->setVar( 'action', 'edit' ); - $vars->setVar( 'summary', $summary ); - if ( $oldcontent instanceof Content ) { - $oldmodel = $oldcontent->getModel(); - $oldtext = AbuseFilter::contentToString( $oldcontent ); - } else { - $oldmodel = ''; - $oldtext = ''; - } - $vars->setVar( 'old_content_model', $oldmodel ); - $vars->setVar( 'new_content_model', $newcontent->getModel() ); - $vars->setVar( 'old_wikitext', $oldtext ); - $vars->setVar( 'new_wikitext', $text ); - // TODO: set old_content and new_content vars, use them - $vars->addHolders( AbuseFilter::getEditVars( $title, $page ) ); - - return $vars; - } - - /** * @param Status $status Error message details - * @return array API result + * @return Status Status containing the same error messages with extra data for the API */ - private static function getApiResult( Status $status ) { - global $wgFullyInitialised; - - $params = $status->getErrorsArray()[0]; - $key = array_shift( $params ); + private static function getApiStatus( Status $status ) { + $allActionsTaken = $status->getValue(); + $statusForApi = Status::newGood(); + + foreach ( $status->getErrors() as $error ) { + list( $filterDescription, $filter ) = $error['params']; + $actionsTaken = $allActionsTaken[ $filter ]; + + $code = ( $actionsTaken === [ 'warn' ] ) ? 'abusefilter-warning' : 'abusefilter-disallowed'; + $data = [ + 'abusefilter' => [ + 'id' => $filter, + 'description' => $filterDescription, + 'actions' => $actionsTaken, + ], + ]; - $warning = wfMessage( $key )->params( $params ); - if ( !$wgFullyInitialised ) { - // This could happen for account autocreation checks - $warning = $warning->inContentLanguage(); + $message = ApiMessage::create( $error, $code, $data ); + $statusForApi->fatal( $message ); } - $filterDescription = $params[0]; - $filter = $params[1]; - - // 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( - array_merge( ...array_values( $status->getValue() ) ) - ) ); - $code = ( $actionsTaken === [ 'warn' ] ) ? 'abusefilter-warning' : 'abusefilter-disallowed'; - - ApiResult::setIndexedTagName( $params, 'param' ); - return [ - 'code' => $code, - 'message' => [ - 'key' => $key, - 'params' => $params, - ], - 'abusefilter' => [ - 'id' => $filter, - 'description' => $filterDescription, - 'actions' => $actionsTaken, - ], - // For backwards-compatibility - 'info' => 'Hit AbuseFilter: ' . $filterDescription, - 'warning' => $warning->parse(), - ]; + return $statusForApi; } /** * @param WikiPage $wikiPage - * @param User $user - * @param string $content Content + * @param UserIdentity $userIdentity * @param string $summary - * @param bool $minoredit - * @param bool $watchthis - * @param string $sectionanchor * @param int $flags - * @param Revision $revision - * @param Status $status - * @param int $baseRevId + * @param RevisionRecord $revisionRecord */ - public static function onPageContentSaveComplete( - WikiPage $wikiPage, $user, $content, $summary, $minoredit, $watchthis, $sectionanchor, - $flags, $revision, $status, $baseRevId + public static function onPageSaveComplete( + WikiPage $wikiPage, + UserIdentity $userIdentity, + string $summary, + int $flags, + RevisionRecord $revisionRecord ) { - if ( !self::$successful_action_vars || !$revision ) { - self::$successful_action_vars = false; - return; - } - - /** @var AbuseFilterVariableHolder|bool $vars */ - $vars = self::$successful_action_vars; - - if ( $vars->getVar( 'page_prefixedtitle' )->toString() !== - $wikiPage->getTitle()->getPrefixedText() + $curTitle = $wikiPage->getTitle()->getPrefixedText(); + if ( !isset( AbuseFilter::$logIds[ $curTitle ] ) || + $wikiPage !== self::$lastEditPage ) { + // This isn't the edit AbuseFilter::$logIds was set for + AbuseFilter::$logIds = []; return; } - if ( !self::identicalPageObjects( $wikiPage, self::$last_edit_page ) ) { - // This isn't the edit $successful_action_vars was set for - return; + // Ignore null edit. + $parentRevId = $revisionRecord->getParentId(); + if ( $parentRevId !== null ) { + $parentRev = MediaWikiServices::getInstance() + ->getRevisionLookup() + ->getRevisionById( $parentRevId ); + if ( $parentRev && $revisionRecord->hasSameContent( $parentRev ) ) { + AbuseFilter::$logIds = []; + return; + } } - self::$last_edit_page = false; - if ( $vars->getVar( 'local_log_ids' ) ) { + self::$lastEditPage = null; + + $logs = AbuseFilter::$logIds[ $curTitle ]; + if ( $logs[ 'local' ] ) { // Now actually do our storage - $log_ids = $vars->getVar( 'local_log_ids' )->toNative(); $dbw = wfGetDB( DB_MASTER ); - if ( $log_ids !== null && count( $log_ids ) ) { - $dbw->update( 'abuse_filter_log', - [ 'afl_rev_id' => $revision->getId() ], - [ 'afl_id' => $log_ids ], - __METHOD__ - ); - } + $dbw->update( 'abuse_filter_log', + [ 'afl_rev_id' => $revisionRecord->getId() ], + [ 'afl_id' => $logs['local'] ], + __METHOD__ + ); } - if ( $vars->getVar( 'global_log_ids' ) ) { - $log_ids = $vars->getVar( 'global_log_ids' )->toNative(); - - if ( $log_ids !== null && count( $log_ids ) ) { - global $wgAbuseFilterCentralDB; - $fdb = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB ); - - $fdb->update( 'abuse_filter_log', - [ 'afl_rev_id' => $revision->getId() ], - [ 'afl_id' => $log_ids, 'afl_wiki' => wfWikiID() ], - __METHOD__ - ); - } + if ( $logs[ 'global' ] ) { + $fdb = AbuseFilter::getCentralDB( DB_MASTER ); + $fdb->update( 'abuse_filter_log', + [ 'afl_rev_id' => $revisionRecord->getId() ], + [ 'afl_id' => $logs['global'], 'afl_wiki' => WikiMap::getCurrentWikiDbDomain()->getId() ], + __METHOD__ + ); } } /** - * Check if two article objects are identical or have an identical WikiPage - * @param Article|WikiPage $page1 - * @param Article|WikiPage $page2 - * @return bool - */ - protected static function identicalPageObjects( $page1, $page2 ) { - $wpage1 = ( $page1 instanceof Article ) ? $page1->getPage() : $page1; - $wpage2 = ( $page2 instanceof Article ) ? $page2->getPage() : $page2; - - return $wpage1 === $wpage2; - } - - /** * @param User $user * @param array &$promote */ - public static function onGetAutoPromoteGroups( $user, &$promote ) { + public static function onGetAutoPromoteGroups( User $user, &$promote ) { if ( $promote ) { - $key = AbuseFilter::autoPromoteBlockKey( $user ); - $blocked = (bool)ObjectCache::getInstance( 'hash' )->getWithSetCallback( + $cache = ObjectCache::getInstance( 'hash' ); + $key = AbuseFilter::autoPromoteBlockKey( $cache, $user ); + $blocked = (bool)$cache->getWithSetCallback( $key, - 30, - function () use ( $key ) { - return (int)ObjectCache::getMainStashInstance()->get( $key ); + $cache::TTL_PROC_LONG, + function () use ( $user ) { + return AbuseFilter::getAutoPromoteBlockStatus( $user ); } ); @@ -322,25 +286,21 @@ class AbuseFilterHooks { * @param Title $newTitle * @param User $user * @param string $reason - * @param Status $status - * @return bool + * @param Status &$status */ - public static function onMovePageCheckPermissions( Title $oldTitle, Title $newTitle, - User $user, $reason, Status $status + public static function onTitleMove( + Title $oldTitle, + Title $newTitle, + User $user, + $reason, + Status &$status ) { - $vars = new AbuseFilterVariableHolder; - $vars->addHolders( - AbuseFilter::generateUserVars( $user ), - AbuseFilter::generateTitleVars( $oldTitle, 'MOVED_FROM' ), - AbuseFilter::generateTitleVars( $newTitle, 'MOVED_TO' ) - ); - $vars->setVar( 'SUMMARY', $reason ); - $vars->setVar( 'ACTION', 'move' ); - - $result = AbuseFilter::filterAction( $vars, $oldTitle, 'default', $user ); + $vars = new AbuseFilterVariableHolder(); + $builder = new RunVariableGenerator( $vars, $user, $oldTitle ); + $vars = $builder->getMoveVars( $newTitle, $reason ); + $runner = new AbuseFilterRunner( $user, $oldTitle, $vars, 'default' ); + $result = $runner->run(); $status->merge( $result ); - - return $result->isOK(); } /** @@ -351,41 +311,46 @@ class AbuseFilterHooks { * @param Status $status * @return bool */ - public static function onArticleDelete( $article, $user, $reason, &$error, $status ) { - $vars = new AbuseFilterVariableHolder; - - $vars->addHolders( - AbuseFilter::generateUserVars( $user ), - AbuseFilter::generateTitleVars( $article->getTitle(), 'PAGE' ) - ); - - $vars->setVar( 'SUMMARY', $reason ); - $vars->setVar( 'ACTION', 'delete' ); - - $filter_result = AbuseFilter::filterAction( $vars, $article->getTitle(), 'default', $user ); + public static function onArticleDelete( WikiPage $article, User $user, $reason, &$error, + Status $status ) { + $vars = new AbuseFilterVariableHolder(); + $builder = new RunVariableGenerator( $vars, $user, $article->getTitle() ); + $vars = $builder->getDeleteVars( $reason ); + $runner = new AbuseFilterRunner( $user, $article->getTitle(), $vars, 'default' ); + $filterResult = $runner->run(); - $status->merge( $filter_result ); - $error = $filter_result->isOK() ? '' : $filter_result->getHTML(); + $status->merge( $filterResult ); + $error = $filterResult->isOK() ? '' : $filterResult->getHTML(); - return $filter_result->isOK(); + return $filterResult->isOK(); } /** * @param RecentChange $recentChange */ - public static function onRecentChangeSave( $recentChange ) { + public static function onRecentChangeSave( RecentChange $recentChange ) { $title = Title::makeTitle( $recentChange->getAttribute( 'rc_namespace' ), $recentChange->getAttribute( 'rc_title' ) ); - $action = $recentChange->mAttribs['rc_log_type'] ? - $recentChange->mAttribs['rc_log_type'] : 'edit'; - $actionID = implode( '-', [ - $title->getPrefixedText(), $recentChange->getAttribute( 'rc_user_text' ), $action - ] ); + + $logType = $recentChange->getAttribute( 'rc_log_type' ) ?: 'edit'; + if ( $logType === 'newusers' ) { + $action = $recentChange->getAttribute( 'rc_log_action' ) === 'autocreate' ? + 'autocreateaccount' : + 'createaccount'; + } else { + $action = $logType; + } + $actionID = AbuseFilter::getTaggingActionId( + $action, + $title, + $recentChange->getAttribute( 'rc_user_text' ) + ); if ( isset( AbuseFilter::$tagsToSet[$actionID] ) ) { $recentChange->addTags( AbuseFilter::$tagsToSet[$actionID] ); + unset( AbuseFilter::$tagsToSet[$actionID] ); } } @@ -408,7 +373,7 @@ class AbuseFilterHooks { } /** - * @param array $tags + * @param array &$tags * @param bool $enabled */ private static function fetchAllTags( array &$tags, $enabled ) { @@ -419,10 +384,8 @@ class AbuseFilterHooks { $tags = $cache->getWithSetCallback( // Key to store the cached value under $cache->makeKey( self::FETCH_ALL_TAGS_KEY, (int)$enabled ), - // Time-to-live (in seconds) $cache::TTL_MINUTE, - // Function that derives the new key value function ( $oldValue, &$ttl, array &$setOpts ) use ( $enabled, $tags, $fname ) { global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; @@ -453,12 +416,11 @@ class AbuseFilterHooks { } if ( $wgAbuseFilterCentralDB && !$wgAbuseFilterIsCentral ) { - $dbr = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); - $where['af_global'] = 1; + $dbr = AbuseFilter::getCentralDB( DB_REPLICA ); $res = $dbr->select( [ 'abuse_filter_action', 'abuse_filter' ], 'afa_parameters', - $where, + [ 'af_global' => 1 ] + $where, $fname, [], [ 'abuse_filter' => [ 'INNER JOIN', 'afa_filter=af_id' ] ] @@ -499,18 +461,17 @@ class AbuseFilterHooks { public static function onLoadExtensionSchemaUpdates( DatabaseUpdater $updater ) { $dir = dirname( __DIR__ ); - if ( $updater->getDB()->getType() == 'mysql' || $updater->getDB()->getType() == 'sqlite' ) { - if ( $updater->getDB()->getType() == 'mysql' ) { + if ( $updater->getDB()->getType() === 'mysql' || $updater->getDB()->getType() === 'sqlite' ) { + if ( $updater->getDB()->getType() === 'mysql' ) { $updater->addExtensionUpdate( [ 'addTable', 'abuse_filter', "$dir/abusefilter.tables.sql", true ] ); - $updater->addExtensionUpdate( [ 'addTable', 'abuse_filter_history', - "$dir/db_patches/patch-abuse_filter_history.sql", true ] ); } else { $updater->addExtensionUpdate( [ 'addTable', 'abuse_filter', "$dir/abusefilter.tables.sqlite.sql", true ] ); - $updater->addExtensionUpdate( [ 'addTable', 'abuse_filter_history', - "$dir/db_patches/patch-abuse_filter_history.sqlite.sql", true ] ); } + $updater->addExtensionTable( 'abuse_filter_history', + "$dir/db_patches/patch-abuse_filter_history.sql" ); + $updater->addExtensionUpdate( [ 'addField', 'abuse_filter_history', 'afh_changed_fields', "$dir/db_patches/patch-afh_changed_fields.sql", true @@ -523,7 +484,7 @@ class AbuseFilterHooks { "$dir/db_patches/patch-global_filters.sql", true ] ); $updater->addExtensionUpdate( [ 'addField', 'abuse_filter_log', 'afl_rev_id', "$dir/db_patches/patch-afl_action_id.sql", true ] ); - if ( $updater->getDB()->getType() == 'mysql' ) { + if ( $updater->getDB()->getType() === 'mysql' ) { $updater->addExtensionUpdate( [ 'addIndex', 'abuse_filter_log', 'filter_timestamp', "$dir/db_patches/patch-fix-indexes.sql", true ] ); } else { @@ -536,42 +497,42 @@ class AbuseFilterHooks { $updater->addExtensionUpdate( [ 'addField', 'abuse_filter', 'af_group', "$dir/db_patches/patch-af_group.sql", true ] ); - if ( $updater->getDB()->getType() == 'mysql' ) { + $updater->addExtensionIndex( + 'abuse_filter_log', 'afl_wiki_timestamp', + "$dir/db_patches/patch-global_logging_wiki-index.sql" + ); + + if ( $updater->getDB()->getType() === 'mysql' ) { $updater->addExtensionUpdate( [ - 'addIndex', 'abuse_filter_log', 'wiki_timestamp', - "$dir/db_patches/patch-global_logging_wiki-index.sql", true + 'modifyField', 'abuse_filter_log', 'afl_namespace', + "$dir/db_patches/patch-afl-namespace_int.sql", true ] ); } else { $updater->addExtensionUpdate( [ - 'addIndex', 'abuse_filter_log', 'afl_wiki_timestamp', - "$dir/db_patches/patch-global_logging_wiki-index.sqlite.sql", true + 'modifyField', 'abuse_filter_log', 'afl_namespace', + "$dir/db_patches/patch-afl-namespace_int.sqlite.sql", true ] ); } + if ( $updater->getDB()->getType() === 'mysql' ) { + $updater->addExtensionUpdate( [ 'dropField', 'abuse_filter_log', + 'afl_log_id', "$dir/db_patches/patch-drop_afl_log_id.sql", true ] ); + } else { + $updater->addExtensionUpdate( [ 'dropField', 'abuse_filter_log', + 'afl_log_id', "$dir/db_patches/patch-drop_afl_log_id.sqlite.sql", true ] ); + } if ( $updater->getDB()->getType() == 'mysql' ) { $updater->addExtensionUpdate( [ - 'modifyField', 'abuse_filter_log', 'afl_namespace', - "$dir/db_patches/patch-afl-namespace_int.sql", true + 'addIndex', 'abuse_filter_log', 'filter_timestamp_full', + "$dir/db_patches/patch-split-afl_filter.sql", true ] ); } else { - /* - $updater->addExtensionUpdate( array( - 'modifyField', - 'abuse_filter_log', - 'afl_namespace', - "$dir/db_patches/patch-afl-namespace_int.sqlite.sql", - true - ) ); - */ - /* @todo Modify a column in sqlite, which do not support such - * things create backup, drop, create with new schema, copy, - * drop backup or simply see - * https://www.mediawiki.org/wiki/Manual:SQLite#About_SQLite : - * Several extensions are known to have database update or - * installation issues with SQLite: AbuseFilter, ... - */ + $updater->addExtensionUpdate( [ + 'addIndex', 'abuse_filter_log', 'filter_timestamp_full', + "$dir/db_patches/patch-split-afl_filter.sqlite.sql", true + ] ); } - } elseif ( $updater->getDB()->getType() == 'postgres' ) { + } elseif ( $updater->getDB()->getType() === 'postgres' ) { $updater->addExtensionUpdate( [ 'addTable', 'abuse_filter', "$dir/abusefilter.tables.pg.sql", true ] ); $updater->addExtensionUpdate( [ @@ -605,8 +566,6 @@ class AbuseFilterHooks { $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( [ 'changeField', 'abuse_filter_log', 'afl_namespace', "INTEGER", '' ] ); @@ -643,23 +602,34 @@ class AbuseFilterHooks { '(afl_rev_id)' ] ); $updater->addExtensionUpdate( [ - 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_log_id', - '(afl_log_id)' - ] ); - $updater->addExtensionUpdate( [ 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_wiki_timestamp', '(afl_wiki,afl_timestamp)' ] ); + $updater->addExtensionUpdate( [ + 'dropPgField', 'abuse_filter_log', 'afl_log_id' ] ); + $updater->addExtensionUpdate( [ + 'setDefault', 'abuse_filter_log', 'afl_filter', '' + ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter_log', 'afl_global', 'SMALLINT NOT NULL DEFAULT 0' ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter_log', 'afl_filter_id', 'INTEGER NOT NULL DEFAULT 0' ] ); + $updater->addExtensionUpdate( [ + 'addPgIndex', 'abuse_filter_log', 'abuse_filter_log_filter_timestamp_full', + '(afl_global, afl_filter_id, afl_timestamp)' ] ); } $updater->addExtensionUpdate( [ [ __CLASS__, 'createAbuseFilterUser' ] ] ); + $updater->addPostDatabaseUpdateMaintenance( 'NormalizeThrottleParameters' ); + $updater->addPostDatabaseUpdateMaintenance( 'FixOldLogEntries' ); + $updater->addPostDatabaseUpdateMaintenance( 'UpdateVarDumps' ); } /** * Updater callback to create the AbuseFilter user after the user tables have been updated. * @param DatabaseUpdater $updater */ - public static function createAbuseFilterUser( $updater ) { + public static function createAbuseFilterUser( DatabaseUpdater $updater ) { $username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text(); $user = User::newFromName( $username ); @@ -677,9 +647,12 @@ class AbuseFilterHooks { * @param array &$tools * @param SpecialPage $sp for context */ - public static function onContributionsToolLinks( $id, $nt, array &$tools, SpecialPage $sp ) { + public static function onContributionsToolLinks( $id, Title $nt, array &$tools, SpecialPage $sp ) { $username = $nt->getText(); - if ( $sp->getUser()->isAllowed( 'abusefilter-log' ) && !IP::isValidRange( $username ) ) { + if ( MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $sp->getUser(), 'abusefilter-log' ) + && !IPUtils::isValidRange( $username ) + ) { $linkRenderer = $sp->getLinkRenderer(); $tools['abuselog'] = $linkRenderer->makeLink( SpecialPage::getTitleFor( 'AbuseLog' ), @@ -702,7 +675,9 @@ class AbuseFilterHooks { array &$links ) { $user = $context->getUser(); - if ( $user->isAllowed( 'abusefilter-log' ) ) { + if ( MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $user, 'abusefilter-log' ) + ) { $links[] = $linkRenderer->makeLink( SpecialPage::getTitleFor( 'AbuseLog' ), $context->msg( 'abusefilter-log-linkonhistory' )->text(), @@ -713,18 +688,43 @@ class AbuseFilterHooks { } /** + * @param IContextSource $context + * @param LinkRenderer $linkRenderer + * @param string[] &$links + */ + public static function onUndeletePageToolLinks( + IContextSource $context, + LinkRenderer $linkRenderer, + array &$links + ) { + $pm = MediaWikiServices::getInstance()->getPermissionManager(); + $show = $pm->userHasRight( $context->getUser(), 'abusefilter-log' ); + $action = $context->getRequest()->getVal( 'action', 'view' ); + + // For 'history action', the link would be added by HistoryPageToolLinks hook. + if ( $show && $action !== 'history' ) { + $links[] = $linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseLog' ), + $context->msg( 'abusefilter-log-linkonundelete' )->text(), + [ 'title' => $context->msg( 'abusefilter-log-linkonundelete-text' )->text() ], + [ 'wpSearchTitle' => $context->getTitle()->getPrefixedText() ] + ); + } + } + + /** * Filter an upload. * * @param UploadBase $upload * @param User $user - * @param array $props + * @param array|null $props * @param string $comment * @param string $pageText * @param array|ApiMessage &$error * @return bool */ public static function onUploadVerifyUpload( UploadBase $upload, User $user, - array $props, $comment, $pageText, &$error + $props, $comment, $pageText, &$error ) { return self::filterUpload( 'upload', $upload, $user, $props, $comment, $pageText, $error ); } @@ -752,105 +752,61 @@ class AbuseFilterHooks { * @param string $action 'upload' or 'stashupload' * @param UploadBase $upload * @param User $user User performing the action - * @param array $props File properties, as returned by FSFile::getPropsFromPath() + * @param array|null $props File properties, as returned by MWFileProps::getPropsFromPath(). * @param string|null $summary Upload log comment (also used as edit summary) * @param string|null $text File description page text (only used for new uploads) * @param array|ApiMessage &$error * @return bool */ public static function filterUpload( $action, UploadBase $upload, User $user, - array $props, $summary, $text, &$error + $props, $summary, $text, &$error ) { $title = $upload->getTitle(); - - $vars = new AbuseFilterVariableHolder; - $vars->addHolders( - AbuseFilter::generateUserVars( $user ), - AbuseFilter::generateTitleVars( $title, 'PAGE' ) - ); - $vars->setVar( 'ACTION', $action ); - - // We use the hexadecimal version of the file sha1. - // Use UploadBase::getTempFileSha1Base36 so that we don't have to calculate the sha1 sum again - $sha1 = Wikimedia\base_convert( $upload->getTempFileSha1Base36(), 36, 16, 40 ); - - $vars->setVar( 'file_sha1', $sha1 ); - $vars->setVar( 'file_size', $upload->getFileSize() ); - - $vars->setVar( 'file_mime', $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'] ); - - // We only have the upload comment and page text when using the UploadVerifyUpload hook - if ( $summary !== null && $text !== null ) { - // This block is adapted from self::filterEdit() - if ( $title->exists() ) { - $page = WikiPage::factory( $title ); - $revision = $page->getRevision(); - if ( !$revision ) { - return true; - } - - $oldcontent = $revision->getContent( Revision::RAW ); - $oldtext = AbuseFilter::contentToString( $oldcontent ); - - // Cache article object so we can share a parse operation - $articleCacheKey = $title->getNamespace() . ':' . $title->getText(); - AFComputedVariable::$articleCache[$articleCacheKey] = $page; - - // Page text is ignored for uploads when the page already exists - $text = $oldtext; - } else { - $page = null; - $oldtext = ''; - } - - // Load vars for filters to check - $vars->setVar( 'summary', $summary ); - $vars->setVar( 'minor_edit', false ); - $vars->setVar( 'old_wikitext', $oldtext ); - $vars->setVar( 'new_wikitext', $text ); - // TODO: set old_content and new_content vars, use them - $vars->addHolders( AbuseFilter::getEditVars( $title, $page ) ); + if ( $title === null ) { + // T144265: This could happen for 'stashupload' if the specified title is invalid. + // Let UploadBase warn the user about that, and we'll filter later. + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->warning( __METHOD__ . " received a null title. Action: $action." ); + return true; } - $filter_result = AbuseFilter::filterAction( $vars, $title, 'default', $user ); + $vars = new AbuseFilterVariableHolder(); + $builder = new RunVariableGenerator( $vars, $user, $title ); + $vars = $builder->getUploadVars( $action, $upload, $summary, $text, $props ); + if ( $vars === null ) { + return true; + } + $runner = new AbuseFilterRunner( $user, $title, $vars, 'default' ); + $filterResult = $runner->run(); - if ( !$filter_result->isOK() ) { - $messageAndParams = $filter_result->getErrorsArray()[0]; - $apiResult = self::getApiResult( $filter_result ); - $error = ApiMessage::create( - $messageAndParams, - $apiResult['code'], - $apiResult - ); + if ( !$filterResult->isOK() ) { + // Produce a useful error message for API edits + $filterResultApi = self::getApiStatus( $filterResult ); + // @todo Return all errors instead of only the first one + $error = $filterResultApi->getErrors()[0]['message']; } - return $filter_result->isOK(); + return $filterResult->isOK(); } /** - * Adds global variables to the Javascript as needed + * For integration with the Renameuser extension. * - * @param array &$vars + * @param RenameuserSQL $renameUserSQL */ - public static function onMakeGlobalVariablesScript( array &$vars ) { - if ( isset( AbuseFilter::$editboxName ) && AbuseFilter::$editboxName !== null ) { - $vars['abuseFilterBoxName'] = AbuseFilter::$editboxName; - } - - if ( AbuseFilterViewExamine::$examineType !== null ) { - $vars['abuseFilterExamine'] = [ - 'type' => AbuseFilterViewExamine::$examineType, - 'id' => AbuseFilterViewExamine::$examineId, - ]; - } + public static function onRenameUserSQL( RenameuserSQL $renameUserSQL ) { + $renameUserSQL->tablesJob['abuse_filter'] = [ + RenameuserSQL::NAME_COL => 'af_user_text', + RenameuserSQL::UID_COL => 'af_user', + RenameuserSQL::TIME_COL => 'af_timestamp', + 'uniqueKey' => 'af_id' + ]; + $renameUserSQL->tablesJob['abuse_filter_history'] = [ + RenameuserSQL::NAME_COL => 'afh_user_text', + RenameuserSQL::UID_COL => 'afh_user', + RenameuserSQL::TIME_COL => 'afh_timestamp', + 'uniqueKey' => 'afh_id' + ]; } /** @@ -871,30 +827,79 @@ class AbuseFilterHooks { * @param Content $content * @param ParserOutput $output * @param string $summary - * @param User|null $user + * @param User $user */ public static function onParserOutputStashForEdit( - WikiPage $page, Content $content, ParserOutput $output, $summary = '', $user = null + WikiPage $page, Content $content, ParserOutput $output, string $summary, User $user ) { - $revision = $page->getRevision(); - if ( !$revision ) { - return; - } - - $text = AbuseFilter::contentToString( $content ); - $oldcontent = $revision->getContent( Revision::RAW ); - $user = $user ?: RequestContext::getMain()->getUser(); + // XXX: This makes the assumption that this method is only ever called for the main slot. + // Which right now holds true, but any more fancy MCR stuff will likely break here... + $slot = SlotRecord::MAIN; // 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, $text, $oldcontent ) { - $vars = self::newVariableHolderForEdit( - $user, $page->getTitle(), $page, $summary, $content, $text, $oldcontent - ); - AbuseFilter::filterAction( $vars, $page->getTitle(), 'default', $user, 'stash' ); + function () use ( + $user, + $page, + $summary, + $content, + $slot + ) { + $startTime = microtime( true ); + $vars = new AbuseFilterVariableHolder(); + $generator = new RunVariableGenerator( $vars, $user, $page->getTitle() ); + $vars = $generator->getStashEditVars( $content, $summary, $slot, $page ); + if ( !$vars ) { + return; + } + $runner = new AbuseFilterRunner( $user, $page->getTitle(), $vars, 'default' ); + $runner->runForStash(); + $totalTime = microtime( true ) - $startTime; + MediaWikiServices::getInstance()->getStatsdDataFactory() + ->timing( 'timing.stashAbuseFilter', $totalTime ); }, DeferredUpdates::PRESEND ); } + + /** + * Setup tables to emulate global filters, used in AbuseFilterConsequencesTest. + * + * @param IMaintainableDatabase $db + * @param string $prefix The prefix used in unit tests + * @suppress PhanUndeclaredClassConstant AbuseFilterConsequencesTest is in AutoloadClasses + * @suppress PhanUndeclaredClassStaticProperty AbuseFilterConsequencesTest is in AutoloadClasses + */ + public static function onUnitTestsAfterDatabaseSetup( IMaintainableDatabase $db, $prefix ) { + $externalPrefix = AbuseFilterConsequencesTest::DB_EXTERNAL_PREFIX; + if ( $db->tableExists( $externalPrefix . AbuseFilterConsequencesTest::$externalTables[0], __METHOD__ ) ) { + // Check a random table to avoid unnecessary table creations. See T155147. + return; + } + + foreach ( AbuseFilterConsequencesTest::$externalTables as $table ) { + // Don't create them as temporary, as we'll access the DB via another connection + $db->duplicateTableStructure( + "$prefix$table", + "$prefix$externalPrefix$table", + false, + __METHOD__ + ); + } + } + + /** + * Drop tables used for global filters in AbuseFilterConsequencesTest. + * Note: this has the same problem as T201290. + * + * @suppress PhanUndeclaredClassConstant AbuseFilterConsequencesTest is in AutoloadClasses + * @suppress PhanUndeclaredClassStaticProperty AbuseFilterConsequencesTest is in AutoloadClasses + */ + public static function onUnitTestsBeforeDatabaseTeardown() { + $db = wfGetDB( DB_MASTER ); + foreach ( AbuseFilterConsequencesTest::$externalTables as $table ) { + $db->dropTable( AbuseFilterConsequencesTest::DB_EXTERNAL_PREFIX . $table ); + } + } } diff --git a/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php b/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php index 769c27d3..8defb8b5 100644 --- a/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php +++ b/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php @@ -15,6 +15,7 @@ class AbuseFilterModifyLogFormatter extends LogFormatter { /** * @return array + * @suppress SecurityCheck-DoubleEscaped taint-check false positives */ protected function extractParameters() { $parameters = $this->entry->getParameters(); diff --git a/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php b/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php index ed72c5a4..343f5478 100644 --- a/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php +++ b/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php @@ -2,6 +2,8 @@ use MediaWiki\Auth\AbstractPreAuthenticationProvider; use MediaWiki\Auth\AuthenticationRequest; +use MediaWiki\Extension\AbuseFilter\VariableGenerator\RunVariableGenerator; +use MediaWiki\MediaWikiServices; class AbuseFilterPreAuthenticationProvider extends AbstractPreAuthenticationProvider { /** @@ -35,23 +37,22 @@ class AbuseFilterPreAuthenticationProvider extends AbstractPreAuthenticationProv * @return StatusValue */ protected function testUser( $user, $creator, $autocreate ) { - if ( $user->getName() == wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text() ) { + $startTime = microtime( true ); + if ( $user->getName() === wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text() ) { return StatusValue::newFatal( 'abusefilter-accountreserved' ); } - $vars = new AbuseFilterVariableHolder; - - // generateUserVars records $creator->getName() which would be the IP for unregistered users - if ( $creator->isLoggedIn() ) { - $vars->addHolders( AbuseFilter::generateUserVars( $creator ) ); - } - - $vars->setVar( 'ACTION', $autocreate ? 'autocreateaccount' : 'createaccount' ); - $vars->setVar( 'ACCOUNTNAME', $user->getName() ); + $title = SpecialPage::getTitleFor( 'Userlogin' ); + $vars = new AbuseFilterVariableHolder(); + $builder = new RunVariableGenerator( $vars, $creator, $title ); + $vars = $builder->getAccountCreationVars( $user, $autocreate ); // pass creator in explicitly to prevent recording the current user on autocreation - T135360 - $status = AbuseFilter::filterAction( $vars, SpecialPage::getTitleFor( 'Userlogin' ), - 'default', $creator ); + $runner = new AbuseFilterRunner( $creator, $title, $vars, 'default' ); + $status = $runner->run(); + + MediaWikiServices::getInstance()->getStatsdDataFactory() + ->timing( 'timing.createaccountAbuseFilter', microtime( true ) - $startTime ); return $status->getStatusValue(); } diff --git a/AbuseFilter/includes/AbuseFilterRightsLogFormatter.php b/AbuseFilter/includes/AbuseFilterRightsLogFormatter.php new file mode 100644 index 00000000..37255d2f --- /dev/null +++ b/AbuseFilter/includes/AbuseFilterRightsLogFormatter.php @@ -0,0 +1,42 @@ +<?php + +class AbuseFilterRightsLogFormatter extends LogFormatter { + + /** + * This method is identical to the parent, but it's redeclared to give grep a chance + * to find the messages. + * @inheritDoc + */ + protected function getMessageKey() { + $subtype = $this->entry->getSubtype(); + // Messages that can be used here: + // * logentry-rights-blockautopromote + // * logentry-rights-restoreautopromote + return "logentry-rights-$subtype"; + } + + /** + * @inheritDoc + */ + protected function extractParameters() { + $ret = []; + $ret[3] = $this->entry->getTarget()->getText(); + if ( $this->entry->getSubType() === 'blockautopromote' ) { + $parameters = $this->entry->getParameters(); + $duration = $parameters['7::duration']; + $ret[4] = $this->context->getLanguage()->formatDuration( $duration ); + } + return $ret; + } + + /** + * @inheritDoc + */ + protected function getMessageParameters() { + $params = parent::getMessageParameters(); + // remove "User:" prefix + $params[2] = $this->formatParameterValue( 'user-link', $this->entry->getTarget()->getText() ); + return $params; + } + +} diff --git a/AbuseFilter/includes/AbuseFilterRunner.php b/AbuseFilter/includes/AbuseFilterRunner.php new file mode 100644 index 00000000..eca20bae --- /dev/null +++ b/AbuseFilter/includes/AbuseFilterRunner.php @@ -0,0 +1,1434 @@ +<?php + +use MediaWiki\Block\DatabaseBlock; +use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; +use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator; +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; +use MediaWiki\Session\SessionManager; +use Wikimedia\IPUtils; +use Wikimedia\Rdbms\IDatabase; + +/** + * This class contains the logic for executing abuse filters and their actions. The entry points are + * run() and runForStash(). Note that run() can only be executed once on a given instance. + * @todo In a perfect world, every time this class gets constructed we should have a context + * source at hand. Unfortunately, this currently isn't true, as the hooks used for filtering + * don't pass a full context. If they did, this class would just extend ContextSource and use + * that to retrieve user, title, globals etc. + */ +class AbuseFilterRunner { + /** + * @var User The user who performed the action being filtered + */ + protected $user; + /** + * @var Title The title where the action being filtered was performed + */ + protected $title; + /** + * @var AbuseFilterVariableHolder The variables for the current action + */ + protected $vars; + /** + * @var string The group of filters to check (as defined in $wgAbuseFilterValidGroups) + */ + protected $group; + /** + * @var string The action we're filtering + */ + protected $action; + + /** + * @var array Data from per-filter profiling. Shape: + * [ filterName => [ 'time' => float, 'conds' => int, 'result' => bool ] ] + * @phan-var array<string,array{time:float,conds:int,result:bool}> + * + * Where 'timeTaken' is in seconds, 'result' is a boolean indicating whether the filter matched + * the action, and 'filterID' is "{prefix}-{ID}" ; Prefix should be empty for local + * filters. In stash mode this member is saved in cache, while in execute mode it's used to + * update profiling after checking all filters. + */ + protected $profilingData; + + /** + * @var AbuseFilterParser The parser instance to use to check all filters + * @protected Public for back-compat only, will be made protected. self::init already handles + * building a parser object. + */ + public $parser; + /** + * @var bool Whether a run() was already performed. Used to avoid multiple executions with the + * same members. + */ + private $executed = false; + + /** @var AbuseFilterHookRunner */ + private $hookRunner; + + /** + * @param User $user The user who performed the action being filtered + * @param Title $title The title where the action being filtered was performed + * @param AbuseFilterVariableHolder $vars The variables for the current action + * @param string $group The group of filters to check. It must be defined as so in + * $wgAbuseFilterValidGroups, or this will throw. + * @throws InvalidArgumentException + */ + public function __construct( User $user, Title $title, AbuseFilterVariableHolder $vars, $group ) { + global $wgAbuseFilterValidGroups; + if ( !in_array( $group, $wgAbuseFilterValidGroups ) ) { + throw new InvalidArgumentException( '$group must be defined in $wgAbuseFilterValidGroups' ); + } + if ( !$vars->varIsSet( 'action' ) ) { + throw new InvalidArgumentException( "The 'action' variable is not set." ); + } + $this->user = $user; + $this->title = $title; + $this->vars = $vars; + $this->vars->setLogger( LoggerFactory::getInstance( 'AbuseFilter' ) ); + $this->group = $group; + $this->action = $vars->getVar( 'action' )->toString(); + $this->hookRunner = AbuseFilterHookRunner::getRunner(); + } + + /** + * Inits variables and parser right before running + */ + private function init() { + // Add vars from extensions + $this->hookRunner->onAbuseFilterFilterAction( + $this->vars, + $this->title + ); + $this->hookRunner->onAbuseFilterAlterVariables( + $this->vars, + $this->title, + $this->user + ); + $generator = new VariableGenerator( $this->vars ); + $this->vars = $generator->addGenericVars()->getVariableHolder(); + + $this->vars->forFilter = true; + $this->vars->setVar( 'timestamp', (int)wfTimestamp( TS_UNIX ) ); + $this->parser = $this->getParser(); + $this->parser->setStatsd( MediaWikiServices::getInstance()->getStatsdDataFactory() ); + $this->profilingData = []; + } + + /** + * Shortcut method, so that it can be overridden in mocks. + * @return AbuseFilterParser + */ + protected function getParser() : AbuseFilterParser { + return AbuseFilter::getDefaultParser( $this->vars ); + } + + /** + * The main entry point of this class. This method runs all filters and takes their consequences. + * + * @param bool $allowStash Whether we are allowed to check the cache to see if there's a cached + * result of a previous execution for the same edit. + * @throws BadMethodCallException If run() was already called on this instance + * @return Status Good if no action has been taken, a fatal otherwise. + */ + public function run( $allowStash = true ) : Status { + global $wgAbuseFilterActions; + if ( $this->executed ) { + throw new BadMethodCallException( 'run() was already called on this instance.' ); + } + $this->executed = true; + $this->init(); + + $skipReasons = []; + $shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction( + $this->vars, $this->title, $this->user, $skipReasons + ); + if ( !$shouldFilter ) { + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->info( + 'Skipping action {action}. Reasons provided: {reasons}', + [ 'action' => $this->action, 'reasons' => implode( ', ', $skipReasons ) ] + ); + return Status::newGood(); + } + + $useStash = $allowStash && $this->action === 'edit'; + + $fromCache = false; + $result = []; + if ( $useStash ) { + $cacheData = $this->seekCache(); + if ( $cacheData !== false ) { + if ( isset( $wgAbuseFilterActions['tag'] ) && $wgAbuseFilterActions['tag'] ) { + // Merge in any tags to apply to recent changes entries + AbuseFilter::bufferTagsToSetByAction( $cacheData['tags'] ); + } + // Use cached vars (T176291) and profiling data (T191430) + $this->vars = AbuseFilterVariableHolder::newFromArray( $cacheData['vars'] ); + $result = [ + 'matches' => $cacheData['matches'], + 'runtime' => $cacheData['runtime'], + 'condCount' => $cacheData['condCount'], + 'profiling' => $cacheData['profiling'] + ]; + $fromCache = true; + } + } + + if ( !$fromCache ) { + $startTime = microtime( true ); + // Ensure there's no extra time leftover + AFComputedVariable::$profilingExtraTime = 0; + + // This also updates $this->profilingData and $this->parser->mCondCount used later + $matches = $this->checkAllFilters(); + $timeTaken = ( microtime( true ) - $startTime - AFComputedVariable::$profilingExtraTime ) * 1000; + $result = [ + 'matches' => $matches, + 'runtime' => $timeTaken, + 'condCount' => $this->parser->getCondCount(), + 'profiling' => $this->profilingData + ]; + } + '@phan-var array{matches:array,runtime:int,condCount:int,profiling:array} $result'; + + $matchedFilters = array_keys( array_filter( $result['matches'] ) ); + $allFilters = array_keys( $result['matches'] ); + + $this->profileExecution( $result, $matchedFilters, $allFilters ); + + if ( count( $matchedFilters ) === 0 ) { + return Status::newGood(); + } + + $status = $this->executeFilterActions( $matchedFilters ); + $actionsTaken = $status->getValue(); + + $this->addLogEntries( $actionsTaken ); + + return $status; + } + + /** + * Similar to run(), but runs in "stash" mode, which means filters are executed, no actions are + * taken, and the result is saved in cache to be later reused. This can only be used for edits, + * and not doing so will throw. + * + * @throws InvalidArgumentException + * @return Status Always a good status, since we're only saving data. + */ + public function runForStash() : Status { + if ( $this->action !== 'edit' ) { + throw new InvalidArgumentException( + __METHOD__ . " can only be called for edits, called for action {$this->action}." + ); + } + + $this->init(); + + $skipReasons = []; + $shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction( + $this->vars, $this->title, $this->user, $skipReasons + ); + if ( !$shouldFilter ) { + // Don't log it yet + return Status::newGood(); + } + + $cache = ObjectCache::getLocalClusterInstance(); + $stashKey = $this->getStashKey( $cache ); + + $startTime = microtime( true ); + // Ensure there's no extra time leftover + AFComputedVariable::$profilingExtraTime = 0; + + $matchedFilters = $this->checkAllFilters(); + // Save the filter stash result and do nothing further + $cacheData = [ + 'matches' => $matchedFilters, + 'tags' => AbuseFilter::$tagsToSet, + 'condCount' => $this->parser->getCondCount(), + 'runtime' => ( microtime( true ) - $startTime - AFComputedVariable::$profilingExtraTime ) * 1000, + 'vars' => $this->vars->dumpAllVars(), + 'profiling' => $this->profilingData + ]; + + $cache->set( $stashKey, $cacheData, $cache::TTL_MINUTE ); + $this->logCache( 'store', $stashKey ); + + return Status::newGood(); + } + + /** + * Search the cache to find data for a previous execution done for the current edit. + * + * @return false|array False on failure, the array with data otherwise + */ + protected function seekCache() { + $cache = ObjectCache::getLocalClusterInstance(); + $stashKey = $this->getStashKey( $cache ); + + $ret = $cache->get( $stashKey ); + $status = $ret !== false ? 'hit' : 'miss'; + $this->logCache( $status, $stashKey ); + + return $ret; + } + + /** + * Get the stash key for the current variables + * + * @param BagOStuff $cache + * @return string + */ + protected function getStashKey( BagOStuff $cache ) { + $inputVars = $this->vars->exportNonLazyVars(); + // Exclude noisy fields that have superficial changes + $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 ) ); + + return $cache->makeKey( + 'abusefilter', + 'check-stash', + $this->group, + $hash, + 'v2' + ); + } + + /** + * Log cache operations related to stashed edits, i.e. store, hit and miss + * + * @param string $type Either 'store', 'hit' or 'miss' + * @param string $key The cache key used + * @throws InvalidArgumentException + */ + protected function logCache( $type, $key ) { + if ( !in_array( $type, [ 'store', 'hit', 'miss' ] ) ) { + throw new InvalidArgumentException( '$type must be either "store", "hit" or "miss"' ); + } + $logger = LoggerFactory::getInstance( 'StashEdit' ); + // Bots do not use edit stashing, so avoid distorting the stats + $statsd = $this->user->isBot() + ? new NullStatsdDataFactory() + : MediaWikiServices::getInstance()->getStatsdDataFactory(); + + $logger->debug( __METHOD__ . ": cache $type for '{$this->title}' (key $key)." ); + $statsd->increment( "abusefilter.check-stash.$type" ); + } + + /** + * Returns an associative array of filters which were tripped + * + * @protected Public for back compat only; this will actually be made protected in the future. + * You should either rely on $this->run() or subclass this class. + * @todo This method should simply return an array with IDs of matched filters as values, + * since we always end up filtering it after calling this method. + * @return bool[] Map of (integer filter ID => bool) + */ + public function checkAllFilters() : array { + global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral, $wgAbuseFilterConditionLimit; + + // Ensure that we start fresh, see T193374 + $this->parser->resetCondCount(); + + $matchedFilters = []; + + foreach ( $this->getLocalFilters() as $row ) { + $matchedFilters[$row->af_id] = $this->checkFilter( $row ); + } + + if ( $wgAbuseFilterCentralDB && !$wgAbuseFilterIsCentral ) { + foreach ( $this->getGlobalFilters() as $row ) { + $matchedFilters[ AbuseFilter::buildGlobalName( $row->af_id ) ] = + $this->checkFilter( $row, true ); + } + } + + // Tag the action if the condition limit was hit + if ( $this->parser->getCondCount() > $wgAbuseFilterConditionLimit ) { + $actionID = $this->getTaggingID(); + AbuseFilter::bufferTagsToSetByAction( [ $actionID => [ 'abusefilter-condition-limit' ] ] ); + } + + return $matchedFilters; + } + + /** + * @return array abuse_filter DB rows + */ + protected function getLocalFilters() : array { + return iterator_to_array( wfGetDB( DB_REPLICA )->select( + 'abuse_filter', + AbuseFilter::ALL_ABUSE_FILTER_FIELDS, + [ + 'af_enabled' => 1, + 'af_deleted' => 0, + 'af_group' => $this->group, + ], + __METHOD__ + ) ); + } + + /** + * @return array abuse_filter rows from the foreign DB + */ + protected function getGlobalFilters() : array { + $globalRulesKey = AbuseFilter::getGlobalRulesKey( $this->group ); + $fname = __METHOD__; + + return MediaWikiServices::getInstance()->getMainWANObjectCache()->getWithSetCallback( + $globalRulesKey, + WANObjectCache::TTL_WEEK, + function () use ( $fname ) { + $fdb = AbuseFilter::getCentralDB( DB_REPLICA ); + + return iterator_to_array( $fdb->select( + 'abuse_filter', + AbuseFilter::ALL_ABUSE_FILTER_FIELDS, + [ + 'af_enabled' => 1, + 'af_deleted' => 0, + 'af_global' => 1, + 'af_group' => $this->group, + ], + $fname + ) ); + }, + [ + 'checkKeys' => [ $globalRulesKey ], + 'lockTSE' => 300, + 'version' => 1 + ] + ); + } + + /** + * Check the conditions of a single filter, and profile it if $this->executeMode is true + * + * @param stdClass $row + * @param bool $global + * @return bool + */ + protected function checkFilter( $row, $global = false ) { + $filterName = AbuseFilter::buildGlobalName( $row->af_id, $global ); + + $startConds = $this->parser->getCondCount(); + $startTime = microtime( true ); + $origExtraTime = AFComputedVariable::$profilingExtraTime; + + // Store the row somewhere convenient + AbuseFilter::cacheFilter( $filterName, $row ); + + $pattern = trim( $row->af_pattern ); + $this->parser->setFilter( $filterName ); + $result = $this->parser->checkConditions( $pattern, true, $filterName ); + + $actualExtra = AFComputedVariable::$profilingExtraTime - $origExtraTime; + $timeTaken = 1000 * ( microtime( true ) - $startTime - $actualExtra ); + $condsUsed = $this->parser->getCondCount() - $startConds; + + $this->profilingData[$filterName] = [ + 'time' => $timeTaken, + 'conds' => $condsUsed, + 'result' => $result + ]; + + return $result; + } + + /** + * @param array $result Result of the execution, as created in run() + * @param string[] $matchedFilters + * @param string[] $allFilters + */ + protected function profileExecution( array $result, array $matchedFilters, array $allFilters ) { + $this->checkResetProfiling( $allFilters ); + $this->recordRuntimeProfilingResult( + count( $allFilters ), + $result['condCount'], + $result['runtime'] + ); + $this->recordPerFilterProfiling( $result['profiling'] ); + $this->recordStats( $result['condCount'], $result['runtime'], (bool)$matchedFilters ); + } + + /** + * Check if profiling data for all filters is lesser than the limit. If not, delete it and + * also delete per-filter profiling for all filters. Note that we don't need to reset it for + * disabled filters too, as their profiling data will be reset upon re-enabling anyway. + * + * @param array $allFilters + */ + protected function checkResetProfiling( array $allFilters ) { + global $wgAbuseFilterProfileActionsCap; + + $profileKey = AbuseFilter::filterProfileGroupKey( $this->group ); + $stash = MediaWikiServices::getInstance()->getMainObjectStash(); + + $profile = $stash->get( $profileKey ); + $total = $profile['total'] ?? 0; + + if ( $total > $wgAbuseFilterProfileActionsCap ) { + $stash->delete( $profileKey ); + foreach ( $allFilters as $filter ) { + AbuseFilter::resetFilterProfile( $filter ); + } + } + } + + /** + * Record per-filter profiling, for all filters + * + * @param array $data Profiling data, as stored in $this->profilingData + * @phan-param array<string,array{time:float,conds:int,result:bool}> $data + */ + protected function recordPerFilterProfiling( array $data ) { + global $wgAbuseFilterSlowFilterRuntimeLimit; + + foreach ( $data as $filterName => $params ) { + list( $filterID, $global ) = AbuseFilter::splitGlobalName( $filterName ); + if ( !$global ) { + // @todo Maybe add a parameter to recordProfilingResult to record global filters + // data separately (in the foreign wiki) + $this->recordProfilingResult( $filterID, $params['time'], $params['conds'], $params['result'] ); + } + + if ( $params['time'] > $wgAbuseFilterSlowFilterRuntimeLimit ) { + $this->recordSlowFilter( $filterName, $params['time'], $params['conds'], $params['result'] ); + } + } + } + + /** + * Record per-filter profiling data + * + * @param int $filter + * @param float $time Time taken, in milliseconds + * @param int $conds + * @param bool $matched + */ + protected function recordProfilingResult( $filter, $time, $conds, $matched ) { + // Defer updates to avoid massive (~1 second) edit time increases + DeferredUpdates::addCallableUpdate( function () use ( $filter, $time, $conds, $matched ) { + $stash = MediaWikiServices::getInstance()->getMainObjectStash(); + $profileKey = AbuseFilter::filterProfileKey( $filter ); + $profile = $stash->get( $profileKey ); + + if ( $profile !== false ) { + // Number of observed executions of this filter + $profile['count']++; + if ( $matched ) { + // Number of observed matches of this filter + $profile['matches']++; + } + // Total time spent on this filter from all observed executions + $profile['total-time'] += $time; + // Total number of conditions for this filter from all executions + $profile['total-cond'] += $conds; + } else { + $profile = [ + 'count' => 1, + 'matches' => (int)$matched, + 'total-time' => $time, + 'total-cond' => $conds + ]; + } + // Note: It is important that all key information be stored together in a single + // memcache entry to avoid race conditions where competing Apache instances + // partially overwrite the stats. + $stash->set( $profileKey, $profile, 3600 ); + } ); + } + + /** + * Logs slow filter's runtime data for later analysis + * + * @param string $filterId + * @param float $runtime + * @param int $totalConditions + * @param bool $matched + */ + protected function recordSlowFilter( $filterId, $runtime, $totalConditions, $matched ) { + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->info( + 'Edit filter {filter_id} on {wiki} is taking longer than expected', + [ + 'wiki' => WikiMap::getCurrentWikiDbDomain()->getId(), + 'filter_id' => $filterId, + 'title' => $this->title->getPrefixedText(), + 'runtime' => $runtime, + 'matched' => $matched, + 'total_conditions' => $totalConditions + ] + ); + } + + /** + * Update global statistics + * + * @param int $condsUsed The amount of used conditions + * @param float $totalTime Time taken, in milliseconds + * @param bool $anyMatch Whether at least one filter matched the action + */ + protected function recordStats( $condsUsed, $totalTime, $anyMatch ) { + $profileKey = AbuseFilter::filterProfileGroupKey( $this->group ); + $stash = MediaWikiServices::getInstance()->getMainObjectStash(); + + // Note: All related data is stored in a single memcache entry and updated via merge() + // to avoid race conditions where partial updates on competing instances corrupt the data. + $stash->merge( + $profileKey, + function ( $cache, $key, $profile ) use ( $condsUsed, $totalTime, $anyMatch ) { + global $wgAbuseFilterConditionLimit; + + if ( $profile === false ) { + $profile = [ + // Total number of actions observed + 'total' => 0, + // Number of actions ending by exceeding condition limit + 'overflow' => 0, + // Total time of execution of all observed actions + 'total-time' => 0, + // Total number of conditions from all observed actions + 'total-cond' => 0, + // Total number of filters matched + 'matches' => 0 + ]; + } + + $profile['total']++; + $profile['total-time'] += $totalTime; + $profile['total-cond'] += $condsUsed; + + // Increment overflow counter, if our condition limit overflowed + if ( $condsUsed > $wgAbuseFilterConditionLimit ) { + $profile['overflow']++; + } + + // Increment counter by 1 if there was at least one match + if ( $anyMatch ) { + $profile['matches']++; + } + + return $profile; + }, + AbuseFilter::$statsStoragePeriod + ); + } + + /** + * Record runtime profiling data for all filters together + * + * @param int $totalFilters + * @param int $totalConditions + * @param float $runtime + */ + protected function recordRuntimeProfilingResult( $totalFilters, $totalConditions, $runtime ) { + $keyPrefix = 'abusefilter.runtime-profile.' . WikiMap::getCurrentWikiDbDomain()->getId() . '.'; + + $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $statsd->timing( $keyPrefix . 'runtime', $runtime ); + $statsd->timing( $keyPrefix . 'total_filters', $totalFilters ); + $statsd->timing( $keyPrefix . 'total_conditions', $totalConditions ); + } + + /** + * Executes a set of actions. + * + * @param string[] $filters + * @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. + */ + protected function executeFilterActions( array $filters ) : Status { + global $wgMainCacheType, $wgAbuseFilterDisallowGlobalLocalBlocks, $wgAbuseFilterRestrictions, + $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration; + + $actionsByFilter = AbuseFilter::getConsequencesForFilters( $filters ); + $actionsTaken = array_fill_keys( $filters, [] ); + + $messages = []; + // Accumulator to track max block to issue + $maxExpiry = -1; + + foreach ( $actionsByFilter as $filter => $actions ) { + // Special-case handling for warnings. + $filterPublicComments = AbuseFilter::getFilter( $filter )->af_public_comments; + + $isGlobalFilter = AbuseFilter::splitGlobalName( $filter )[1]; + + // 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']; + $throttleId = array_shift( $parameters ); + list( $rateCount, $ratePeriod ) = explode( ',', array_shift( $parameters ) ); + $rateCount = (int)$rateCount; + $ratePeriod = (int)$ratePeriod; + + $hitThrottle = false; + + // The rest are throttle-types. + foreach ( $parameters as $throttleType ) { + $hitThrottle = $hitThrottle || $this->isThrottled( + $throttleId, $throttleType, $rateCount, $ratePeriod, $isGlobalFilter ); + } + + unset( $actions['throttle'] ); + if ( !$hitThrottle ) { + $actionsTaken[$filter][] = 'throttle'; + continue; + } + } + + if ( $wgAbuseFilterDisallowGlobalLocalBlocks && $isGlobalFilter ) { + $actions = array_diff_key( $actions, array_filter( $wgAbuseFilterRestrictions ) ); + } + + if ( !empty( $actions['warn'] ) ) { + $parameters = $actions['warn']['parameters']; + // 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( $this->title->getPrefixedText() ) . + '-' . $filter . '-' . $this->action; + + // Make sure the session is started prior to using it + $session = SessionManager::getGlobalSession(); + $session->persist(); + + if ( !isset( $session[$warnKey] ) || !$session[$warnKey] ) { + $session[$warnKey] = true; + + $msg = $parameters[0] ?? 'abusefilter-warning'; + $messages[] = [ $msg, $filterPublicComments, $filter ]; + + $actionsTaken[$filter][] = 'warn'; + + // Don't do anything else. + continue; + } 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'] ) ) { + $parameters = $actions['block']['parameters']; + + if ( count( $parameters ) === 3 ) { + // New type of filters with custom block + if ( $this->user->isAnon() ) { + $expiry = $parameters[1]; + } else { + $expiry = $parameters[2]; + } + } else { + // Old type with fixed expiry + if ( $this->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 ( $maxExpiry === -1 || $currentExpiry > SpecialBlock::parseExpiryInput( $maxExpiry ) ) { + // Save the parameters to issue the block with + $maxExpiry = $expiry; + $blockValues = [ + AbuseFilter::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 = $this->takeConsequenceAction( + $action, + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive + $info['parameters'], + AbuseFilter::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 ) { + // @phan-suppress-next-line PhanTypeMismatchArgumentNullable + $this->doBlock( $blockValues[0], $blockValues[1], $maxExpiry, $blockValues[2] ); + $message = [ + 'abusefilter-blocked-display', + $blockValues[0], + $blockValues[1] + ]; + // Manually add the message. If we're here, there is one. + $messages[] = $message; + // @phan-suppress-next-line PhanTypeMismatchDimAssignment + $actionsTaken[$blockValues[1]][] = 'block'; + } + + return $this->buildStatus( $actionsTaken, $messages ); + } + + /** + * @param string $throttleId + * @param string $types + * @param int $rateCount + * @param int $ratePeriod + * @param bool $global + * @return bool + */ + protected function isThrottled( + $throttleId, + $types, + int $rateCount, + int $ratePeriod, + $global = false + ) { + $stash = MediaWikiServices::getInstance()->getMainObjectStash(); + $key = $this->throttleKey( $throttleId, $types, $global ); + $count = (int)$stash->get( $key ); + + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->debug( "Got value $count for throttle key $key" ); + + $count = $stash->incrWithInit( $key, $ratePeriod ); + + if ( $count > $rateCount ) { + $logger->debug( "Throttle $key hit value $count -- maximum is $rateCount." ); + return true; + } + $logger->debug( "Throttle $key not hit!" ); + return false; + } + + /** + * @param string $throttleId + * @param string $type + * @param bool $global + * @return string + */ + protected function throttleKey( $throttleId, $type, $global = false ) { + global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB; + + $types = explode( ',', $type ); + + $identifiers = []; + + foreach ( $types as $subtype ) { + $identifiers[] = $this->throttleIdentifier( $subtype ); + } + + $identifier = sha1( implode( ':', $identifiers ) ); + + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + if ( $global && !$wgAbuseFilterIsCentral ) { + return $cache->makeGlobalKey( + 'abusefilter', 'throttle', $wgAbuseFilterCentralDB, $throttleId, $type, $identifier + ); + } + + return $cache->makeKey( 'abusefilter', 'throttle', $throttleId, $type, $identifier ); + } + + /** + * @param string $type + * @return int|string + */ + protected function throttleIdentifier( $type ) { + $request = RequestContext::getMain()->getRequest(); + + switch ( $type ) { + case 'ip': + $identifier = $request->getIP(); + break; + case 'user': + $identifier = $this->user->getId(); + break; + case 'range': + $identifier = substr( IPUtils::toHex( $request->getIP() ), 0, 4 ); + break; + case 'creationdate': + $reg = (int)$this->user->getRegistration(); + $identifier = $reg - ( $reg % 86400 ); + break; + case 'editcount': + // Hack for detecting different single-purpose accounts. + $identifier = (int)$this->user->getEditCount(); + break; + case 'site': + $identifier = 1; + break; + case 'page': + $identifier = $this->title->getPrefixedText(); + break; + default: + // Should never happen + // @codeCoverageIgnoreStart + $identifier = 0; + // @codeCoverageIgnoreEnd + } + + return $identifier; + } + + /** + * @param string $action + * @param array $parameters + * @param string $ruleDescription + * @param int|string $ruleNumber + * + * @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. + */ + protected function takeConsequenceAction( $action, $parameters, $ruleDescription, $ruleNumber ) { + global $wgAbuseFilterCustomActionsHandlers, $wgAbuseFilterBlockAutopromoteDuration; + + $message = null; + + switch ( $action ) { + case 'disallow': + $msg = $parameters[0] ?? 'abusefilter-disallowed'; + $message = [ $msg, $ruleDescription, $ruleNumber ]; + break; + case 'rangeblock': + $this->doRangeBlock( $ruleDescription, $ruleNumber, '1 week' ); + + $message = [ + 'abusefilter-blocked-display', + $ruleDescription, + $ruleNumber + ]; + break; + case 'degroup': + if ( !$this->user->isAnon() ) { + // Pull the groups from the VariableHolder, so that they will always be computed. + // This allow us to pull the groups from the VariableHolder to undo the degroup + // via Special:AbuseFilter/revert. + $groups = $this->vars->getVar( 'user_groups', AbuseFilterVariableHolder::GET_LAX ); + if ( $groups->type !== AFPData::DARRAY ) { + // Somehow, the variable wasn't set + $groups = $this->user->getEffectiveGroups(); + $this->vars->setVar( 'user_groups', $groups ); + } else { + $groups = $groups->toNative(); + } + $this->vars->setVar( 'user_groups', $groups ); + + foreach ( $groups as $group ) { + $this->user->removeGroup( $group ); + } + + $message = [ + 'abusefilter-degrouped', + $ruleDescription, + $ruleNumber + ]; + + // Don't log it if there aren't any groups being removed! + if ( !count( $groups ) ) { + break; + } + + $logEntry = new ManualLogEntry( 'rights', 'rights' ); + $logEntry->setPerformer( AbuseFilter::getFilterUser() ); + $logEntry->setTarget( $this->user->getUserPage() ); + $logEntry->setComment( + wfMessage( + 'abusefilter-degroupreason', + $ruleDescription, + $ruleNumber + )->inContentLanguage()->text() + ); + $logEntry->setParameters( [ + '4::oldgroups' => $groups, + '5::newgroups' => [] + ] ); + $logEntry->publish( $logEntry->insert() ); + } + + break; + case 'blockautopromote': + if ( !$this->user->isAnon() ) { + $duration = $wgAbuseFilterBlockAutopromoteDuration * 86400; + $blocked = AbuseFilter::blockAutoPromote( + $this->user, + wfMessage( + 'abusefilter-blockautopromotereason', + $ruleDescription, + $ruleNumber + )->inContentLanguage()->text(), + $duration + ); + + if ( $blocked ) { + $message = [ + 'abusefilter-autopromote-blocked', + $ruleDescription, + $ruleNumber, + $duration + ]; + } else { + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->warning( + 'Cannot block autopromotion to {target}', + [ 'target' => $this->user->getName() ] + ); + } + } + break; + + case 'block': + // Do nothing, handled at the end of executeFilterActions. Here for completeness. + break; + + case 'tag': + // Mark with a tag on recentchanges. + $actionID = $this->getTaggingID(); + AbuseFilter::bufferTagsToSetByAction( [ $actionID => $parameters ] ); + break; + default: + if ( isset( $wgAbuseFilterCustomActionsHandlers[$action] ) ) { + $customFunction = $wgAbuseFilterCustomActionsHandlers[$action]; + if ( is_callable( $customFunction ) ) { + $msg = call_user_func( + $customFunction, + $action, + $parameters, + $this->title, + $this->vars, + $ruleDescription, + $ruleNumber + ); + } + if ( isset( $msg ) ) { + $message = [ $msg ]; + } + } else { + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->warning( "Unrecognised action $action" ); + } + } + + return $message; + } + + /** + * @param string $ruleDesc + * @param string|int $ruleNumber + * @param string $expiry + */ + private function doRangeBlock( $ruleDesc, $ruleNumber, $expiry ) { + global $wgAbuseFilterRangeBlockSize, $wgBlockCIDRLimit; + + $ip = RequestContext::getMain()->getRequest()->getIP(); + $type = IPUtils::isIPv6( $ip ) ? 'IPv6' : 'IPv4'; + $CIDRsize = max( $wgAbuseFilterRangeBlockSize[$type], $wgBlockCIDRLimit[$type] ); + $blockCIDR = $ip . '/' . $CIDRsize; + + $target = IPUtils::sanitizeRange( $blockCIDR ); + $autoblock = false; + $this->doBlockInternal( $ruleDesc, $ruleNumber, $target, $expiry, $autoblock, false ); + } + + /** + * @param string $ruleDesc + * @param string|int $ruleNumber + * @param string $expiry + * @param bool $preventsTalk + */ + private function doBlock( $ruleDesc, $ruleNumber, $expiry, $preventsTalk ) { + $target = $this->user->getName(); + $autoblock = true; + $this->doBlockInternal( $ruleDesc, $ruleNumber, $target, $expiry, $autoblock, $preventsTalk ); + } + + /** + * Perform a block by the AbuseFilter system user + * @param string $ruleDesc + * @param int|string $ruleNumber + * @param string $target + * @param string $expiry + * @param bool $isAutoBlock + * @param bool $preventEditOwnUserTalk + */ + private function doBlockInternal( + $ruleDesc, + $ruleNumber, + $target, + $expiry, + $isAutoBlock, + $preventEditOwnUserTalk + ) { + $filterUser = AbuseFilter::getFilterUser(); + $reason = wfMessage( + 'abusefilter-blockreason', + $ruleDesc, $ruleNumber + )->inContentLanguage()->text(); + + $block = new DatabaseBlock(); + $block->setTarget( $target ); + $block->setBlocker( $filterUser ); + $block->setReason( $reason ); + $block->isHardblock( false ); + $block->isAutoblocking( $isAutoBlock ); + $block->isCreateAccountBlocked( true ); + $block->isUsertalkEditAllowed( !$preventEditOwnUserTalk ); + $block->setExpiry( SpecialBlock::parseExpiryInput( $expiry ) ); + + $success = $block->insert(); + + if ( $success ) { + // Log it only if the block was successful + $logParams = []; + $logParams['5::duration'] = ( $block->getExpiry() === 'infinity' ) + ? 'indefinite' + : $expiry; + $flags = [ 'nocreate' ]; + if ( !$block->isAutoblocking() && !IPUtils::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() ); + } + } + + /** + * 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 of arrays, where each array contains a message key + * followed by any message parameters. + * + * @return Status + */ + protected function buildStatus( array $actionsTaken, array $messages ) : Status { + $status = Status::newGood( $actionsTaken ); + + foreach ( $messages as $msg ) { + $status->fatal( ...$msg ); + } + + return $status; + } + + /** + * Creates a template to use for logging taken actions + * + * @return array + */ + protected function buildLogTemplate() : array { + global $wgAbuseFilterLogIP; + + $request = RequestContext::getMain()->getRequest(); + // If $this->user isn't safe to load (e.g. a failure during + // AbortAutoAccount), create a dummy anonymous user instead. + $user = $this->user->isSafeToLoad() ? $this->user : new User; + // Create a template + $logTemplate = [ + 'afl_user' => $user->getId(), + 'afl_user_text' => $user->getName(), + 'afl_timestamp' => wfGetDB( DB_REPLICA )->timestamp(), + 'afl_namespace' => $this->title->getNamespace(), + 'afl_title' => $this->title->getDBkey(), + 'afl_action' => $this->action, + 'afl_ip' => $wgAbuseFilterLogIP ? $request->getIP() : '' + ]; + // Hack to avoid revealing IPs of people creating accounts + if ( + !$user->getId() && + ( $this->action === 'createaccount' || $this->action === 'autocreateaccount' ) + ) { + $logTemplate['afl_user_text'] = $this->vars->getVar( 'accountname' )->toString(); + } + return $logTemplate; + } + + /** + * Create and publish log entries for taken actions + * + * @param array[] $actionsTaken + * @todo Split this method + */ + protected function addLogEntries( array $actionsTaken ) { + $dbw = wfGetDB( DB_MASTER ); + $logTemplate = $this->buildLogTemplate(); + $centralLogTemplate = [ + 'afl_wiki' => WikiMap::getCurrentWikiDbDomain()->getId(), + ]; + + $logRows = []; + $centralLogRows = []; + $loggedLocalFilters = []; + $loggedGlobalFilters = []; + + foreach ( $actionsTaken as $filter => $actions ) { + list( $filterID, $global ) = AbuseFilter::splitGlobalName( $filter ); + $thisLog = $logTemplate; + $thisLog['afl_filter'] = $filter; + $thisLog['afl_actions'] = implode( ',', $actions ); + + // Don't log if we were only throttling. + if ( $thisLog['afl_actions'] !== 'throttle' ) { + $logRows[] = $thisLog; + // Global logging + if ( $global ) { + $centralLog = $thisLog + $centralLogTemplate; + $centralLog['afl_filter'] = $filterID; + $centralLog['afl_title'] = $this->title->getPrefixedText(); + $centralLog['afl_namespace'] = 0; + + $centralLogRows[] = $centralLog; + $loggedGlobalFilters[] = $filterID; + } else { + $loggedLocalFilters[] = $filter; + } + } + } + + if ( !count( $logRows ) ) { + return; + } + + // Only store the var dump if we're actually going to add log rows. + $varDump = AbuseFilter::storeVarDump( $this->vars ); + $varDump = "tt:$varDump"; + + $localLogIDs = []; + global $wgAbuseFilterNotifications, $wgAbuseFilterNotificationsPrivate; + foreach ( $logRows as $data ) { + $data['afl_var_dump'] = $varDump; + $dbw->insert( 'abuse_filter_log', $data, __METHOD__ ); + $localLogIDs[] = $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 ); + $entry->setTarget( $this->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 + ) { + global $wgCheckUserLogAdditionalRights; + $wgCheckUserLogAdditionalRights[] = 'abusefilter-view'; + $rc = $entry->getRecentChange(); + CheckUserHooks::updateCheckUserData( $rc ); + } + + if ( $wgAbuseFilterNotifications !== false ) { + list( $filterID, $global ) = AbuseFilter::splitGlobalName( $data['afl_filter'] ); + if ( AbuseFilter::filterHidden( $filterID, $global ) && !$wgAbuseFilterNotificationsPrivate ) { + continue; + } + $this->publishEntry( $dbw, $entry, $wgAbuseFilterNotifications ); + } + } + + $method = __METHOD__; + + if ( count( $loggedLocalFilters ) ) { + // Update hit-counter. + $dbw->onTransactionPreCommitOrIdle( + function () use ( $dbw, $loggedLocalFilters, $method ) { + $dbw->update( 'abuse_filter', + [ 'af_hit_count=af_hit_count+1' ], + [ 'af_id' => $loggedLocalFilters ], + $method + ); + }, + $method + ); + } + + $globalLogIDs = []; + + // Global stuff + if ( count( $loggedGlobalFilters ) ) { + $this->vars->computeDBVars(); + $globalVarDump = AbuseFilter::storeVarDump( $this->vars, true ); + $globalVarDump = "tt:$globalVarDump"; + foreach ( $centralLogRows as $index => $data ) { + $centralLogRows[$index]['afl_var_dump'] = $globalVarDump; + } + + $fdb = AbuseFilter::getCentralDB( DB_MASTER ); + + foreach ( $centralLogRows as $row ) { + $fdb->insert( 'abuse_filter_log', $row, __METHOD__ ); + $globalLogIDs[] = $fdb->insertId(); + } + + $fdb->onTransactionPreCommitOrIdle( + function () use ( $fdb, $loggedGlobalFilters, $method ) { + $fdb->update( 'abuse_filter', + [ 'af_hit_count=af_hit_count+1' ], + [ 'af_id' => $loggedGlobalFilters ], + $method + ); + }, + $method + ); + } + + AbuseFilter::$logIds[ $this->title->getPrefixedText() ] = [ + 'local' => $localLogIDs, + 'global' => $globalLogIDs + ]; + + $this->checkEmergencyDisable( $loggedLocalFilters ); + } + + /** + * Like LogEntry::publish, but doesn't require an ID (which we don't have) and skips the + * tagging part + * + * @param IDatabase $dbw To cancel the callback if the log insertion fails + * @param ManualLogEntry $entry + * @param string $to One of 'udp', 'rc' and 'rcandudp' + */ + private function publishEntry( IDatabase $dbw, ManualLogEntry $entry, $to ) { + DeferredUpdates::addCallableUpdate( + function () use ( $entry, $to ) { + $rc = $entry->getRecentChange(); + + if ( $to === 'rc' || $to === 'rcandudp' ) { + $rc->save( $rc::SEND_NONE ); + } + if ( $to === 'udp' || $to === 'rcandudp' ) { + $rc->notifyRCFeeds(); + } + }, + DeferredUpdates::POSTSEND, + $dbw + ); + } + + /** + * Determine whether a filter must be throttled, i.e. its potentially dangerous + * actions must be disabled. + * + * @param string[] $filters The filters to check + */ + protected function checkEmergencyDisable( array $filters ) { + $stash = MediaWikiServices::getInstance()->getMainObjectStash(); + // @ToDo this is an amount between 1 and AbuseFilterProfileActionsCap, which means that the + // reliability of this number may strongly vary. We should instead use a fixed one. + $groupProfile = $stash->get( AbuseFilter::filterProfileGroupKey( $this->group ) ); + $totalActions = $groupProfile['total']; + + foreach ( $filters as $filter ) { + $threshold = AbuseFilter::getEmergencyValue( 'threshold', $this->group ); + $hitCountLimit = AbuseFilter::getEmergencyValue( 'count', $this->group ); + $maxAge = AbuseFilter::getEmergencyValue( 'age', $this->group ); + + $filterProfile = $stash->get( AbuseFilter::filterProfileKey( $filter ) ); + $matchCount = $filterProfile['matches'] ?? 1; + + // Figure out if the filter is subject to being throttled. + $filterAge = (int)wfTimestamp( TS_UNIX, AbuseFilter::getFilter( $filter )->af_timestamp ); + $exemptTime = $filterAge + $maxAge; + + if ( $totalActions && $exemptTime > time() && $matchCount > $hitCountLimit && + ( $matchCount / $totalActions ) > $threshold + ) { + // More than $wgAbuseFilterEmergencyDisableCount matches, constituting more than + // $threshold (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 + ); + } + ) + ); + } + } + } + + /** + * Helper function to get the ID used to identify an action for later tagging it. + * @return string + */ + protected function getTaggingID() { + if ( strpos( $this->action, 'createaccount' ) === false ) { + $username = $this->user->getName(); + $actionTitle = $this->title; + } else { + $username = $this->vars->getVar( 'accountname' )->toString(); + $actionTitle = Title::makeTitleSafe( NS_USER, $username ); + } + '@phan-var Title $actionTitle'; + + return AbuseFilter::getTaggingActionId( $this->action, $actionTitle, $username ); + } +} diff --git a/AbuseFilter/includes/AbuseFilterServices.php b/AbuseFilter/includes/AbuseFilterServices.php new file mode 100644 index 00000000..a028c5aa --- /dev/null +++ b/AbuseFilter/includes/AbuseFilterServices.php @@ -0,0 +1,15 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter; + +use MediaWiki\MediaWikiServices; + +class AbuseFilterServices { + /** + * Conveniency wrapper for strong typing + * @return KeywordsManager + */ + public static function getKeywordsManager() : KeywordsManager { + return MediaWikiServices::getInstance()->getService( KeywordsManager::SERVICE_NAME ); + } +} diff --git a/AbuseFilter/includes/AbuseFilterVariableHolder.php b/AbuseFilter/includes/AbuseFilterVariableHolder.php index 498c7b4e..e8dfd2fd 100644 --- a/AbuseFilter/includes/AbuseFilterVariableHolder.php +++ b/AbuseFilter/includes/AbuseFilterVariableHolder.php @@ -1,21 +1,80 @@ <?php +use MediaWiki\Extension\AbuseFilter\AbuseFilterServices; +use MediaWiki\Extension\AbuseFilter\KeywordsManager; +use Psr\Log\LoggerInterface; + class AbuseFilterVariableHolder { - /** @var (AFPData|AFComputedVariable)[] */ + /** + * Used in self::getVar() to determine what to do if the requested variable is missing. See + * the docs of that method for an explanation. + */ + public const GET_LAX = 0; + public const GET_STRICT = 1; + public const GET_BC = 2; + + /** @var KeywordsManager */ + private $keywordsManager; + + /** @var LoggerInterface */ + private $logger; + + /** + * @var (AFPData|AFComputedVariable)[] + * @fixme This should be private, but it isn't because of T231542: there are serialized instances + * stored in the DB, and mVars wouldn't be available in HHVM after deserializing them (T213006) + */ public $mVars = []; - /** @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 bool Whether this object is being used for an ongoing action being filtered */ + public $forFilter = false; + + /** + * @param KeywordsManager|null $keywordsManager Optional for BC + */ + public function __construct( KeywordsManager $keywordsManager = null ) { + $this->keywordsManager = $keywordsManager ?? AbuseFilterServices::getKeywordsManager(); + // Avoid injecting a Logger, as it's just temporary + $this->logger = new Psr\Log\NullLogger(); + } - /** @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. + /** + * @param LoggerInterface $logger + */ + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * Utility function to translate an array with shape [ varname => value ] into a self instance + * + * @param array $vars + * @param KeywordsManager|null $keywordsManager Optional for BC + * @return AbuseFilterVariableHolder */ - public $mVarsVersion = 2; + public static function newFromArray( + array $vars, + KeywordsManager $keywordsManager = null + ) : AbuseFilterVariableHolder { + $ret = new self( $keywordsManager ); + foreach ( $vars as $var => $value ) { + $ret->setVar( $var, $value ); + } + return $ret; + } - public function __construct() { - // Backwards-compatibility (unused now) - $this->setVar( 'minor_edit', false ); + /** + * Checks whether any deprecated variable is stored with the old name, and replaces it with + * the new name. This should normally only happen when a DB dump is retrieved from the DB. + */ + public function translateDeprecatedVars() : void { + $deprecatedVars = $this->keywordsManager->getDeprecatedVariables(); + foreach ( $this->mVars as $name => $value ) { + if ( array_key_exists( $name, $deprecatedVars ) ) { + $this->mVars[ $deprecatedVars[$name] ] = $value; + unset( $this->mVars[$name] ); + } + } } /** @@ -32,83 +91,107 @@ class AbuseFilterVariableHolder { } /** + * Get all variables stored in this object + * + * @return (AFPData|AFComputedVariable)[] + */ + public function getVars() { + return $this->mVars; + } + + /** + * Get a lazy loader for a variable. This method is here for testing ease + * @param string $method + * @param array $parameters + * @return AFComputedVariable + */ + public function getLazyLoader( $method, $parameters ) { + return new AFComputedVariable( $method, $parameters ); + } + + /** * @param string $variable * @param string $method * @param array $parameters */ public function setLazyLoadVar( $variable, $method, $parameters ) { - $placeholder = new AFComputedVariable( $method, $parameters ); + $placeholder = $this->getLazyLoader( $method, $parameters ); $this->setVar( $variable, $placeholder ); } /** * Get a variable from the current object * - * @param string $variable + * @param string $varName The variable name + * @param int $mode One of the self::GET_* constants, determines how to behave when the variable is unset: + * - GET_STRICT -> In the future, this will throw an exception. For now it returns a DUNDEFINED and logs a warning + * - GET_LAX -> Return a DUNDEFINED AFPData + * - GET_BC -> Return a DNULL AFPData (this should only be used for BC, see T230256) + * @param string|null $tempFilter Filter ID, if available; only used for debugging (temporarily) * @return AFPData */ - 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 ); + public function getVar( $varName, $mode = self::GET_STRICT, $tempFilter = null ) : AFPData { + $varName = strtolower( $varName ); + if ( $this->varIsSet( $varName ) ) { + /** @var $variable AFComputedVariable|AFPData */ + $variable = $this->mVars[$varName]; + if ( $variable instanceof AFComputedVariable ) { + $value = $variable->compute( $this ); + $this->setVar( $varName, $value ); return $value; - } elseif ( $this->mVars[$variable] instanceof AFPData ) { - return $this->mVars[$variable]; + } elseif ( $variable instanceof AFPData ) { + return $variable; + } else { + throw new UnexpectedValueException( + "Variable $varName has unexpected type " . gettype( $variable ) + ); } } - return new AFPData(); - } - - /** - * @return AbuseFilterVariableHolder - */ - public static function merge() { - $newHolder = new AbuseFilterVariableHolder; - $newHolder->addHolders( ...func_get_args() ); - return $newHolder; + // The variable is not set. + switch ( $mode ) { + case self::GET_STRICT: + $this->logger->warning( + __METHOD__ . ": requested unset variable {varname} in strict mode, filter: {filter}", + [ + 'varname' => $varName, + 'exception' => new RuntimeException(), + 'filter' => $tempFilter ?? 'unavailable' + ] + ); + // @todo change the line below to throw an exception in a future MW version + return new AFPData( AFPData::DUNDEFINED ); + case self::GET_LAX: + return new AFPData( AFPData::DUNDEFINED ); + case self::GET_BC: + // Old behaviour, which can sometimes lead to unexpected results (e.g. + // `edit_delta < -5000` will match any non-edit action). + return new AFPData( AFPData::DNULL ); + default: + throw new LogicException( "Mode '$mode' not recognized." ); + } } /** * Merge any number of holders given as arguments into this holder. * - * @throws MWException + * @param AbuseFilterVariableHolder ...$holders */ - public function addHolders() { - $holders = func_get_args(); - + public function addHolders( AbuseFilterVariableHolder ...$holders ) { foreach ( $holders as $addHolder ) { - if ( !is_object( $addHolder ) ) { - throw new MWException( 'Invalid argument to AbuseFilterVariableHolder::addHolders' ); - } $this->mVars = array_merge( $this->mVars, $addHolder->mVars ); } } - public function __wakeup() { - // Reset the context. - $this->setVar( 'context', 'stored' ); - } - /** - * Export all variables stored in this object as string + * Export all variables stored in this object with their native (PHP) types. * - * @return string[] + * @return array */ public function exportAllVars() { $exported = []; foreach ( array_keys( $this->mVars ) as $varName ) { - if ( !in_array( $varName, self::$varBlacklist ) ) { - $exported[$varName] = $this->getVar( $varName )->toString(); - } + $exported[ $varName ] = $this->getVar( $varName )->toNative(); } return $exported; @@ -122,10 +205,7 @@ class AbuseFilterVariableHolder { public function exportNonLazyVars() { $exported = []; foreach ( $this->mVars as $varName => $data ) { - if ( - !( $data instanceof AFComputedVariable ) - && !in_array( $varName, self::$varBlacklist ) - ) { + if ( !( $data instanceof AFComputedVariable ) ) { $exported[$varName] = $this->getVar( $varName )->toString(); } } @@ -144,51 +224,25 @@ class AbuseFilterVariableHolder { * @return array */ public function dumpAllVars( $compute = [], $includeUserVars = false ) { - $allVarNames = array_keys( $this->mVars ); - $exported = []; $coreVariables = []; if ( !$includeUserVars ) { // Compile a list of all variables set by the extension to be able // to filter user set ones by name - global $wgRestrictionTypes; - - $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 = [ 'MOVED_FROM', 'MOVED_TO', 'PAGE' ]; - $titleVars = [ - '_ID', - '_NAMESPACE', - '_TITLE', - '_PREFIXEDTITLE', - '_recent_contributors', - '_age', - ]; - foreach ( $wgRestrictionTypes as $action ) { - $titleVars[] = "_restrictions_$action"; - } - - foreach ( $titleVars as $var ) { - foreach ( $prefixes as $prefix ) { - $coreVariables[] = $prefix . $var; - } - } + $activeVariables = array_keys( $this->keywordsManager->getVarsMappings() ); + $deprecatedVariables = array_keys( $this->keywordsManager->getDeprecatedVariables() ); + $disabledVariables = array_keys( $this->keywordsManager->getDisabledVariables() ); + $coreVariables = array_merge( $activeVariables, $deprecatedVariables, $disabledVariables ); $coreVariables = array_map( 'strtolower', $coreVariables ); } - foreach ( $allVarNames as $varName ) { + $exported = []; + foreach ( array_keys( $this->mVars ) as $varName ) { + $computeThis = ( is_array( $compute ) && in_array( $varName, $compute ) ) || $compute === true; if ( ( $includeUserVars || in_array( strtolower( $varName ), $coreVariables ) ) && // Only include variables set in the extension in case $includeUserVars is false - !in_array( $varName, self::$varBlacklist ) && - ( $compute === true || - ( is_array( $compute ) && in_array( $varName, $compute ) ) || - $this->mVars[$varName] instanceof AFPData - ) + ( $computeThis || $this->mVars[$varName] instanceof AFPData ) ) { $exported[$varName] = $this->getVar( $varName )->toNative(); } @@ -208,9 +262,6 @@ class AbuseFilterVariableHolder { /** * 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) */ public function computeDBVars() { static $dbTypes = [ @@ -226,13 +277,23 @@ class AbuseFilterVariableHolder { 'revision-text-by-timestamp' ]; - foreach ( $this->mVars as $name => $value ) { - if ( $value instanceof AFComputedVariable && - in_array( $value->mMethod, $dbTypes ) - ) { + /** @var AFComputedVariable[] $missingVars */ + $missingVars = array_filter( $this->mVars, function ( $el ) { + return ( $el instanceof AFComputedVariable ); + } ); + foreach ( $missingVars as $name => $value ) { + if ( in_array( $value->mMethod, $dbTypes ) ) { $value = $value->compute( $this ); $this->setVar( $name, $value ); } } } + + /** + * @fixme Back-compat hack for old objects serialized and stored in the DB. + * Remove this once T213006 is done. + */ + public function __wakeup() { + $this->keywordsManager = AbuseFilterServices::getKeywordsManager(); + } } diff --git a/AbuseFilter/includes/AbuseLogHitFormatter.php b/AbuseFilter/includes/AbuseLogHitFormatter.php index a2fbb284..5bdb7878 100644 --- a/AbuseFilter/includes/AbuseLogHitFormatter.php +++ b/AbuseFilter/includes/AbuseLogHitFormatter.php @@ -1,7 +1,5 @@ <?php -use MediaWiki\MediaWikiServices; - /** * This class formats abuse log notifications. * @@ -14,7 +12,7 @@ class AbuseLogHitFormatter extends LogFormatter { */ protected function getMessageParameters() { $entry = $this->entry->getParameters(); - $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + $linkRenderer = $this->getLinkRenderer(); $params = parent::getMessageParameters(); $filter_title = SpecialPage::getTitleFor( 'AbuseFilter', $entry['filter'] ); @@ -40,19 +38,19 @@ class AbuseLogHitFormatter extends LogFormatter { ) ); } - $actions_taken = $entry['actions']; - if ( !strlen( trim( $actions_taken ) ) ) { + $actions_takenRaw = $entry['actions']; + if ( !strlen( trim( $actions_takenRaw ) ) ) { $actions_taken = $this->msg( 'abusefilter-log-noactions' ); } else { - $actions = explode( ',', $actions_taken ); + $actions = explode( ',', $actions_takenRaw ); $displayActions = []; foreach ( $actions as $action ) { - $displayActions[] = AbuseFilter::getActionDisplay( $action ); + $displayActions[] = AbuseFilter::getActionDisplay( $action, $this->context ); } $actions_taken = $this->context->getLanguage()->commaList( $displayActions ); } - $params[5] = $actions_taken; + $params[5] = Message::rawParam( $actions_taken ); // Bad things happen if the numbers are not in correct order ksort( $params ); diff --git a/AbuseFilter/includes/Hooks/AbuseFilterAlterVariablesHook.php b/AbuseFilter/includes/Hooks/AbuseFilterAlterVariablesHook.php new file mode 100644 index 00000000..b8307c6e --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterAlterVariablesHook.php @@ -0,0 +1,27 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use AbuseFilterVariableHolder; +use Title; +use User; + +interface AbuseFilterAlterVariablesHook { + /** + * Hook runner for the `AbuseFilterAlterVariables` hook + * + * Allows overwriting of abusefilter variables just before they're + * checked against filters. Note that you may specify custom variables in a saner way using other hooks: + * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars. + * + * @param AbuseFilterVariableHolder &$vars + * @param Title $title Title object target of the action + * @param User $user User object performer of the action + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterAlterVariables( + AbuseFilterVariableHolder &$vars, + Title $title, + User $user + ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterBuilderHook.php b/AbuseFilter/includes/Hooks/AbuseFilterBuilderHook.php new file mode 100644 index 00000000..0354709b --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterBuilderHook.php @@ -0,0 +1,15 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +interface AbuseFilterBuilderHook { + /** + * Hook runner for the `AbuseFilter-builder` hook + * + * Allows overwriting of the builder values returned by AbuseFilter::getBuilderValues + * + * @param array &$realValues Builder values + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterBuilder( array &$realValues ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterComputeVariableHook.php b/AbuseFilter/includes/Hooks/AbuseFilterComputeVariableHook.php new file mode 100644 index 00000000..ef7a77ec --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterComputeVariableHook.php @@ -0,0 +1,26 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use AbuseFilterVariableHolder; + +interface AbuseFilterComputeVariableHook { + /** + * Hook runner for the `AbuseFilter-computeVariable` hook + * + * Like AbuseFilter-interceptVariable but called if the requested method wasn't found. + * Return true to indicate that the method is known to the hook and was computed successful. + * + * @param string $method Method to generate the variable + * @param AbuseFilterVariableHolder $vars + * @param array $parameters Parameters with data to compute the value + * @param ?string &$result Result of the computation + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterComputeVariable( + string $method, + AbuseFilterVariableHolder $vars, + array $parameters, + ?string &$result + ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterContentToStringHook.php b/AbuseFilter/includes/Hooks/AbuseFilterContentToStringHook.php new file mode 100644 index 00000000..74ef1ee6 --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterContentToStringHook.php @@ -0,0 +1,23 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use Content; + +interface AbuseFilterContentToStringHook { + /** + * Hook runner for the `AbuseFilter-contentToString` hook + * + * Called when converting a Content object to a string to which + * filters can be applied. If the hook function returns true, Content::getTextForSearchIndex() + * will be used for non-text content. + * + * @param Content $content + * @param ?string &$text + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterContentToString( + Content $content, + ?string &$text + ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterDeprecatedVariablesHook.php b/AbuseFilter/includes/Hooks/AbuseFilterDeprecatedVariablesHook.php new file mode 100644 index 00000000..98340d9e --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterDeprecatedVariablesHook.php @@ -0,0 +1,16 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +interface AbuseFilterDeprecatedVariablesHook { + /** + * Hook runner for the `AbuseFilter-deprecatedVariables` hook + * + * Allows adding deprecated variables. If a filter uses an old variable, the parser + * will automatically translate it to the new one. + * + * @param array &$deprecatedVariables deprecated variables, syntax: [ 'old_name' => 'new_name' ] + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterDeprecatedVariables( array &$deprecatedVariables ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterFilterActionHook.php b/AbuseFilter/includes/Hooks/AbuseFilterFilterActionHook.php new file mode 100644 index 00000000..655c06d5 --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterFilterActionHook.php @@ -0,0 +1,26 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use AbuseFilterVariableHolder; +use Title; + +interface AbuseFilterFilterActionHook { + /** + * Hook runner for the `AbuseFilter-filterAction` hook + * + * DEPRECATED! Use AbuseFilterAlterVariables instead. + * + * Allows overwriting of abusefilter variables in AbuseFilter::filterAction just before they're + * checked against filters. Note that you may specify custom variables in a saner way using other hooks: + * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars. + * + * @param AbuseFilterVariableHolder &$vars + * @param Title $title + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterFilterAction( + AbuseFilterVariableHolder &$vars, + Title $title + ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterGenerateGenericVarsHook.php b/AbuseFilter/includes/Hooks/AbuseFilterGenerateGenericVarsHook.php new file mode 100644 index 00000000..b7ba9217 --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterGenerateGenericVarsHook.php @@ -0,0 +1,23 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use AbuseFilterVariableHolder; +use RecentChange; + +interface AbuseFilterGenerateGenericVarsHook { + /** + * Hook runner for the `AbuseFilter-generateGenericVars` hook + * + * Allows altering generic variables, i.e. independent from page and user + * + * @param AbuseFilterVariableHolder $vars + * @param ?RecentChange $rc If the variables should be generated for an RC entry, + * this is the entry. Null if it's for the current action being filtered. + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterGenerateGenericVars( + AbuseFilterVariableHolder $vars, + ?RecentChange $rc + ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterGenerateTitleVarsHook.php b/AbuseFilter/includes/Hooks/AbuseFilterGenerateTitleVarsHook.php new file mode 100644 index 00000000..a14ef980 --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterGenerateTitleVarsHook.php @@ -0,0 +1,28 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use AbuseFilterVariableHolder; +use RecentChange; +use Title; + +interface AbuseFilterGenerateTitleVarsHook { + /** + * Hook runner for the `AbuseFilter-generateTitleVars` hook + * + * Allows altering the variables generated for a title + * + * @param AbuseFilterVariableHolder $vars + * @param Title $title + * @param string $prefix Variable name prefix + * @param ?RecentChange $rc If the variables should be generated for an RC entry, + * this is the entry. Null if it's for the current action being filtered. + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterGenerateTitleVars( + AbuseFilterVariableHolder $vars, + Title $title, + string $prefix, + ?RecentChange $rc + ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterGenerateUserVarsHook.php b/AbuseFilter/includes/Hooks/AbuseFilterGenerateUserVarsHook.php new file mode 100644 index 00000000..f304a325 --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterGenerateUserVarsHook.php @@ -0,0 +1,26 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use AbuseFilterVariableHolder; +use RecentChange; +use User; + +interface AbuseFilterGenerateUserVarsHook { + /** + * Hook runner for the `AbuseFilter-generateUserVars` hook + * + * Allows altering the variables generated for a specific user + * + * @param AbuseFilterVariableHolder $vars + * @param User $user + * @param ?RecentChange $rc If the variables should be generated for an RC entry, + * this is the entry. Null if it's for the current action being filtered. + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterGenerateUserVars( + AbuseFilterVariableHolder $vars, + User $user, + ?RecentChange $rc + ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterHookRunner.php b/AbuseFilter/includes/Hooks/AbuseFilterHookRunner.php new file mode 100644 index 00000000..be6bfaf8 --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterHookRunner.php @@ -0,0 +1,290 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use AbuseFilterVariableHolder; +use Content; +use MediaWiki\HookContainer\HookContainer; +use MediaWiki\MediaWikiServices; +use RecentChange; +use Title; +use User; + +/** + * Handle running AbuseFilter's hooks + * @author DannyS712 + */ +class AbuseFilterHookRunner implements + AbuseFilterAlterVariablesHook, + AbuseFilterBuilderHook, + AbuseFilterComputeVariableHook, + AbuseFilterContentToStringHook, + AbuseFilterDeprecatedVariablesHook, + AbuseFilterFilterActionHook, + AbuseFilterGenerateGenericVarsHook, + AbuseFilterGenerateTitleVarsHook, + AbuseFilterGenerateUserVarsHook, + AbuseFilterInterceptVariableHook, + AbuseFilterShouldFilterActionHook +{ + + /** @var HookContainer */ + private $hookContainer; + + /** + * @param HookContainer $hookContainer + */ + public function __construct( HookContainer $hookContainer ) { + $this->hookContainer = $hookContainer; + } + + /** + * Convenience getter for static contexts + * + * See also core's Hooks::runner + * + * @return AbuseFilterHookRunner + */ + public static function getRunner() : AbuseFilterHookRunner { + return new AbuseFilterHookRunner( + MediaWikiServices::getInstance()->getHookContainer() + ); + } + + /** + * Hook runner for the `AbuseFilter-builder` hook + * + * Allows overwriting of the builder values returned by AbuseFilter::getBuilderValues + * + * @param array &$realValues Builder values + * @return bool|void + */ + public function onAbuseFilterBuilder( array &$realValues ) { + return $this->hookContainer->run( + 'AbuseFilter-builder', + [ &$realValues ] + ); + } + + /** + * Hook runner for the `AbuseFilter-deprecatedVariables` hook + * + * Allows adding deprecated variables. If a filter uses an old variable, the parser + * will automatically translate it to the new one. + * + * @param array &$deprecatedVariables deprecated variables, syntax: [ 'old_name' => 'new_name' ] + * @return bool|void + */ + public function onAbuseFilterDeprecatedVariables( array &$deprecatedVariables ) { + return $this->hookContainer->run( + 'AbuseFilter-deprecatedVariables', + [ &$deprecatedVariables ] + ); + } + + /** + * Hook runner for the `AbuseFilter-computeVariable` hook + * + * Like AbuseFilter-interceptVariable but called if the requested method wasn't found. + * Return true to indicate that the method is known to the hook and was computed successful. + * + * @param string $method Method to generate the variable + * @param AbuseFilterVariableHolder $vars + * @param array $parameters Parameters with data to compute the value + * @param ?string &$result Result of the computation + * @return bool|void + */ + public function onAbuseFilterComputeVariable( + string $method, + AbuseFilterVariableHolder $vars, + array $parameters, + ?string &$result + ) { + return $this->hookContainer->run( + 'AbuseFilter-computeVariable', + [ $method, $vars, $parameters, &$result ] + ); + } + + /** + * Hook runner for the `AbuseFilter-contentToString` hook + * + * Called when converting a Content object to a string to which + * filters can be applied. If the hook function returns true, Content::getTextForSearchIndex() + * will be used for non-text content. + * + * @param Content $content + * @param ?string &$text + * @return bool|void + */ + public function onAbuseFilterContentToString( + Content $content, + ?string &$text + ) { + return $this->hookContainer->run( + 'AbuseFilter-contentToString', + [ $content, &$text ] + ); + } + + /** + * Hook runner for the `AbuseFilter-filterAction` hook + * + * DEPRECATED! Use AbuseFilterAlterVariables instead. + * + * Allows overwriting of abusefilter variables in AbuseFilter::filterAction just before they're + * checked against filters. Note that you may specify custom variables in a saner way using other hooks: + * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars. + * + * @param AbuseFilterVariableHolder &$vars + * @param Title $title + * @return bool|void + */ + public function onAbuseFilterFilterAction( + AbuseFilterVariableHolder &$vars, + Title $title + ) { + return $this->hookContainer->run( + 'AbuseFilter-filterAction', + [ &$vars, $title ] + ); + } + + /** + * Hook runner for the `AbuseFilterAlterVariables` hook + * + * Allows overwriting of abusefilter variables just before they're + * checked against filters. Note that you may specify custom variables in a saner way using other hooks: + * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars. + * + * @param AbuseFilterVariableHolder &$vars + * @param Title $title Title object target of the action + * @param User $user User object performer of the action + * @return bool|void + */ + public function onAbuseFilterAlterVariables( + AbuseFilterVariableHolder &$vars, + Title $title, + User $user + ) { + return $this->hookContainer->run( + 'AbuseFilterAlterVariables', + [ &$vars, $title, $user ] + ); + } + + /** + * Hook runner for the `AbuseFilter-generateTitleVars` hook + * + * Allows altering the variables generated for a title + * + * @param AbuseFilterVariableHolder $vars + * @param Title $title + * @param string $prefix Variable name prefix + * @param ?RecentChange $rc If the variables should be generated for an RC entry, + * this is the entry. Null if it's for the current action being filtered. + * @return bool|void + */ + public function onAbuseFilterGenerateTitleVars( + AbuseFilterVariableHolder $vars, + Title $title, + string $prefix, + ?RecentChange $rc + ) { + return $this->hookContainer->run( + 'AbuseFilter-generateTitleVars', + [ $vars, $title, $prefix, $rc ] + ); + } + + /** + * Hook runner for the `AbuseFilter-generateUserVars` hook + * + * Allows altering the variables generated for a specific user + * + * @param AbuseFilterVariableHolder $vars + * @param User $user + * @param ?RecentChange $rc If the variables should be generated for an RC entry, + * this is the entry. Null if it's for the current action being filtered. + * @return bool|void + */ + public function onAbuseFilterGenerateUserVars( + AbuseFilterVariableHolder $vars, + User $user, + ?RecentChange $rc + ) { + return $this->hookContainer->run( + 'AbuseFilter-generateUserVars', + [ $vars, $user, $rc ] + ); + } + + /** + * Hook runner for the `AbuseFilter-generateGenericVars` hook + * + * Allows altering generic variables, i.e. independent from page and user + * + * @param AbuseFilterVariableHolder $vars + * @param ?RecentChange $rc If the variables should be generated for an RC entry, + * this is the entry. Null if it's for the current action being filtered. + * @return bool|void + */ + public function onAbuseFilterGenerateGenericVars( + AbuseFilterVariableHolder $vars, + ?RecentChange $rc + ) { + return $this->hookContainer->run( + 'AbuseFilter-generateGenericVars', + [ $vars, $rc ] + ); + } + + /** + * Hook runner for the `AbuseFilter-interceptVariable` hook + * + * Called before a variable is set in AFComputedVariable::compute to be able to set + * it before the core code runs. Return false to make the function return right after. + * + * @param string $method Method to generate the variable + * @param AbuseFilterVariableHolder $vars + * @param array $parameters Parameters with data to compute the value + * @param mixed &$result Result of the computation + * @return bool|void + */ + public function onAbuseFilterInterceptVariable( + string $method, + AbuseFilterVariableHolder $vars, + array $parameters, + &$result + ) { + return $this->hookContainer->run( + 'AbuseFilter-interceptVariable', + [ $method, $vars, $parameters, &$result ] + ); + } + + /** + * Hook runner for the `AbuseFilterShouldFilterAction` hook + * + * Called before filtering an action. If the current action should not be filtered, + * return false and add a useful reason to $skipReasons. + * + * @param AbuseFilterVariableHolder $vars + * @param Title $title Title object target of the action + * @param User $user User object performer of the action + * @param array &$skipReasons Array of reasons why the action should be skipped + * @return bool|void + */ + public function onAbuseFilterShouldFilterAction( + AbuseFilterVariableHolder $vars, + Title $title, + User $user, + array &$skipReasons + ) { + return $this->hookContainer->run( + 'AbuseFilterShouldFilterAction', + [ $vars, $title, $user, &$skipReasons ] + ); + } + +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterInterceptVariableHook.php b/AbuseFilter/includes/Hooks/AbuseFilterInterceptVariableHook.php new file mode 100644 index 00000000..ba93b3ac --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterInterceptVariableHook.php @@ -0,0 +1,26 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use AbuseFilterVariableHolder; + +interface AbuseFilterInterceptVariableHook { + /** + * Hook runner for the `AbuseFilter-interceptVariable` hook + * + * Called before a variable is set in AFComputedVariable::compute to be able to set + * it before the core code runs. Return false to make the function return right after. + * + * @param string $method Method to generate the variable + * @param AbuseFilterVariableHolder $vars + * @param array $parameters Parameters with data to compute the value + * @param mixed &$result Result of the computation + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterInterceptVariable( + string $method, + AbuseFilterVariableHolder $vars, + array $parameters, + &$result + ); +} diff --git a/AbuseFilter/includes/Hooks/AbuseFilterShouldFilterActionHook.php b/AbuseFilter/includes/Hooks/AbuseFilterShouldFilterActionHook.php new file mode 100644 index 00000000..1d67bfff --- /dev/null +++ b/AbuseFilter/includes/Hooks/AbuseFilterShouldFilterActionHook.php @@ -0,0 +1,28 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\Hooks; + +use AbuseFilterVariableHolder; +use Title; +use User; + +interface AbuseFilterShouldFilterActionHook { + /** + * Hook runner for the `AbuseFilterShouldFilterAction` hook + * + * Called before filtering an action. If the current action should not be filtered, + * return false and add a useful reason to $skipReasons. + * + * @param AbuseFilterVariableHolder $vars + * @param Title $title Title object target of the action + * @param User $user User object performer of the action + * @param array &$skipReasons Array of reasons why the action should be skipped + * @return bool|void True or no return value to continue or false to abort + */ + public function onAbuseFilterShouldFilterAction( + AbuseFilterVariableHolder $vars, + Title $title, + User $user, + array &$skipReasons + ); +} diff --git a/AbuseFilter/includes/KeywordsManager.php b/AbuseFilter/includes/KeywordsManager.php new file mode 100644 index 00000000..d86c9a3f --- /dev/null +++ b/AbuseFilter/includes/KeywordsManager.php @@ -0,0 +1,283 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter; + +use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; + +/** + * This service can be used to manage the list of keywords recognized by the Parser + */ +class KeywordsManager { + public const SERVICE_NAME = 'AbuseFilterKeywordsManager'; + + private const BUILDER_VALUES = [ + '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 else iffalse end' => 'cond', + 'if cond then iftrue end' => 'cond-short', + ], + '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', + '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', + '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', + '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_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_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', + 'user_groups' => 'user-groups', + 'user_rights' => 'user-rights', + 'user_blocked' => 'user-blocked', + 'user_emailconfirm' => 'user-emailconfirm', + 'old_wikitext' => 'old-wikitext', + 'new_wikitext' => 'new-wikitext', + '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', + 'new_html' => 'new-html', + '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', + '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', + 'wiki_name' => 'wiki-name', + 'wiki_language' => 'wiki-language', + ], + ]; + + /** @var array Old vars which aren't in use anymore */ + private const DISABLED_VARS = [ + 'old_text' => 'old-text', + 'old_html' => 'old-html', + 'minor_edit' => 'minor-edit' + ]; + + private const DEPRECATED_VARS = [ + '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', + ]; + + /** @var string[][] Final list of builder values */ + private $builderValues; + + /** @var string[] Final list of deprecated vars */ + private $deprecatedVars; + + /** @var AbuseFilterHookRunner */ + private $hookRunner; + + /** + * @param AbuseFilterHookRunner $hookRunner + */ + public function __construct( AbuseFilterHookRunner $hookRunner ) { + $this->hookRunner = $hookRunner; + } + + /** + * @return array + */ + public function getDisabledVariables(): array { + return self::DISABLED_VARS; + } + + /** + * @return array + */ + public function getDeprecatedVariables(): array { + if ( $this->deprecatedVars === null ) { + $this->deprecatedVars = self::DEPRECATED_VARS; + $this->hookRunner->onAbuseFilterDeprecatedVariables( $this->deprecatedVars ); + } + return $this->deprecatedVars; + } + + /** + * @return array + */ + public function getBuilderValues(): array { + if ( $this->builderValues === null ) { + $this->builderValues = self::BUILDER_VALUES; + $this->hookRunner->onAbuseFilterBuilder( $this->builderValues ); + } + return $this->builderValues; + } + + /** + * @param string $name + * @return bool + */ + public function isVarDisabled( string $name ): bool { + return array_key_exists( $name, self::DISABLED_VARS ); + } + + /** + * @param string $name + * @return bool + */ + public function isVarDeprecated( string $name ): bool { + return array_key_exists( $name, $this->getDeprecatedVariables() ); + } + + /** + * @param string $name + * @return bool + */ + public function isVarInUse( string $name ): bool { + return array_key_exists( $name, $this->getVarsMappings() ); + } + + /** + * Check whether the given name corresponds to a known variable. + * @param string $name + * @return bool + */ + public function varExists( string $name ): bool { + return $this->isVarInUse( $name ) || + $this->isVarDisabled( $name ) || + $this->isVarDeprecated( $name ); + } + + /** + * Get the message for a builtin variable; takes deprecated variables into account. + * Returns null for non-builtin variables. + * + * @param string $var + * @return string|null + */ + public function getMessageKeyForVar( string $var ): ?string { + if ( !$this->varExists( $var ) ) { + return null; + } + if ( $this->isVarDeprecated( $var ) ) { + $var = $this->getDeprecatedVariables()[$var]; + } + + $key = self::DISABLED_VARS[$var] ?? + $this->getVarsMappings()[$var]; + return "abusefilter-edit-builder-vars-$key"; + } + + /** + * @return array + */ + public function getVarsMappings(): array { + return $this->getBuilderValues()['vars']; + } +} diff --git a/AbuseFilter/includes/ServiceWiring.php b/AbuseFilter/includes/ServiceWiring.php new file mode 100644 index 00000000..5901ca80 --- /dev/null +++ b/AbuseFilter/includes/ServiceWiring.php @@ -0,0 +1,13 @@ +<?php + +use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; +use MediaWiki\Extension\AbuseFilter\KeywordsManager; +use MediaWiki\MediaWikiServices; + +return [ + KeywordsManager::SERVICE_NAME => function ( MediaWikiServices $services ): KeywordsManager { + return new KeywordsManager( + new AbuseFilterHookRunner( $services->getHookContainer() ) + ); + }, +]; diff --git a/AbuseFilter/includes/VariableGenerator/RCVariableGenerator.php b/AbuseFilter/includes/VariableGenerator/RCVariableGenerator.php new file mode 100644 index 00000000..8c4a1d81 --- /dev/null +++ b/AbuseFilter/includes/VariableGenerator/RCVariableGenerator.php @@ -0,0 +1,235 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\VariableGenerator; + +use AbuseFilterVariableHolder; +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; +use MWFileProps; +use MWTimestamp; +use RecentChange; +use Title; +use User; + +/** + * This class contains the logic used to create AbuseFilterVariableHolder objects used to + * examine a RecentChanges row. + */ +class RCVariableGenerator extends VariableGenerator { + /** + * @var RecentChange + */ + protected $rc; + + /** @var User */ + private $contextUser; + + /** + * @param AbuseFilterVariableHolder $vars + * @param RecentChange $rc + * @param User $contextUser + */ + public function __construct( + AbuseFilterVariableHolder $vars, + RecentChange $rc, + User $contextUser + ) { + parent::__construct( $vars ); + + $this->rc = $rc; + $this->contextUser = $contextUser; + } + + /** + * Get an instance for a given rc_id. + * + * @todo FIXME this method doesn't appear to have any uses + * + * @param int $id + * @param AbuseFilterVariableHolder $vars + * @param User $contextUser + * @return self|null + */ + public static function newFromId( + int $id, + AbuseFilterVariableHolder $vars, + User $contextUser + ) : ?self { + $rc = RecentChange::newFromId( $id ); + + if ( !$rc ) { + return null; + } + return new self( $vars, $rc, $contextUser ); + } + + /** + * @return AbuseFilterVariableHolder|null + */ + public function getVars() : ?AbuseFilterVariableHolder { + if ( $this->rc->getAttribute( 'rc_type' ) == RC_LOG ) { + switch ( $this->rc->getAttribute( 'rc_log_type' ) ) { + case 'move': + $this->addMoveVars(); + break; + case 'newusers': + $this->addCreateAccountVars(); + break; + case 'delete': + $this->addDeleteVars(); + break; + case 'upload': + $this->addUploadVars(); + break; + default: + return null; + } + } elseif ( $this->rc->getAttribute( 'rc_last_oldid' ) ) { + // It's an edit. + $this->addEditVarsForRow(); + } else { + // @todo Ensure this cannot happen, and throw if it does + return null; + } + + $this->addGenericVars(); + $this->vars->setVar( + 'timestamp', + MWTimestamp::convert( TS_UNIX, $this->rc->getAttribute( 'rc_timestamp' ) ) + ); + + return $this->vars; + } + + /** + * @return $this + */ + private function addMoveVars() : self { + $user = $this->rc->getPerformer(); + + $oldTitle = $this->rc->getTitle(); + $newTitle = Title::newFromText( $this->rc->getParam( '4::target' ) ); + + $this->addUserVars( $user, $this->rc ) + ->addTitleVars( $oldTitle, 'moved_from', $this->rc ) + ->addTitleVars( $newTitle, 'moved_to', $this->rc ); + + $this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) ); + $this->vars->setVar( 'action', 'move' ); + + return $this; + } + + /** + * @return $this + */ + private function addCreateAccountVars() : self { + $this->vars->setVar( + 'action', + $this->rc->getAttribute( 'rc_log_action' ) === 'autocreate' + ? 'autocreateaccount' + : 'createaccount' + ); + + $name = $this->rc->getTitle()->getText(); + // Add user data if the account was created by a registered user + $user = $this->rc->getPerformer(); + if ( !$user->isAnon() && $name !== $user->getName() ) { + $this->addUserVars( $user, $this->rc ); + } + + $this->vars->setVar( 'accountname', $name ); + + return $this; + } + + /** + * @return $this + */ + private function addDeleteVars() : self { + $title = $this->rc->getTitle(); + $user = $this->rc->getPerformer(); + + $this->addUserVars( $user, $this->rc ) + ->addTitleVars( $title, 'page', $this->rc ); + + $this->vars->setVar( 'action', 'delete' ); + $this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) ); + + return $this; + } + + /** + * @return $this + */ + private function addUploadVars() : self { + $title = $this->rc->getTitle(); + $user = $this->rc->getPerformer(); + + $this->addUserVars( $user, $this->rc ) + ->addTitleVars( $title, 'page', $this->rc ); + + $this->vars->setVar( 'action', 'upload' ); + $this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) ); + + $time = $this->rc->getParam( 'img_timestamp' ); + $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( + $title, [ 'time' => $time, 'private' => $this->contextUser ] + ); + if ( !$file ) { + // FixMe This shouldn't happen! + $logger = LoggerFactory::getInstance( 'AbuseFilter' ); + $logger->debug( "Cannot find file from RC row with title $title" ); + return $this; + } + + // This is the same as AbuseFilterHooks::filterUpload, but from a different source + $this->vars->setVar( 'file_sha1', \Wikimedia\base_convert( $file->getSha1(), 36, 16, 40 ) ); + $this->vars->setVar( 'file_size', $file->getSize() ); + + $this->vars->setVar( 'file_mime', $file->getMimeType() ); + $this->vars->setVar( + 'file_mediatype', + MediaWikiServices::getInstance()->getMimeAnalyzer() + ->getMediaType( null, $file->getMimeType() ) + ); + $this->vars->setVar( 'file_width', $file->getWidth() ); + $this->vars->setVar( 'file_height', $file->getHeight() ); + + $mwProps = new MWFileProps( MediaWikiServices::getInstance()->getMimeAnalyzer() ); + $bits = $mwProps->getPropsFromPath( $file->getLocalRefPath(), true )['bits']; + $this->vars->setVar( 'file_bits_per_channel', $bits ); + + return $this; + } + + /** + * @return $this + */ + private function addEditVarsForRow() : self { + $title = $this->rc->getTitle(); + $user = $this->rc->getPerformer(); + + $this->addUserVars( $user, $this->rc ) + ->addTitleVars( $title, 'page', $this->rc ); + + // @todo Set old_content_model and new_content_model + $this->vars->setVar( 'action', 'edit' ); + $this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) ); + + $this->vars->setLazyLoadVar( 'new_wikitext', 'revision-text-by-id', + [ 'revid' => $this->rc->getAttribute( 'rc_this_oldid' ) ] ); + + $parentId = $this->rc->getAttribute( 'rc_last_oldid' ); + if ( $parentId ) { + $this->vars->setLazyLoadVar( 'old_wikitext', 'revision-text-by-id', + [ 'revid' => $parentId ] ); + } else { + $this->vars->setVar( 'old_wikitext', '' ); + } + + $this->addEditVars( $title ); + + return $this; + } +} diff --git a/AbuseFilter/includes/VariableGenerator/RunVariableGenerator.php b/AbuseFilter/includes/VariableGenerator/RunVariableGenerator.php new file mode 100644 index 00000000..1f0deab4 --- /dev/null +++ b/AbuseFilter/includes/VariableGenerator/RunVariableGenerator.php @@ -0,0 +1,316 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\VariableGenerator; + +use AbuseFilter; +use AbuseFilterVariableHolder; +use AFComputedVariable; +use Content; +use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\MutableRevisionRecord; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\SlotRecord; +use MWException; +use MWFileProps; +use Title; +use UploadBase; +use User; +use WikiPage; + +/** + * This class contains the logic used to create AbuseFilterVariableHolder objects before filtering + * an action. + */ +class RunVariableGenerator extends VariableGenerator { + /** + * @var User + */ + protected $user; + + /** + * @var Title + */ + protected $title; + + /** + * @param AbuseFilterVariableHolder $vars + * @param User $user + * @param Title $title + */ + public function __construct( AbuseFilterVariableHolder $vars, User $user, Title $title ) { + parent::__construct( $vars ); + $this->user = $user; + $this->title = $title; + } + + /** + * Get variables for pre-filtering an edit during stash + * + * @param Content $content + * @param string $summary + * @param string $slot + * @param WikiPage $page + * @return AbuseFilterVariableHolder|null + */ + public function getStashEditVars( + Content $content, + string $summary, + $slot, + WikiPage $page + ) : ?AbuseFilterVariableHolder { + $filterText = $this->getEditTextForFiltering( $page, $content, $slot ); + if ( $filterText === null ) { + return null; + } + list( $oldContent, $oldAfText, $text ) = $filterText; + return $this->newVariableHolderForEdit( + $page, $summary, $content, $text, $oldAfText, $oldContent + ); + } + + /** + * Get the text of an edit to be used for filtering + * @todo Full support for multi-slots + * + * @param WikiPage $page + * @param Content $content + * @param string $slot + * @return array|null + */ + protected function getEditTextForFiltering( WikiPage $page, Content $content, $slot ) : ?array { + $oldRevRecord = $page->getRevisionRecord(); + if ( !$oldRevRecord ) { + return null; + } + + $oldContent = $oldRevRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW ); + $oldAfText = AbuseFilter::revisionToString( $oldRevRecord, $this->user ); + + // XXX: Recreate what the new revision will probably be so we can get the full AF + // text for all slots + $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevRecord ); + $newRevision->setContent( $slot, $content ); + $text = AbuseFilter::revisionToString( $newRevision, $this->user ); + + // Cache article object so we can share a parse operation + $articleCacheKey = $this->title->getNamespace() . ':' . $this->title->getText(); + AFComputedVariable::$articleCache[$articleCacheKey] = $page; + + // Don't trigger for null edits. Compare Content objects if available, but check the + // stringified contents as well, e.g. for line endings normalization (T240115). + // Don't treat content model change as null edit though. + if ( + ( $oldContent && $content->equals( $oldContent ) ) || + ( $oldContent->getModel() === $content->getModel() && strcmp( $oldAfText, $text ) === 0 ) + ) { + return null; + } + + return [ $oldContent, $oldAfText, $text ]; + } + + /** + * @param WikiPage|null $page + * @param string $summary + * @param Content $newcontent + * @param string $text + * @param string $oldtext + * @param Content|null $oldcontent + * @return AbuseFilterVariableHolder + * @throws MWException + */ + private function newVariableHolderForEdit( + ?WikiPage $page, + string $summary, + Content $newcontent, + string $text, + string $oldtext, + Content $oldcontent = null + ) : AbuseFilterVariableHolder { + $this->addUserVars( $this->user ) + ->addTitleVars( $this->title, 'page' ); + $this->vars->setVar( 'action', 'edit' ); + $this->vars->setVar( 'summary', $summary ); + if ( $oldcontent instanceof Content ) { + $oldmodel = $oldcontent->getModel(); + } else { + $oldmodel = ''; + $oldtext = ''; + } + $this->vars->setVar( 'old_content_model', $oldmodel ); + $this->vars->setVar( 'new_content_model', $newcontent->getModel() ); + $this->vars->setVar( 'old_wikitext', $oldtext ); + $this->vars->setVar( 'new_wikitext', $text ); + $this->addEditVars( $this->title, $page ); + + return $this->vars; + } + + /** + * Get variables for filtering an edit. + * + * @param Content $content + * @param string $text + * @param string $summary + * @param string $slot + * @param WikiPage|null $page + * @return AbuseFilterVariableHolder|null + */ + public function getEditVars( + Content $content, + string $text, + string $summary, + $slot, + WikiPage $page = null + ) : ?AbuseFilterVariableHolder { + $oldContent = null; + + if ( $page !== null ) { + $filterText = $this->getEditTextForFiltering( $page, $content, $slot ); + if ( $filterText === null ) { + return null; + } + list( $oldContent, $oldAfText, $text ) = $filterText; + } else { + $oldAfText = ''; + } + + return $this->newVariableHolderForEdit( + $page, $summary, $content, $text, $oldAfText, $oldContent + ); + } + + /** + * Get variables used to filter a move. + * + * @param Title $newTitle + * @param string $reason + * @return AbuseFilterVariableHolder + */ + public function getMoveVars( + Title $newTitle, + string $reason + ) : AbuseFilterVariableHolder { + $this->addUserVars( $this->user ) + ->addTitleVars( $this->title, 'MOVED_FROM' ) + ->addTitleVars( $newTitle, 'MOVED_TO' ); + $this->vars->setVar( 'summary', $reason ); + $this->vars->setVar( 'action', 'move' ); + return $this->vars; + } + + /** + * Get variables for filtering a deletion. + * + * @param string $reason + * @return AbuseFilterVariableHolder + */ + public function getDeleteVars( + string $reason + ) : AbuseFilterVariableHolder { + $this->addUserVars( $this->user ) + ->addTitleVars( $this->title, 'page' ); + + $this->vars->setVar( 'summary', $reason ); + $this->vars->setVar( 'action', 'delete' ); + return $this->vars; + } + + /** + * Get variables for filtering an upload. + * + * @param string $action + * @param UploadBase $upload + * @param string|null $summary + * @param string|null $text + * @param array|null $props + * @return AbuseFilterVariableHolder|null + */ + public function getUploadVars( + string $action, + UploadBase $upload, + ?string $summary, + ?string $text, + ?array $props + ) : ?AbuseFilterVariableHolder { + $mimeAnalyzer = MediaWikiServices::getInstance()->getMimeAnalyzer(); + if ( !$props ) { + $props = ( new MWFileProps( $mimeAnalyzer ) )->getPropsFromPath( + $upload->getTempPath(), + true + ); + } + + $this->addUserVars( $this->user ) + ->addTitleVars( $this->title, 'page' ); + $this->vars->setVar( 'action', $action ); + + // We use the hexadecimal version of the file sha1. + // Use UploadBase::getTempFileSha1Base36 so that we don't have to calculate the sha1 sum again + $sha1 = \Wikimedia\base_convert( $upload->getTempFileSha1Base36(), 36, 16, 40 ); + + // This is the same as AbuseFilterRowVariableGenerator::addUploadVars, but from a different source + $this->vars->setVar( 'file_sha1', $sha1 ); + $this->vars->setVar( 'file_size', $upload->getFileSize() ); + + $this->vars->setVar( 'file_mime', $props['mime'] ); + $this->vars->setVar( 'file_mediatype', $mimeAnalyzer->getMediaType( null, $props['mime'] ) ); + $this->vars->setVar( 'file_width', $props['width'] ); + $this->vars->setVar( 'file_height', $props['height'] ); + $this->vars->setVar( 'file_bits_per_channel', $props['bits'] ); + + // We only have the upload comment and page text when using the UploadVerifyUpload hook + if ( $summary !== null && $text !== null ) { + // This block is adapted from self::getTextForFiltering() + if ( $this->title->exists() ) { + $page = WikiPage::factory( $this->title ); + $revRec = $page->getRevisionRecord(); + if ( !$revRec ) { + return null; + } + + $oldcontent = $revRec->getContent( SlotRecord::MAIN, RevisionRecord::RAW ); + $oldtext = AbuseFilter::contentToString( $oldcontent ); + + // Cache article object so we can share a parse operation + $articleCacheKey = $this->title->getNamespace() . ':' . $this->title->getText(); + AFComputedVariable::$articleCache[$articleCacheKey] = $page; + + // Page text is ignored for uploads when the page already exists + $text = $oldtext; + } else { + $page = null; + $oldtext = ''; + } + + // Load vars for filters to check + $this->vars->setVar( 'summary', $summary ); + $this->vars->setVar( 'old_wikitext', $oldtext ); + $this->vars->setVar( 'new_wikitext', $text ); + // TODO: set old_content and new_content vars, use them + $this->addEditVars( $this->title, $page ); + } + return $this->vars; + } + + /** + * Get variables for filtering an account creation + * + * @param User $createdUser This is the user being created, not the creator (which is $this->user) + * @param bool $autocreate + * @return AbuseFilterVariableHolder + */ + public function getAccountCreationVars( + User $createdUser, + bool $autocreate + ) : AbuseFilterVariableHolder { + // generateUserVars records $this->user->getName() which would be the IP for unregistered users + if ( $this->user->isLoggedIn() ) { + $this->addUserVars( $this->user ); + } + + $this->vars->setVar( 'action', $autocreate ? 'autocreateaccount' : 'createaccount' ); + $this->vars->setVar( 'accountname', $createdUser->getName() ); + return $this->vars; + } +} diff --git a/AbuseFilter/includes/VariableGenerator/VariableGenerator.php b/AbuseFilter/includes/VariableGenerator/VariableGenerator.php new file mode 100644 index 00000000..468d5c75 --- /dev/null +++ b/AbuseFilter/includes/VariableGenerator/VariableGenerator.php @@ -0,0 +1,230 @@ +<?php + +namespace MediaWiki\Extension\AbuseFilter\VariableGenerator; + +use AbuseFilterVariableHolder; +use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; +use RecentChange; +use Title; +use User; +use WikiPage; + +/** + * Class used to generate variables, for instance related to a given user or title. + */ +class VariableGenerator { + /** + * @var AbuseFilterVariableHolder + */ + protected $vars; + + /** @var AbuseFilterHookRunner */ + private $hookRunner; + + /** + * @param AbuseFilterVariableHolder $vars + */ + public function __construct( AbuseFilterVariableHolder $vars ) { + $this->vars = $vars; + + // TODO this class is constructed in other extensions; make this a parameter + $this->hookRunner = AbuseFilterHookRunner::getRunner(); + } + + /** + * @return AbuseFilterVariableHolder + */ + public function getVariableHolder() : AbuseFilterVariableHolder { + return $this->vars; + } + + /** + * Computes all variables unrelated to title and user. In general, these variables may be known + * even without an ongoing action. + * + * @param RecentChange|null $rc If the variables should be generated for an RC entry, + * this is the entry. Null if it's for the current action being filtered. + * @return $this For chaining + */ + public function addGenericVars( RecentChange $rc = null ) : self { + // These are lazy-loaded just to reduce the amount of preset variables, but they + // shouldn't be expensive. + $this->vars->setLazyLoadVar( 'wiki_name', 'get-wiki-name', [] ); + $this->vars->setLazyLoadVar( 'wiki_language', 'get-wiki-language', [] ); + + $this->hookRunner->onAbuseFilterGenerateGenericVars( $this->vars, $rc ); + return $this; + } + + /** + * @param User $user + * @param RecentChange|null $rc If the variables should be generated for an RC entry, + * this is the entry. Null if it's for the current action being filtered. + * @return $this For chaining + */ + public function addUserVars( User $user, RecentChange $rc = null ) : self { + $this->vars->setLazyLoadVar( + 'user_editcount', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getEditCount' ] + ); + + $this->vars->setVar( 'user_name', $user->getName() ); + + $this->vars->setLazyLoadVar( + 'user_emailconfirm', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getEmailAuthenticationTimestamp' ] + ); + + $this->vars->setLazyLoadVar( + 'user_age', + 'user-age', + [ 'user' => $user, 'asof' => wfTimestampNow() ] + ); + + $this->vars->setLazyLoadVar( + 'user_groups', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getEffectiveGroups' ] + ); + + $this->vars->setLazyLoadVar( + 'user_rights', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getRights' ] + ); + + $this->vars->setLazyLoadVar( + 'user_blocked', + 'user-block', + [ 'user' => $user ] + ); + + $this->hookRunner->onAbuseFilterGenerateUserVars( $this->vars, $user, $rc ); + + return $this; + } + + /** + * @param Title $title + * @param string $prefix + * @param RecentChange|null $rc If the variables should be generated for an RC entry, + * this is the entry. Null if it's for the current action being filtered. + * @return $this For chaining + */ + public function addTitleVars( + Title $title, + string $prefix, + RecentChange $rc = null + ) : self { + $this->vars->setVar( $prefix . '_id', $title->getArticleID() ); + $this->vars->setVar( $prefix . '_namespace', $title->getNamespace() ); + $this->vars->setVar( $prefix . '_title', $title->getText() ); + $this->vars->setVar( $prefix . '_prefixedtitle', $title->getPrefixedText() ); + + // We only support the default values in $wgRestrictionTypes. Custom restrictions wouldn't + // have i18n messages. If a restriction is not enabled we'll just return the empty array. + $types = [ 'edit', 'move', 'create', 'upload' ]; + foreach ( $types as $action ) { + $this->vars->setLazyLoadVar( "{$prefix}_restrictions_$action", 'get-page-restrictions', + [ 'title' => $title->getText(), + 'namespace' => $title->getNamespace(), + 'action' => $action + ] + ); + } + + $this->vars->setLazyLoadVar( "{$prefix}_recent_contributors", 'load-recent-authors', + [ + 'title' => $title->getText(), + 'namespace' => $title->getNamespace() + ] ); + + $this->vars->setLazyLoadVar( "{$prefix}_age", 'page-age', + [ + 'title' => $title->getText(), + 'namespace' => $title->getNamespace(), + 'asof' => wfTimestampNow() + ] ); + + $this->vars->setLazyLoadVar( "{$prefix}_first_contributor", 'load-first-author', + [ + 'title' => $title->getText(), + 'namespace' => $title->getNamespace() + ] ); + + $this->hookRunner->onAbuseFilterGenerateTitleVars( $this->vars, $title, $prefix, $rc ); + + return $this; + } + + /** + * @param Title $title + * @param WikiPage|null $page + * @return $this For chaining + */ + public function addEditVars( Title $title, WikiPage $page = null ) : self { + // NOTE: $page may end up remaining null, e.g. if $title points to a special page. + if ( !$page && $title->canExist() ) { + // TODO: The caller should do this! + $page = WikiPage::factory( $title ); + } + + $this->vars->setLazyLoadVar( 'edit_diff', 'diff-array', + [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_wikitext' ] ); + $this->vars->setLazyLoadVar( 'edit_diff_pst', 'diff-array', + [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_pst' ] ); + $this->vars->setLazyLoadVar( 'new_size', 'length', [ 'length-var' => 'new_wikitext' ] ); + $this->vars->setLazyLoadVar( 'old_size', 'length', [ 'length-var' => 'old_wikitext' ] ); + $this->vars->setLazyLoadVar( 'edit_delta', 'subtract-int', + [ 'val1-var' => 'new_size', 'val2-var' => 'old_size' ] ); + + // Some more specific/useful details about the changes. + $this->vars->setLazyLoadVar( 'added_lines', 'diff-split', + [ 'diff-var' => 'edit_diff', 'line-prefix' => '+' ] ); + $this->vars->setLazyLoadVar( 'removed_lines', 'diff-split', + [ 'diff-var' => 'edit_diff', 'line-prefix' => '-' ] ); + $this->vars->setLazyLoadVar( 'added_lines_pst', 'diff-split', + [ 'diff-var' => 'edit_diff_pst', 'line-prefix' => '+' ] ); + + // Links + $this->vars->setLazyLoadVar( 'added_links', 'link-diff-added', + [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] ); + $this->vars->setLazyLoadVar( 'removed_links', 'link-diff-removed', + [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] ); + $this->vars->setLazyLoadVar( 'new_text', 'strip-html', + [ 'html-var' => 'new_html' ] ); + + $this->vars->setLazyLoadVar( 'all_links', 'links-from-wikitext', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'text-var' => 'new_wikitext', + 'article' => $page + ] ); + $this->vars->setLazyLoadVar( 'old_links', 'links-from-wikitext-or-database', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'text-var' => 'old_wikitext' + ] ); + $this->vars->setLazyLoadVar( 'new_pst', 'parse-wikitext', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'wikitext-var' => 'new_wikitext', + 'article' => $page, + 'pst' => true, + ] ); + $this->vars->setLazyLoadVar( 'new_html', 'parse-wikitext', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'wikitext-var' => 'new_wikitext', + 'article' => $page + ] ); + + return $this; + } +} diff --git a/AbuseFilter/includes/Views/AbuseFilterView.php b/AbuseFilter/includes/Views/AbuseFilterView.php index 9b019335..a8cbcadb 100644 --- a/AbuseFilter/includes/Views/AbuseFilterView.php +++ b/AbuseFilter/includes/Views/AbuseFilterView.php @@ -1,9 +1,29 @@ <?php +use MediaWiki\Extension\AbuseFilter\AbuseFilterServices; use Wikimedia\Rdbms\IDatabase; abstract class AbuseFilterView extends ContextSource { - public $mFilter, $mHistoryID, $mSubmit, $mPage, $mParams; + /** + * @var string The ID of the current filter + */ + public $mFilter; + /** + * @var int|null The history ID of the current filter + */ + public $mHistoryID; + /** + * @var bool Whether the form was submitted + */ + public $mSubmit; + /** + * @var SpecialAbuseFilter The related special page object + */ + public $mPage; + /** + * @var array The parameters of the current request + */ + public $mParams; /** * @var \MediaWiki\Linker\LinkRenderer @@ -14,7 +34,7 @@ abstract class AbuseFilterView extends ContextSource { * @param SpecialAbuseFilter $page * @param array $params */ - public function __construct( $page, $params ) { + public function __construct( SpecialAbuseFilter $page, $params ) { $this->mPage = $page; $this->mParams = $params; $this->setContext( $this->mPage->getContext() ); @@ -22,7 +42,7 @@ abstract class AbuseFilterView extends ContextSource { } /** - * @param string $subpage + * @param string|int $subpage * @return Title */ public function getTitle( $subpage = '' ) { @@ -35,74 +55,45 @@ abstract class AbuseFilterView extends ContextSource { abstract public function show(); /** - * @return bool - */ - public function canEdit() { - return ( - !$this->getUser()->isBlocked() && - $this->getUser()->isAllowed( 'abusefilter-modify' ) - ); - } - - /** - * @return bool - */ - public function canEditGlobal() { - return $this->getUser()->isAllowed( 'abusefilter-modify-global' ); - } - - /** - * Whether the user can edit the given filter. - * - * @param object $row Filter row - * - * @return bool - */ - public function canEditFilter( $row ) { - return ( - $this->canEdit() && - !( isset( $row->af_global ) && $row->af_global == 1 && !$this->canEditGlobal() ) - ); - } - - /** * @param string $rules - * @param string $textName * @param bool $addResultDiv * @param bool $externalForm * @param bool $needsModifyRights * @param-taint $rules none * @return string */ - public function buildEditBox( + protected function buildEditBox( $rules, - $textName = 'wpFilterRules', $addResultDiv = true, $externalForm = false, $needsModifyRights = true ) { $this->getOutput()->enableOOUI(); + $user = $this->getUser(); // Rules are in English - $editorAttrib = [ 'dir' => 'ltr' ]; + $editorAttribs = [ 'dir' => 'ltr' ]; $noTestAttrib = []; $isUserAllowed = $needsModifyRights ? - $this->getUser()->isAllowed( 'abusefilter-modify' ) : - $this->canViewPrivate(); + AbuseFilter::canEdit( $user ) : + AbuseFilter::canViewPrivate( $user ); if ( !$isUserAllowed ) { $noTestAttrib['disabled'] = 'disabled'; $addResultDiv = false; } $rules = rtrim( $rules ) . "\n"; - $canEdit = $needsModifyRights ? $this->canEdit() : $this->canViewPrivate(); $switchEditor = null; + $rulesContainer = ''; if ( ExtensionRegistry::getInstance()->isLoaded( 'CodeEditor' ) ) { - $editorAttrib['name'] = 'wpAceFilterEditor'; - $editorAttrib['id'] = 'wpAceFilterEditor'; - $editorAttrib['class'] = 'mw-abusefilter-editor'; + $aceAttribs = [ + 'name' => 'wpAceFilterEditor', + 'id' => 'wpAceFilterEditor', + 'class' => 'mw-abusefilter-editor' + ]; + $attribs = array_merge( $editorAttribs, $aceAttribs ); $switchEditor = new OOUI\ButtonWidget( @@ -112,35 +103,27 @@ abstract class AbuseFilterView extends ContextSource { ] + $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 ); + $rulesContainer .= Xml::element( 'div', $attribs, $rules ); // Add Ace configuration variable + $editorConfig = AbuseFilter::getAceConfig( $isUserAllowed ); $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 ) { + // Build a dummy textarea to be used: for submitting form if CodeEditor isn't installed, + // and in case JS is disabled (with or without CodeEditor) + if ( !$isUserAllowed ) { + $editorAttribs['readonly'] = 'readonly'; + } + if ( $externalForm ) { + $editorAttribs['form'] = 'wpFilterForm'; + } + $rulesContainer .= Xml::textarea( 'wpFilterRules', $rules, 40, 15, $editorAttribs ); + + if ( $isUserAllowed ) { + $keywordsManager = AbuseFilterServices::getKeywordsManager(); // Generate builder drop-down - $rawDropDown = AbuseFilter::getBuilderValues(); + $rawDropDown = $keywordsManager->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 @@ -211,14 +194,52 @@ abstract class AbuseFilterView extends ContextSource { // Add script $this->getOutput()->addModules( 'ext.abuseFilter.edit' ); - AbuseFilter::$editboxName = $textName; return $rulesContainer; } /** + * Build input and button for loading a filter + * + * @return string + */ + public function buildFilterLoader() { + $loadText = + new OOUI\TextInputWidget( + [ + 'type' => 'number', + 'name' => 'wpInsertFilter', + 'id' => 'mw-abusefilter-load-filter' + ] + ); + $loadButton = + new OOUI\ButtonWidget( + [ + 'label' => $this->msg( 'abusefilter-test-load' )->text(), + 'id' => 'mw-abusefilter-load' + ] + ); + $loadGroup = + new OOUI\ActionFieldLayout( + $loadText, + $loadButton, + [ + 'label' => $this->msg( '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; + } + + /** * @param IDatabase $db - * @param string|bool $action 'edit', 'move', 'createaccount', 'delete' or false for all + * @param string|false $action 'edit', 'move', 'createaccount', 'delete' or false for all * @return string */ public function buildTestConditions( IDatabase $db, $action = false ) { @@ -250,7 +271,18 @@ abstract class AbuseFilterView extends ContextSource { 'rc_log_type' => 'delete', 'rc_log_action' => 'delete' ], LIST_AND ); - // @ToDo: case 'upload' + case 'upload': + return $db->makeList( [ + 'rc_source' => RecentChange::SRC_LOG, + 'rc_log_type' => 'upload', + 'rc_log_action' => [ 'upload', 'overwrite', 'revert' ] + ], LIST_AND ); + case false: + // Done later + break; + default: + // @phan-suppress-next-line PhanTypeSuspiciousStringExpression False does not reach here + throw new MWException( __METHOD__ . ' called with invalid action: ' . $action ); } return $db->makeList( [ @@ -273,7 +305,10 @@ abstract class AbuseFilterView extends ContextSource { 'rc_log_type' => 'delete', 'rc_log_action' => 'delete' ], LIST_AND ), - // @todo: add upload + $db->makeList( [ + 'rc_log_type' => 'upload', + 'rc_log_action' => [ 'upload', 'overwrite', 'revert' ] + ], LIST_AND ), ], LIST_OR ), ], LIST_AND ), ], LIST_OR ); @@ -290,19 +325,4 @@ abstract class AbuseFilterView extends ContextSource { $text ); } - - /** - * @return bool - */ - public static function canViewPrivate() { - global $wgUser; - static $canView = null; - - if ( is_null( $canView ) ) { - $canView = $wgUser->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ); - } - - return $canView; - } - } diff --git a/AbuseFilter/includes/Views/AbuseFilterViewDiff.php b/AbuseFilter/includes/Views/AbuseFilterViewDiff.php index 8128ad65..11699a50 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewDiff.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewDiff.php @@ -1,9 +1,23 @@ <?php - +/** + * @phan-file-suppress PhanTypeArraySuspiciousNullable Some confusion with class members + */ class AbuseFilterViewDiff extends AbuseFilterView { + /** + * @var (string|array)[]|null The old version of the filter + */ public $mOldVersion = null; + /** + * @var (string|array)[]|null The new version of the filter + */ public $mNewVersion = null; + /** + * @var int|null The history ID of the next version, if any + */ public $mNextHistoryId = null; + /** + * @var int|null The ID of the filter + */ public $mFilter = null; /** @@ -55,7 +69,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { ] ); } - if ( !is_null( $this->mNextHistoryId ) ) { + if ( $this->mNextHistoryId !== null ) { // Create a "next change" link if this isn't the last change of the given filter $href = $this->getTitle( 'history/' . $this->mFilter . '/diff/prev/' . $this->mNextHistoryId @@ -85,21 +99,29 @@ class AbuseFilterViewDiff extends AbuseFilterView { $newSpec = $this->mParams[4]; $this->mFilter = $this->mParams[1]; - if ( AbuseFilter::filterHidden( $this->mFilter ) - && !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) - ) { - $this->getOutput()->addWikiMsg( 'abusefilter-history-error-hidden' ); + if ( !is_numeric( $this->mFilter ) ) { + $this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' ); return false; } $this->mOldVersion = $this->loadSpec( $oldSpec, $newSpec ); $this->mNewVersion = $this->loadSpec( $newSpec, $oldSpec ); - if ( is_null( $this->mOldVersion ) || is_null( $this->mNewVersion ) ) { + if ( $this->mOldVersion === null || $this->mNewVersion === null ) { $this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' ); return false; } + if ( !AbuseFilter::canViewPrivate( $this->getUser() ) && + ( + in_array( 'hidden', explode( ',', $this->mOldVersion['info']['flags'] ) ) || + in_array( 'hidden', explode( ',', $this->mNewVersion['info']['flags'] ) ) + ) + ) { + $this->getOutput()->addWikiMsg( 'abusefilter-history-error-hidden' ); + return false; + } + $this->mNextHistoryId = $this->getNextHistoryId( $this->mNewVersion['meta']['history_id'] ); @@ -134,7 +156,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { /** * @param string $spec * @param string $otherSpec - * @return array|null + * @return (string|array)[]|null */ public function loadSpec( $spec, $otherSpec ) { static $dependentSpecs = [ 'prev', 'next' ]; @@ -166,7 +188,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { [ 'afh_id' => $spec, 'afh_filter' => $this->mFilter ], __METHOD__ ); - } elseif ( $spec == 'cur' ) { + } elseif ( $spec === 'cur' ) { $row = $dbr->selectRow( 'abuse_filter_history', $selectFields, @@ -174,39 +196,23 @@ class AbuseFilterViewDiff extends AbuseFilterView { __METHOD__, [ 'ORDER BY' => 'afh_timestamp desc' ] ); - } elseif ( $spec == 'prev' && !in_array( $otherSpec, $dependentSpecs ) ) { - // cached - $other = $this->loadSpec( $otherSpec, $spec ); - - $row = $dbr->selectRow( - 'abuse_filter_history', - $selectFields, - [ - 'afh_filter' => $this->mFilter, - 'afh_id<' . $dbr->addQuotes( $other['meta']['history_id'] ), - ], - __METHOD__, - [ 'ORDER BY' => 'afh_timestamp desc' ] - ); - if ( $other && !$row ) { - $t = $this->getTitle( - 'history/' . $this->mFilter . '/item/' . $other['meta']['history_id'] ); - $this->getOutput()->redirect( $t->getFullURL() ); - return null; - } - } elseif ( $spec == 'next' && !in_array( $otherSpec, $dependentSpecs ) ) { + } elseif ( ( $spec === 'prev' || $spec === 'next' ) && + !in_array( $otherSpec, $dependentSpecs ) + ) { // cached $other = $this->loadSpec( $otherSpec, $spec ); + $comparison = $spec === 'prev' ? '<' : '>'; + $order = $spec === 'prev' ? 'DESC' : 'ASC'; $row = $dbr->selectRow( 'abuse_filter_history', $selectFields, [ 'afh_filter' => $this->mFilter, - 'afh_id>' . $dbr->addQuotes( $other['meta']['history_id'] ), + "afh_id $comparison" . $dbr->addQuotes( $other['meta']['history_id'] ), ], __METHOD__, - [ 'ORDER BY' => 'afh_timestamp ASC' ] + [ 'ORDER BY' => "afh_timestamp $order" ] ); if ( $other && !$row ) { @@ -228,7 +234,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { /** * @param stdClass $row - * @return array + * @return (string|array)[] */ public function loadFromHistoryRow( $row ) { return [ @@ -319,7 +325,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { ); if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 || - $oldVersion['info']['group'] != $newVersion['info']['group'] + $oldVersion['info']['group'] !== $newVersion['info']['group'] ) { $info .= $this->getDiffRow( 'abusefilter-edit-group', @@ -329,8 +335,8 @@ class AbuseFilterViewDiff extends AbuseFilterView { } $info .= $this->getDiffRow( 'abusefilter-edit-flags', - AbuseFilter::formatFlags( $oldVersion['info']['flags'] ), - AbuseFilter::formatFlags( $newVersion['info']['flags'] ) + AbuseFilter::formatFlags( $oldVersion['info']['flags'], $this->getLanguage() ), + AbuseFilter::formatFlags( $newVersion['info']['flags'], $this->getLanguage() ) ); $info .= $this->getDiffRow( @@ -343,7 +349,6 @@ class AbuseFilterViewDiff extends AbuseFilterView { $body .= $infoHeader . $info; } - // Pattern $patternHeader = $this->getHeaderRow( 'abusefilter-diff-pattern' ); $pattern = ''; $pattern .= $this->getDiffRow( @@ -356,7 +361,6 @@ class AbuseFilterViewDiff extends AbuseFilterView { $body .= $patternHeader . $pattern; } - // Actions $actionsHeader = $this->getHeaderRow( 'abusefilter-edit-consequences' ); $actions = ''; @@ -392,7 +396,7 @@ class AbuseFilterViewDiff extends AbuseFilterView { ksort( $actions ); foreach ( $actions as $action => $parameters ) { - $lines[] = AbuseFilter::formatAction( $action, $parameters ); + $lines[] = AbuseFilter::formatAction( $action, $parameters, $this->getLanguage() ); } if ( !count( $lines ) ) { diff --git a/AbuseFilter/includes/Views/AbuseFilterViewEdit.php b/AbuseFilter/includes/Views/AbuseFilterViewEdit.php index e4c5a93e..d395564d 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewEdit.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewEdit.php @@ -1,12 +1,13 @@ <?php +use MediaWiki\MediaWikiServices; + class AbuseFilterViewEdit extends AbuseFilterView { - public static $mLoadedRow = null, $mLoadedActions = null; /** * @param SpecialAbuseFilter $page * @param array $params */ - public function __construct( $page, $params ) { + public function __construct( SpecialAbuseFilter $page, array $params ) { parent::__construct( $page, $params ); $this->mFilter = $page->mFilter; $this->mHistoryID = $page->mHistoryID; @@ -19,25 +20,34 @@ class AbuseFilterViewEdit extends AbuseFilterView { $user = $this->getUser(); $out = $this->getOutput(); $request = $this->getRequest(); - $config = $this->getConfig(); $out->setPageTitle( $this->msg( 'abusefilter-edit' ) ); $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); $filter = $this->mFilter; + if ( !is_numeric( $filter ) && $filter !== 'new' ) { + $out->addHTML( + Xml::tags( + 'p', + null, + Html::errorBox( $this->msg( 'abusefilter-edit-badfilter' )->parse() ) + ) + ); + return; + } $history_id = $this->mHistoryID; if ( $this->mHistoryID ) { $dbr = wfGetDB( DB_REPLICA ); - $row = $dbr->selectRow( + $lastID = (int)$dbr->selectField( 'abuse_filter_history', 'afh_id', [ 'afh_filter' => $filter, ], __METHOD__, - [ 'ORDER BY' => 'afh_timestamp DESC' ] + [ 'ORDER BY' => 'afh_id DESC' ] ); // change $history_id to null if it's current version id - if ( $row->afh_id === $this->mHistoryID ) { + if ( $lastID === $this->mHistoryID ) { $history_id = null; } } @@ -45,7 +55,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { // Add the default warning and disallow messages in a JS variable $this->exposeMessages(); - if ( $filter == 'new' && !$this->canEdit() ) { + $canEdit = AbuseFilter::canEdit( $user ); + + if ( $filter === 'new' && !$canEdit ) { $out->addHTML( Xml::tags( 'p', @@ -59,86 +71,115 @@ class AbuseFilterViewEdit extends AbuseFilterView { $editToken = $request->getVal( 'wpEditToken' ); $tokenMatches = $user->matchEditToken( $editToken, [ 'abusefilter', $filter ], $request ); + $isImport = $request->getRawVal( 'wpImportText' ) !== null; + + if ( $request->wasPosted() && $canEdit && $tokenMatches ) { + $this->saveCurrentFilter( $filter, $history_id ); + return; + } - if ( $tokenMatches && $this->canEdit() ) { - list( $newRow, $actions ) = $this->loadRequest( $filter ); - $status = AbuseFilter::saveFilter( $this, $filter, $request, $newRow, $actions ); + if ( $request->wasPosted() && !$isImport && !$tokenMatches ) { + // Special case for when the token has expired with the page open, warn to retry + $out->addHTML( + Html::warningBox( $this->msg( 'abusefilter-edit-token-not-match' )->escaped() ) + ); + } + + if ( $isImport || ( $request->wasPosted() && !$tokenMatches ) ) { + // Make sure to load from HTTP if the token doesn't match! + $status = $this->loadRequest( $filter ); 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 ); - } - } else { - if ( $status->getValue() === false ) { - // No change - $out->redirect( $this->getTitle()->getLocalURL() ); - } else { - list( $new_id, $history_id ) = $status->getValue(); - $out->redirect( - $this->getTitle()->getLocalURL( - [ - 'result' => 'success', - 'changedfilter' => $new_id, - 'changeid' => $history_id, - ] - ) - ); - } - } - } else { - if ( $tokenMatches ) { - // Lost rights meanwhile $out->addHTML( Xml::tags( 'p', null, - Html::errorBox( $this->msg( 'abusefilter-edit-notallowed' )->parse() ) + Html::errorBox( $status->getMessage()->parse() ) ) ); - } elseif ( $request->wasPosted() ) { - // Warn the user to re-attempt save - $out->addHTML( - Html::warningBox( $this->msg( 'abusefilter-edit-token-not-match' )->escaped() ) - ); + return; } + $data = $status->getValue(); + } else { + $data = $this->loadFromDatabase( $filter, $history_id ); + } - if ( $history_id ) { - $out->addWikiMsg( - 'abusefilter-edit-oldwarning', $history_id, $filter ); - } + list( $row, $actions ) = $data ?? [ null, [] ]; - $out->addHTML( $this->buildFilterEditor( null, $filter, $history_id ) ); + // Either the user is just viewing the filter, they cannot edit it, they lost the + // abusefilter-modify right with the page open, the token is invalid, or they're viewing + // the result of importing a filter + $this->buildFilterEditor( null, $row, $actions, $filter, $history_id ); + } - if ( $history_id ) { - $out->addWikiMsg( - 'abusefilter-edit-oldwarning', $history_id, $filter ); + /** + * @param int|string $filter The filter ID or 'new'. + * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null + */ + private function saveCurrentFilter( $filter, $history_id ) : void { + $out = $this->getOutput(); + $reqStatus = $this->loadRequest( $filter ); + if ( !$reqStatus->isGood() ) { + // In the current implementation, this cannot happen. + throw new LogicException( 'Should always be able to retrieve data for saving' ); + } + list( $newRow, $actions ) = $reqStatus->getValue(); + $dbw = wfGetDB( DB_MASTER ); + $status = AbuseFilter::saveFilter( $this, $filter, $newRow, $actions, $dbw ); + + if ( !$status->isGood() ) { + $err = $status->getErrors(); + $msg = $err[0]['message']; + $params = $err[0]['params']; + if ( $status->isOK() ) { + // Fixable error, show the editing interface + $this->buildFilterEditor( + $this->msg( $msg, $params )->parseAsBlock(), + $newRow, + $actions, + $filter, + $history_id + ); + } else { + // Permission-related error + $out->addWikiMsg( $msg ); } + } elseif ( $status->getValue() === false ) { + // No change + $out->redirect( $this->getTitle()->getLocalURL() ); + } else { + // Everything went fine! + list( $new_id, $history_id ) = $status->getValue(); + $out->redirect( + $this->getTitle()->getLocalURL( + [ + 'result' => 'success', + 'changedfilter' => $new_id, + 'changeid' => $history_id, + ] + ) + ); } } /** - * 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 + * Builds the full form for edit filters, adding it to the OutputPage. $row and $actions can be + * passed in (for instance if there was a failure during save) to avoid searching the DB. + * * @param string|null $error An error message to show above the filter box. - * @param int $filter The filter ID + * @param stdClass|null $row abuse_filter row representing this filter, null if it doesn't exist + * @param array $actions Actions enabled and their parameters + * @param int|string $filter The filter ID or 'new'. * @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. */ - public function buildFilterEditor( $error, $filter, $history_id = null ) { + protected function buildFilterEditor( + $error, + ?stdClass $row, + array $actions, + $filter, + $history_id + ) { if ( $filter === null ) { - return false; + return; } // Build the edit form @@ -148,10 +189,11 @@ class AbuseFilterViewEdit extends AbuseFilterView { $lang = $this->getLanguage(); $user = $this->getUser(); - // Load from request OR database. - list( $row, $actions ) = $this->loadRequest( $filter, $history_id ); - - if ( !$row ) { + if ( + $row === null || + // @fixme Temporary stopgap for T237887 + ( $history_id && $row->af_id !== $filter ) + ) { $out->addHTML( Xml::tags( 'p', @@ -165,44 +207,53 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'href' => $href ] ); $out->addHTML( $btn ); - return false; + return; } $out->addSubtitle( $this->msg( $filter === 'new' ? 'abusefilter-edit-subtitle-new' : 'abusefilter-edit-subtitle', - $this->getLanguage()->formatNum( $filter ), $history_id + $filter === 'new' ? $filter : $this->getLanguage()->formatNum( $filter ), + $history_id )->parse() ); // Hide hidden filters. - if ( ( ( isset( $row->af_hidden ) && $row->af_hidden ) || - AbuseFilter::filterHidden( $filter ) ) - && !$this->canViewPrivate() ) { - return $this->msg( 'abusefilter-edit-denied' )->escaped(); + if ( + ( $row->af_hidden || ( $filter !== 'new' && AbuseFilter::filterHidden( $filter ) ) ) && + !AbuseFilter::canViewPrivate( $user ) + ) { + $out->addHTML( $this->msg( 'abusefilter-edit-denied' )->escaped() ); + return; } - $output = ''; - if ( $error ) { - $output .= Html::errorBox( $error ); + if ( $history_id ) { + $oldWarningMessage = AbuseFilter::canEditFilter( $user, $row ) + ? 'abusefilter-edit-oldwarning' + : 'abusefilter-edit-oldwarning-view'; + $out->addWikiMsg( + $oldWarningMessage, + $history_id, + $filter + ); } - // Read-only attribute - $readOnlyAttrib = []; - - if ( !$this->canEditFilter( $row ) ) { - $readOnlyAttrib['disabled'] = 'disabled'; + if ( $error ) { + $out->addHTML( Html::errorBox( $error ) ); } + $readOnly = !AbuseFilter::canEditFilter( $user, $row ); + $fields = []; $fields['abusefilter-edit-id'] = - $this->mFilter == 'new' ? + $this->mFilter === 'new' ? $this->msg( 'abusefilter-edit-new' )->escaped() : $lang->formatNum( $filter ); $fields['abusefilter-edit-description'] = new OOUI\TextInputWidget( [ 'name' => 'wpFilterDescription', - 'value' => $row->af_public_comments ?? '' - ] + $readOnlyAttrib + 'value' => $row->af_public_comments ?? '', + 'readOnly' => $readOnly + ] ); $validGroups = $this->getConfig()->get( 'AbuseFilterValidGroups' ); @@ -212,7 +263,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'name' => 'wpFilterGroup', 'id' => 'mw-abusefilter-edit-group-input', 'value' => 'default', - 'disabled' => !empty( $readOnlyAttrib ) + 'disabled' => $readOnly ] ); $options = []; @@ -231,7 +282,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { } // Hit count display - if ( !empty( $row->af_hit_count ) && $user->isAllowed( 'abusefilter-log-detail' ) ) { + if ( !empty( $row->af_hit_count ) && SpecialAbuseLog::canSeeDetails( $user ) ) { $count_display = $this->msg( 'abusefilter-hitcount' ) ->numParams( (int)$row->af_hit_count )->text(); $hitCount = $this->linkRenderer->makeKnownLink( @@ -244,39 +295,31 @@ class AbuseFilterViewEdit extends AbuseFilterView { $fields['abusefilter-edit-hitcount'] = $hitCount; } - if ( $filter !== 'new' ) { + if ( $filter !== 'new' && $row->af_enabled ) { // Statistics - $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 ( $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 ) - ->escaped(); - } else { - $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status' ) - ->numParams( $total, $matches_count, $matches_percent ) - ->parse(); - } + list( $totalCount, $matchesCount, $avgTime, $avgCond ) = + AbuseFilter::getFilterProfile( $filter ); + + if ( $totalCount > 0 ) { + $matchesPercent = round( 100 * $matchesCount / $totalCount, 2 ); + $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status' ) + ->numParams( $totalCount, $matchesCount, $matchesPercent, $avgTime, $avgCond ) + ->parse(); } } $fields['abusefilter-edit-rules'] = $this->buildEditBox( $row->af_pattern, - 'wpFilterRules', true ); $fields['abusefilter-edit-notes'] = new OOUI\MultilineTextInputWidget( [ 'name' => 'wpFilterNotes', 'value' => isset( $row->af_comments ) ? $row->af_comments . "\n" : "\n", - 'rows' => 15 - ] + $readOnlyAttrib - ); + 'rows' => 15, + 'readOnly' => $readOnly, + 'id' => 'mw-abusefilter-notes-editor' + ] ); // Build checkboxes $checkboxes = [ 'hidden', 'enabled', 'deleted' ]; @@ -301,12 +344,10 @@ class AbuseFilterViewEdit extends AbuseFilterView { array_keys( $throttledActions ) ); - $flags .= $out->parse( - Html::warningBox( - $this->msg( 'abusefilter-edit-throttled-warning' ) - ->plaintextParams( $lang->commaList( $throttledActions ) ) - ->text() - ) + $flags .= Html::warningBox( + $this->msg( 'abusefilter-edit-throttled-warning' ) + ->plaintextParams( $lang->commaList( $throttledActions ) ) + ->parseAsBlock() ); } } @@ -325,25 +366,26 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'name' => $postVar, 'id' => $postVar, 'selected' => $row->$dbField ?? false, - ] + $readOnlyAttrib; + 'disabled' => $readOnly + ]; $labelAttribs = [ 'label' => $this->msg( $message )->text(), 'align' => 'inline', ]; - if ( $checkboxId == 'global' && !$this->canEditGlobal() ) { + if ( $checkboxId === 'global' && !AbuseFilter::canEditGlobal( $user ) ) { $checkboxAttribs['disabled'] = 'disabled'; } // Set readonly on deleted if the filter isn't disabled - if ( $checkboxId == 'deleted' && $row->af_enabled == 1 ) { + if ( $checkboxId === 'deleted' && $row->af_enabled == 1 ) { $checkboxAttribs['disabled'] = 'disabled'; } // Add infusable where needed - if ( $checkboxId == 'deleted' || $checkboxId == 'enabled' ) { + if ( $checkboxId === 'deleted' || $checkboxId === 'enabled' ) { $checkboxAttribs['infusable'] = true; - if ( $checkboxId == 'deleted' ) { + if ( $checkboxId === 'deleted' ) { $labelAttribs['id'] = $postVar . 'Label'; $labelAttribs['infusable'] = true; } @@ -358,10 +400,12 @@ class AbuseFilterViewEdit extends AbuseFilterView { } $fields['abusefilter-edit-flags'] = $flags; - $tools = ''; - if ( $filter != 'new' ) { - if ( $user->isAllowed( 'abusefilter-revert' ) ) { + if ( $filter !== 'new' ) { + $tools = ''; + if ( MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $user, 'abusefilter-revert' ) + ) { $tools .= Xml::tags( 'p', null, $this->linkRenderer->makeLink( @@ -371,7 +415,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { ); } - if ( $this->canEdit() ) { + if ( AbuseFilter::canViewPrivate( $user ) ) { // Test link $tools .= Xml::tags( 'p', null, @@ -407,21 +451,21 @@ class AbuseFilterViewEdit extends AbuseFilterView { $history_display = new HtmlArmor( $this->msg( 'abusefilter-edit-viewhistory' )->parse() ); $fields['abusefilter-edit-history'] = $this->linkRenderer->makeKnownLink( $this->getTitle( 'history/' . $filter ), $history_display ); - } - // Add export - $exportText = FormatJson::encode( [ 'row' => $row, 'actions' => $actions ] ); - $tools .= Xml::tags( 'a', [ 'href' => '#', 'id' => 'mw-abusefilter-export-link' ], - $this->msg( 'abusefilter-edit-export' )->parse() ); - $tools .= - new OOUI\MultilineTextInputWidget( [ - 'id' => 'mw-abusefilter-export', - 'readOnly' => true, - 'value' => $exportText, - 'rows' => 10 - ] ); + // Add export + $exportText = FormatJson::encode( [ 'row' => $row, 'actions' => $actions ] ); + $tools .= Xml::tags( 'a', [ 'href' => '#', 'id' => 'mw-abusefilter-export-link' ], + $this->msg( 'abusefilter-edit-export' )->parse() ); + $tools .= + new OOUI\MultilineTextInputWidget( [ + 'id' => 'mw-abusefilter-export', + 'readOnly' => true, + 'value' => $exportText, + 'rows' => 10 + ] ); - $fields['abusefilter-edit-tools'] = $tools; + $fields['abusefilter-edit-tools'] = $tools; + } $form = Xml::buildForm( $fields ); $form = Xml::fieldset( $this->msg( 'abusefilter-edit-main' )->text(), $form ); @@ -430,7 +474,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { $this->buildConsequenceEditor( $row, $actions ) ); - if ( $this->canEditFilter( $row ) ) { + if ( AbuseFilter::canEditFilter( $user, $row ) ) { $form .= new OOUI\ButtonInputWidget( [ 'type' => 'submit', @@ -448,24 +492,28 @@ class AbuseFilterViewEdit extends AbuseFilterView { $form = Xml::tags( 'form', [ 'action' => $this->getTitle( $filter )->getFullURL(), - 'method' => 'post' + 'method' => 'post', + 'id' => 'mw-abusefilter-editing-form' ], $form ); - $output .= $form; + $out->addHTML( $form ); - return $output; + if ( $history_id ) { + // @phan-suppress-next-line PhanPossiblyUndeclaredVariable + $out->addWikiMsg( $oldWarningMessage, $history_id, $filter ); + } } /** * Builds the "actions" editor for a given filter. * @param stdClass $row A row from the abuse_filter table. - * @param array $actions Array of rows from the abuse_filter_action table + * @param array[] $actions Array of rows from the abuse_filter_action table * corresponding to the abuse filter held in $row. * @return string HTML text for an action editor. */ - public function buildConsequenceEditor( $row, $actions ) { + private function buildConsequenceEditor( $row, array $actions ) { $enabledActions = array_filter( $this->getConfig()->get( 'AbuseFilterActions' ) ); @@ -478,11 +526,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { $output = ''; foreach ( $enabledActions as $action => $_ ) { - Wikimedia\suppressWarnings(); - $params = $actions[$action]['parameters']; - Wikimedia\restoreWarnings(); + $params = $actions[$action] ?? null; $output .= $this->buildConsequenceSelector( - $action, $setActions[$action], $params, $row ); + $action, $setActions[$action], $row, $params ); } return $output; @@ -491,22 +537,19 @@ class AbuseFilterViewEdit extends AbuseFilterView { /** * @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 + * @param string[]|null $parameters Action parameters. Null iff $set is false. * @return string|\OOUI\FieldLayout */ - public function buildConsequenceSelector( $action, $set, $parameters, $row ) { + private function buildConsequenceSelector( $action, $set, $row, ?array $parameters ) { $config = $this->getConfig(); + $user = $this->getUser(); $actions = $config->get( 'AbuseFilterActions' ); if ( empty( $actions[$action] ) ) { return ''; } - $readOnlyAttrib = []; - - if ( !$this->canEditFilter( $row ) ) { - $readOnlyAttrib['disabled'] = 'disabled'; - } + $readOnly = !AbuseFilter::canEditFilter( $user, $row ); switch ( $action ) { case 'throttle': @@ -520,8 +563,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'name' => 'wpFilterActionThrottle', 'id' => 'mw-abusefilter-action-checkbox-throttle', 'selected' => $set, - 'classes' => [ 'mw-abusefilter-action-checkbox' ] - ] + $readOnlyAttrib + 'classes' => [ 'mw-abusefilter-action-checkbox' ], + 'disabled' => $readOnly + ] ), [ 'label' => $this->msg( 'abusefilter-edit-action-throttle' )->text(), @@ -531,12 +575,10 @@ class AbuseFilterViewEdit extends AbuseFilterView { $throttleFields = []; if ( $set ) { - array_shift( $parameters ); - $throttleRate = explode( ',', $parameters[0] ); - $throttleCount = $throttleRate[0]; - $throttlePeriod = $throttleRate[1]; + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable $parameters is array here + list( $throttleCount, $throttlePeriod ) = explode( ',', $parameters[1], 2 ); - $throttleGroups = array_slice( $parameters, 1 ); + $throttleGroups = array_slice( $parameters, 2 ); } else { $throttleCount = 3; $throttlePeriod = 60; @@ -549,8 +591,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { new OOUI\TextInputWidget( [ 'type' => 'number', 'name' => 'wpFilterThrottleCount', - 'value' => $throttleCount - ] + $readOnlyAttrib + 'value' => $throttleCount, + 'readOnly' => $readOnly + ] ), [ 'label' => $this->msg( 'abusefilter-edit-throttle-count' )->text() @@ -561,21 +604,26 @@ class AbuseFilterViewEdit extends AbuseFilterView { new OOUI\TextInputWidget( [ 'type' => 'number', 'name' => 'wpFilterThrottlePeriod', - 'value' => $throttlePeriod - ] + $readOnlyAttrib + 'value' => $throttlePeriod, + 'readOnly' => $readOnly + ] ), [ '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 ); - + $groupsHelpLink = Html::element( + 'a', + [ + 'href' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/' . + 'Extension:AbuseFilter/Actions#Throttling', + 'target' => '_blank' + ], + $this->msg( 'abusefilter-edit-throttle-groups-help-text' )->text() + ); + $groupsHelp = $this->msg( 'abusefilter-edit-throttle-groups-help' ) + ->rawParams( $groupsHelpLink )->escaped(); $hiddenGroups = new OOUI\FieldLayout( new OOUI\MultilineTextInputWidget( [ @@ -584,20 +632,31 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'rows' => 5, 'placeholder' => $this->msg( 'abusefilter-edit-throttle-hidden-placeholder' )->text(), 'infusable' => true, - 'id' => 'mw-abusefilter-hidden-throttle-field' - ] + $readOnlyAttrib + 'id' => 'mw-abusefilter-hidden-throttle-field', + 'readOnly' => $readOnly + ] ), [ 'label' => new OOUI\HtmlSnippet( $this->msg( 'abusefilter-edit-throttle-groups' )->parse() ), 'align' => 'top', - 'id' => 'mw-abusefilter-hidden-throttle' + 'id' => 'mw-abusefilter-hidden-throttle', + 'help' => new OOUI\HtmlSnippet( $groupsHelp ), + 'helpInline' => true ] ); $throttleFields['abusefilter-edit-throttle-groups'] = $hiddenGroups; + $throttleConfig = [ + 'values' => $throttleGroups, + 'label' => $this->msg( 'abusefilter-edit-throttle-groups' )->parse(), + 'disabled' => $readOnly, + 'help' => $groupsHelp + ]; + $this->getOutput()->addJsConfigVars( 'throttleConfig', $throttleConfig ); + $throttleSettings .= Xml::tags( 'div', @@ -616,8 +675,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { // mw-abusefilter-action-checkbox-warn, mw-abusefilter-action-checkbox-disallow 'id' => "mw-abusefilter-action-checkbox-$action", 'selected' => $set, - 'classes' => [ 'mw-abusefilter-action-checkbox' ] - ] + $readOnlyAttrib + 'classes' => [ 'mw-abusefilter-action-checkbox' ], + 'disabled' => $readOnly + ] ), [ // abusefilter-edit-action-warn, abusefilter-edit-action-disallow @@ -644,8 +704,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { $msg = $action === 'warn' ? 'abusefilter-warning' : 'abusefilter-disallowed'; } + $fields = []; $fields["abusefilter-edit-$action-message"] = - $this->getExistingSelector( $msg, !empty( $readOnlyAttrib ), $action ); + $this->getExistingSelector( $msg, $readOnly, $action ); $otherFieldName = $action === 'warn' ? 'wpFilterWarnMessageOther' : 'wpFilterDisallowMessageOther'; @@ -656,8 +717,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'value' => $msg, // mw-abusefilter-warn-message-other, mw-abusefilter-disallow-message-other 'id' => "mw-abusefilter-$action-message-other", - 'infusable' => true - ] + $readOnlyAttrib + 'infusable' => true, + 'readOnly' => $readOnly + ] ), [ 'label' => new OOUI\HtmlSnippet( @@ -679,7 +741,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { ); $buttonGroup = $previewButton; - if ( $this->getUser()->isAllowed( 'editinterface' ) ) { + if ( MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $user, 'editinterface' ) + ) { $editButton = new OOUI\ButtonInputWidget( [ // abusefilter-edit-warn-edit, abusefilter-edit-disallow-edit @@ -720,11 +784,8 @@ class AbuseFilterViewEdit extends AbuseFilterView { return $output; case 'tag': - if ( $set ) { - $tags = $parameters; - } else { - $tags = []; - } + $tags = $set ? $parameters : []; + '@phan-var string[] $parameters'; $output = ''; $checkbox = @@ -733,8 +794,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'name' => 'wpFilterActionTag', 'id' => 'mw-abusefilter-action-checkbox-tag', 'selected' => $set, - 'classes' => [ 'mw-abusefilter-action-checkbox' ] - ] + $readOnlyAttrib + 'classes' => [ 'mw-abusefilter-action-checkbox' ], + 'disabled' => $readOnly + ] ), [ 'label' => $this->msg( 'abusefilter-edit-action-tag' )->text(), @@ -746,7 +808,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { $tagConfig = [ 'values' => $tags, 'label' => $this->msg( 'abusefilter-edit-tag-tag' )->parse(), - 'disabled' => $readOnlyAttrib + 'disabled' => $readOnly ]; $this->getOutput()->addJsConfigVars( 'tagConfig', $tagConfig ); @@ -758,8 +820,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'rows' => 5, 'placeholder' => $this->msg( 'abusefilter-edit-tag-hidden-placeholder' )->text(), 'infusable' => true, - 'id' => 'mw-abusefilter-hidden-tag-field' - ] + $readOnlyAttrib + 'id' => 'mw-abusefilter-hidden-tag-field', + 'readOnly' => $readOnly + ] ), [ 'label' => new OOUI\HtmlSnippet( @@ -778,12 +841,11 @@ class AbuseFilterViewEdit extends AbuseFilterView { case 'block': if ( $set && count( $parameters ) === 3 ) { // Both blocktalk and custom block durations available - $blockTalk = $parameters[0]; - $defaultAnonDuration = $parameters[1]; - $defaultUserDuration = $parameters[2]; + list( $blockTalk, $defaultAnonDuration, $defaultUserDuration ) = $parameters; } else { if ( $set && count( $parameters ) === 1 ) { // Only blocktalk available + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable $parameters is array here $blockTalk = $parameters[0]; } if ( $config->get( 'AbuseFilterAnonBlockDuration' ) ) { @@ -803,8 +865,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'name' => 'wpFilterActionBlock', 'id' => 'mw-abusefilter-action-checkbox-block', 'selected' => $set, - 'classes' => [ 'mw-abusefilter-action-checkbox' ] - ] + $readOnlyAttrib + 'classes' => [ 'mw-abusefilter-action-checkbox' ], + 'disabled' => $readOnly + ] ), [ 'label' => $this->msg( 'abusefilter-edit-action-block' )->text(), @@ -820,7 +883,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'name' => 'wpBlockAnonDuration', 'options' => $suggestedBlocks, 'value' => $defaultAnonDuration, - 'disabled' => !$this->canEditFilter( $row ) + 'disabled' => !AbuseFilter::canEditFilter( $user, $row ) ] ); $userDuration = @@ -828,7 +891,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'name' => 'wpBlockUserDuration', 'options' => $suggestedBlocks, 'value' => $defaultUserDuration, - 'disabled' => !$this->canEditFilter( $row ) + 'disabled' => !AbuseFilter::canEditFilter( $user, $row ) ] ); $blockOptions = []; @@ -838,9 +901,10 @@ class AbuseFilterViewEdit extends AbuseFilterView { new OOUI\CheckboxInputWidget( [ 'name' => 'wpFilterBlockTalk', 'id' => 'mw-abusefilter-action-checkbox-blocktalk', - 'selected' => isset( $blockTalk ) && $blockTalk == 'blocktalk', - 'classes' => [ 'mw-abusefilter-action-checkbox' ] - ] + $readOnlyAttrib + 'selected' => isset( $blockTalk ) && $blockTalk === 'blocktalk', + 'classes' => [ 'mw-abusefilter-action-checkbox' ], + 'disabled' => $readOnly + ] ), [ 'label' => $this->msg( 'abusefilter-edit-action-blocktalk' )->text(), @@ -889,15 +953,15 @@ class AbuseFilterViewEdit extends AbuseFilterView { 'name' => $form_field, 'id' => "mw-abusefilter-action-checkbox-$action", 'selected' => $status, - 'classes' => [ 'mw-abusefilter-action-checkbox' ] - ] + $readOnlyAttrib + 'classes' => [ 'mw-abusefilter-action-checkbox' ], + 'disabled' => $readOnly + ] ), [ 'label' => $this->msg( $message )->text(), 'align' => 'inline' ] ); - $thisAction = $thisAction; return $thisAction; } } @@ -927,7 +991,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { // 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', + 'value' => $warnMsg === "abusefilter-$action" ? "abusefilter-$action" : 'other', 'infusable' => true ] ); @@ -940,9 +1004,9 @@ class AbuseFilterViewEdit extends AbuseFilterView { // Find other messages. $dbr = wfGetDB( DB_REPLICA ); $pageTitlePrefix = "Abusefilter-$action"; - $res = $dbr->select( + $titles = $dbr->selectFieldValues( 'page', - [ 'page_title' ], + 'page_title', [ 'page_namespace' => 8, 'page_title LIKE ' . $dbr->addQuotes( $pageTitlePrefix . '%' ) @@ -951,19 +1015,19 @@ class AbuseFilterViewEdit extends AbuseFilterView { ); $lang = $this->getLanguage(); - foreach ( $res as $row ) { - if ( $lang->lcfirst( $row->page_title ) == $lang->lcfirst( $warnMsg ) ) { + foreach ( $titles as $title ) { + if ( $lang->lcfirst( $title ) === $lang->lcfirst( $warnMsg ) ) { $existingSelector->setValue( $lang->lcfirst( $warnMsg ) ); } - if ( $row->page_title != "Abusefilter-$action" ) { - $options += [ $lang->lcfirst( $row->page_title ) => $lang->lcfirst( $row->page_title ) ]; + if ( $title !== "Abusefilter-$action" ) { + $options[ $lang->lcfirst( $title ) ] = $lang->lcfirst( $title ); } } } // abusefilter-edit-warn-other, abusefilter-edit-disallow-other - $options += [ $this->msg( "abusefilter-edit-$formId-other" )->text() => 'other' ]; + $options[ $this->msg( "abusefilter-edit-$formId-other" )->text() ] = 'other'; $options = Xml::listDropDownOptionsOoui( $options ); $existingSelector->setOptions( $options ); @@ -987,7 +1051,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { * @param array $durations * @return array */ - protected static function normalizeBlocks( $durations ) { + protected static function normalizeBlocks( array $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 @@ -1029,19 +1093,23 @@ class AbuseFilterViewEdit extends AbuseFilterView { /** * Loads filter data from the database by ID. - * @param int $id The filter's ID number - * @return array|null Either an associative array representing the filter, + * @param int|string $id The filter's ID number, or 'new' + * @return array|null Either a [ DB row, actions ] array representing the filter, * or NULL if the filter does not exist. */ public function loadFilterData( $id ) { - if ( $id == 'new' ) { - $obj = new stdClass; - $obj->af_pattern = ''; - $obj->af_enabled = 1; - $obj->af_hidden = 0; - $obj->af_global = 0; - $obj->af_throttled = 0; - return [ $obj, [] ]; + if ( $id === 'new' ) { + return [ + (object)[ + 'af_pattern' => '', + 'af_enabled' => 1, + 'af_hidden' => 0, + 'af_global' => 0, + 'af_throttled' => 0, + 'af_hit_count' => 0 + ], + [] + ]; } // Load from master to avoid unintended reversions where there's replication lag. @@ -1079,7 +1147,6 @@ class AbuseFilterViewEdit extends AbuseFilterView { } // Load the actions - $actions = []; $res = $dbr->select( 'abuse_filter_action', [ 'afa_consequence', 'afa_parameters' ], @@ -1087,57 +1154,78 @@ class AbuseFilterViewEdit extends AbuseFilterView { __METHOD__ ); + $actions = []; foreach ( $res as $actionRow ) { - $thisAction = []; - $thisAction['action'] = $actionRow->afa_consequence; - $thisAction['parameters'] = array_filter( explode( "\n", $actionRow->afa_parameters ) ); - - $actions[$actionRow->afa_consequence] = $thisAction; + $actions[$actionRow->afa_consequence] = + array_filter( explode( "\n", $actionRow->afa_parameters ) ); } return [ $row, $actions ]; } /** - * Load filter data to show in the edit view. - * Either from the HTTP request or from the filter/history_id given. - * The HTTP request always takes precedence. - * Includes caching. - * @param int $filter The filter ID being requested. + * Load filter data to show in the edit view from the DB. + * @param int|string $filter The filter ID being requested or 'new'. * @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. */ - 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 - } elseif ( $history_id ) { + private function loadFromDatabase( $filter, $history_id = null ) { + if ( $history_id ) { return $this->loadHistoryItem( $history_id ); } else { return $this->loadFilterData( $filter ); } + } + + /** + * Load data from the already-POSTed HTTP request. + * + * @throws BadMethodCallException If called without the request being POSTed or when trying + * to import a filter but $filter is not 'new' + * @param int|string $filter The filter ID being requested. + * @return Status If good, the value is the array [ row, actions ]. If not, it contains an + * error message. + */ + public function loadRequest( $filter ): Status { + $request = $this->getRequest(); + if ( !$request->wasPosted() ) { + // Sanity + throw new BadMethodCallException( __METHOD__ . ' called without the request being POSTed.' ); + } // We need some details like last editor - list( $row, $origActions ) = $this->loadFilterData( $filter ); + list( $origRow, $origActions ) = $this->loadFilterData( $filter ); - $row->mOriginalRow = clone $row; + // Default values + $row = (object)[ + 'af_throttled' => $origRow->af_throttled, + 'af_hit_count' => $origRow->af_hit_count, + ]; + $row->mOriginalRow = $origRow; $row->mOriginalActions = $origActions; // Check for importing $import = $request->getVal( 'wpImportText' ); if ( $import ) { + if ( $filter !== 'new' ) { + // Sanity + throw new BadMethodCallException( __METHOD__ . ' called for importing on existing filter.' ); + } $data = FormatJson::decode( $import ); + if ( !$this->isValidImportData( $data ) ) { + return Status::newFatal( 'abusefilter-import-invalid-data' ); + } + $importRow = $data->row; $actions = wfObjectToArray( $data->actions ); + // Some more default values + $row->af_group = 'default'; + $row->af_global = 0; + $copy = [ 'af_public_comments', 'af_pattern', @@ -1151,6 +1239,15 @@ class AbuseFilterViewEdit extends AbuseFilterView { $row->$name = $importRow->$name; } } else { + if ( $filter !== 'new' ) { + // These aren't needed when saving the filter, but they are otherwise (e.g. if + // saving fails and we need to show the edit interface again). + $row->af_id = $origRow->af_id; + $row->af_user = $origRow->af_user; + $row->af_user_text = $origRow->af_user_text; + $row->af_timestamp = $origRow->af_timestamp; + } + $textLoads = [ 'af_public_comments' => 'wpFilterDescription', 'af_pattern' => 'wpFilterRules', @@ -1169,7 +1266,6 @@ class AbuseFilterViewEdit extends AbuseFilterView { $row->af_global = $request->getCheck( 'wpFilterGlobal' ) && $this->getConfig()->get( 'AbuseFilterIsCentral' ); - // Actions $actions = []; foreach ( array_filter( $this->getConfig()->get( 'AbuseFilterActions' ) ) as $action => $_ ) { // Check if it's set @@ -1178,7 +1274,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { if ( $enabled ) { $parameters = []; - if ( $action == 'throttle' ) { + if ( $action === 'throttle' ) { // We need to load the parameters $throttleCount = $request->getIntOrNull( 'wpFilterThrottleCount' ); $throttlePeriod = $request->getIntOrNull( 'wpFilterThrottlePeriod' ); @@ -1198,52 +1294,55 @@ class AbuseFilterViewEdit extends AbuseFilterView { $parameters[0] = $this->mFilter; $parameters[1] = "$throttleCount,$throttlePeriod"; $parameters = array_merge( $parameters, $throttleGroups ); - } elseif ( $action == 'warn' ) { + } elseif ( $action === 'warn' ) { $specMsg = $request->getVal( 'wpFilterWarnMessage' ); - if ( $specMsg == 'other' ) { + if ( $specMsg === 'other' ) { $specMsg = $request->getVal( 'wpFilterWarnMessageOther' ); } $parameters[0] = $specMsg; - } elseif ( $action == 'block' ) { + } elseif ( $action === 'block' ) { $parameters[0] = $request->getCheck( 'wpFilterBlockTalk' ) ? 'blocktalk' : 'noTalkBlockSet'; $parameters[1] = $request->getVal( 'wpBlockAnonDuration' ); $parameters[2] = $request->getVal( 'wpBlockUserDuration' ); - } elseif ( $action == 'disallow' ) { + } elseif ( $action === 'disallow' ) { $specMsg = $request->getVal( 'wpFilterDisallowMessage' ); - if ( $specMsg == 'other' ) { + if ( $specMsg === 'other' ) { $specMsg = $request->getVal( 'wpFilterDisallowMessageOther' ); } $parameters[0] = $specMsg; - } elseif ( $action == 'tag' ) { + } elseif ( $action === 'tag' ) { $parameters = explode( ',', trim( $request->getText( 'wpFilterTags' ) ) ); + if ( $parameters === [ '' ] ) { + // Since it's not possible to manually add an empty tag, this only happens + // if the form is submitted without touching the tag input field. + // We pass an empty array so that the widget won't show an empty tag in the topbar + $parameters = []; + } } - $thisAction = [ 'action' => $action, 'parameters' => $parameters ]; - $actions[$action] = $thisAction; + $actions[$action] = $parameters; } } } - $row->af_actions = implode( ',', array_keys( array_filter( $actions ) ) ); + $row->af_actions = implode( ',', array_keys( $actions ) ); - self::$mLoadedRow = $row; - self::$mLoadedActions = $actions; - return [ $row, $actions ]; + return Status::newGood( [ $row, $actions ] ); } /** * Loads historical data in a form that the editor can understand. * @param int $id History ID - * @return array|bool False if the history ID is not valid, otherwise array in the usual format: + * @return array|null Null 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. */ - public function loadHistoryItem( $id ) { + private function loadHistoryItem( $id ) : ?array { $dbr = wfGetDB( DB_REPLICA ); $row = $dbr->selectRow( 'abuse_filter_history', @@ -1253,7 +1352,7 @@ class AbuseFilterViewEdit extends AbuseFilterView { ); if ( !$row ) { - return false; + return null; } return AbuseFilter::translateFromHistory( $row ); @@ -1272,4 +1371,42 @@ class AbuseFilterViewEdit extends AbuseFilterView { $this->getConfig()->get( 'AbuseFilterDefaultDisallowMessage' ) ); } + + /** + * Perform basic validation on the JSON-decoded import data. This doesn't check if parameters + * are valid etc., but only if the shape of the object is right. + * + * @param mixed $data Already JSON-decoded + * @return bool + */ + private function isValidImportData( $data ) { + global $wgAbuseFilterActions; + + if ( !is_object( $data ) ) { + return false; + } + + $arr = get_object_vars( $data ); + + $expectedKeys = [ 'row' => true, 'actions' => true ]; + if ( count( $arr ) !== count( $expectedKeys ) || array_diff_key( $arr, $expectedKeys ) ) { + return false; + } + + if ( !is_object( $arr['row'] ) || !is_object( $arr['actions'] ) ) { + return false; + } + + foreach ( $arr['actions'] as $action => $params ) { + if ( !array_key_exists( $action, $wgAbuseFilterActions ) || !is_array( $params ) ) { + return false; + } + } + + if ( !AbuseFilter::isFullAbuseFilterRow( $arr['row'] ) ) { + return false; + } + + return true; + } } diff --git a/AbuseFilter/includes/Views/AbuseFilterViewExamine.php b/AbuseFilter/includes/Views/AbuseFilterViewExamine.php index 6c3d065c..6ef6354d 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewExamine.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewExamine.php @@ -1,10 +1,29 @@ <?php -class AbuseFilterViewExamine extends AbuseFilterView { - public static $examineType = null; - public static $examineId = null; +use MediaWiki\Extension\AbuseFilter\VariableGenerator\RCVariableGenerator; +use MediaWiki\MediaWikiServices; +use MediaWiki\Revision\RevisionRecord; - public $mCounter, $mSearchUser, $mSearchPeriodStart, $mSearchPeriodEnd; +class AbuseFilterViewExamine extends AbuseFilterView { + /** + * @var int Line number of the row, see RecentChange::$counter + */ + public $mCounter; + /** + * @var string The user whose entries we're examinating + */ + public $mSearchUser; + /** + * @var string The start time of the search period + */ + public $mSearchPeriodStart; + /** + * @var string The end time of the search period + */ + public $mSearchPeriodEnd; + /** + * @var string The ID of the filter we're examinating + */ public $mTestFilter; /** @@ -21,7 +40,7 @@ class AbuseFilterViewExamine extends AbuseFilterView { if ( count( $this->mParams ) > 1 && is_numeric( $this->mParams[1] ) ) { $this->showExaminerForRC( $this->mParams[1] ); } elseif ( count( $this->mParams ) > 2 - && $this->mParams[1] == 'log' + && $this->mParams[1] === 'log' && is_numeric( $this->mParams[2] ) ) { $this->showExaminerForLogEntry( $this->mParams[2] ); @@ -96,32 +115,26 @@ class AbuseFilterViewExamine extends AbuseFilterView { */ public function showExaminerForRC( $rcid ) { // Get data - $dbr = wfGetDB( DB_REPLICA ); - $rcQuery = RecentChange::getQueryInfo(); - $row = $dbr->selectRow( - $rcQuery['tables'], - $rcQuery['fields'], - [ 'rc_id' => $rcid ], - __METHOD__, - [], - $rcQuery['joins'] - ); + $rc = RecentChange::newFromId( $rcid ); $out = $this->getOutput(); - if ( !$row ) { + if ( !$rc ) { $out->addWikiMsg( 'abusefilter-examine-notfound' ); return; } - if ( !ChangesList::userCan( RecentChange::newFromRow( $row ), Revision::SUPPRESSED_ALL ) ) { + if ( !ChangesList::userCan( $rc, RevisionRecord::SUPPRESSED_ALL ) ) { $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' ); return; } - self::$examineType = 'rc'; - self::$examineId = $rcid; + $vars = new AbuseFilterVariableHolder(); + $varGenerator = new RCVariableGenerator( $vars, $rc, $this->getUser() ); + $vars = $varGenerator->getVars(); + $out->addJsConfigVars( [ + 'wgAbuseFilterVariables' => $vars ? $vars->dumpAllVars( true ) : [], + 'abuseFilterExamine' => [ 'type' => 'rc', 'id' => $rcid ] + ] ); - $vars = AbuseFilter::getVarsFromRCRow( $row ); - $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) ); $this->showExaminer( $vars ); } @@ -131,6 +144,9 @@ class AbuseFilterViewExamine extends AbuseFilterView { public function showExaminerForLogEntry( $logid ) { // Get data $dbr = wfGetDB( DB_REPLICA ); + $user = $this->getUser(); + $out = $this->getOutput(); + $row = $dbr->selectRow( 'abuse_filter_log', [ @@ -142,35 +158,37 @@ class AbuseFilterViewExamine extends AbuseFilterView { [ 'afl_id' => $logid ], __METHOD__ ); - $out = $this->getOutput(); if ( !$row ) { $out->addWikiMsg( 'abusefilter-examine-notfound' ); return; } - self::$examineType = 'log'; - self::$examineId = $logid; - - if ( !SpecialAbuseLog::canSeeDetails( $row->afl_filter ) ) { + list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter ); + if ( !SpecialAbuseLog::canSeeDetails( $user, $filterID, $global ) ) { $out->addWikiMsg( 'abusefilter-log-cannot-see-details' ); return; } - if ( $row->afl_deleted && !SpecialAbuseLog::canSeeHidden() ) { + if ( $row->afl_deleted && !SpecialAbuseLog::canSeeHidden( $user ) ) { $out->addWikiMsg( 'abusefilter-log-details-hidden' ); return; } if ( SpecialAbuseLog::isHidden( $row ) === 'implicit' ) { - $rev = Revision::newFromId( $row->afl_rev_id ); - if ( !$rev->userCan( Revision::SUPPRESSED_ALL, $this->getUser() ) ) { + $revRec = MediaWikiServices::getInstance() + ->getRevisionLookup() + ->getRevisionById( (int)$row->afl_rev_id ); + if ( !AbuseFilter::userCanViewRev( $revRec, $user ) ) { $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' ); return; } } $vars = AbuseFilter::loadVarDump( $row->afl_var_dump ); - $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) ); + $out->addJsConfigVars( [ + 'wgAbuseFilterVariables' => $vars->dumpAllVars( true ), + 'abuseFilterExamine' => [ 'type' => 'log', 'id' => $logid ] + ] ); $this->showExaminer( $vars ); } @@ -195,10 +213,10 @@ class AbuseFilterViewExamine extends AbuseFilterView { $output->addModules( 'ext.abuseFilter.examine' ); // Add test bit - if ( $this->canViewPrivate() ) { + if ( AbuseFilter::canViewPrivate( $this->getUser() ) ) { $tester = Xml::tags( 'h2', null, $this->msg( 'abusefilter-examine-test' )->parse() ); - $tester .= $this->buildEditBox( $this->mTestFilter, 'wpTestFilter', false, false, false ); - $tester .= AbuseFilter::buildFilterLoader(); + $tester .= $this->buildEditBox( $this->mTestFilter, false, false, false ); + $tester .= $this->buildFilterLoader(); $html .= Xml::tags( 'div', [ 'id' => 'mw-abusefilter-examine-editor' ], $tester ); $html .= Xml::tags( 'p', null, diff --git a/AbuseFilter/includes/Views/AbuseFilterViewHistory.php b/AbuseFilter/includes/Views/AbuseFilterViewHistory.php index 66a14a56..f25d0a4e 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewHistory.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewHistory.php @@ -5,7 +5,7 @@ class AbuseFilterViewHistory extends AbuseFilterView { * @param SpecialAbuseFilter $page * @param array $params */ - public function __construct( $page, $params ) { + public function __construct( SpecialAbuseFilter $page, $params ) { parent::__construct( $page, $params ); $this->mFilter = $page->mFilter; } @@ -17,6 +17,8 @@ class AbuseFilterViewHistory extends AbuseFilterView { $out = $this->getOutput(); $out->enableOOUI(); $filter = $this->getRequest()->getText( 'filter' ) ?: $this->mFilter; + // Ensure the parameter is a valid filter ID + $filter = (int)$filter; if ( $filter ) { $out->setPageTitle( $this->msg( 'abusefilter-history' )->numParams( $filter ) ); @@ -24,9 +26,8 @@ class AbuseFilterViewHistory extends AbuseFilterView { $out->setPageTitle( $this->msg( 'abusefilter-filter-log' ) ); } - // Check perms. abusefilter-modify is a superset of abusefilter-view-private if ( $filter && AbuseFilter::filterHidden( $filter ) - && !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) + && !AbuseFilter::canViewPrivate( $this->getUser() ) ) { $out->addWikiMsg( 'abusefilter-history-error-hidden' ); return; @@ -75,9 +76,9 @@ class AbuseFilterViewHistory extends AbuseFilterView { 'label-message' => 'abusefilter-history-select-user' ], 'filter' => [ - 'type' => 'text', + 'type' => 'int', 'name' => 'filter', - 'default' => $filter, + 'default' => $filter ?: '', 'size' => '45', 'label-message' => 'abusefilter-history-select-filter' ], @@ -93,10 +94,6 @@ class AbuseFilterViewHistory extends AbuseFilterView { $pager = new AbuseFilterHistoryPager( $filter, $this, $user, $this->linkRenderer ); - $out->addHTML( - $pager->getNavigationBar() . - $pager->getBody() . - $pager->getNavigationBar() - ); + $out->addParserOutputContent( $pager->getFullOutput() ); } } diff --git a/AbuseFilter/includes/Views/AbuseFilterViewImport.php b/AbuseFilter/includes/Views/AbuseFilterViewImport.php index 01e2fc85..80dc8c98 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewImport.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewImport.php @@ -6,7 +6,7 @@ class AbuseFilterViewImport extends AbuseFilterView { */ public function show() { $out = $this->getOutput(); - if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + if ( !AbuseFilter::canEdit( $this->getUser() ) ) { $out->addWikiMsg( 'abusefilter-edit-notallowed' ); return; } @@ -17,9 +17,10 @@ class AbuseFilterViewImport extends AbuseFilterView { $formDescriptor = [ 'ImportText' => [ 'type' => 'textarea', + 'required' => true ] ]; - $htmlform = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) ->setSubmitTextMsg( 'abusefilter-import-submit' ) ->setAction( $url ) ->show(); diff --git a/AbuseFilter/includes/Views/AbuseFilterViewList.php b/AbuseFilter/includes/Views/AbuseFilterViewList.php index ad92ad6c..efbf6217 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewList.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewList.php @@ -1,5 +1,7 @@ <?php +use MediaWiki\MediaWikiServices; + /** * The default view used in Special:AbuseFilter */ @@ -11,6 +13,7 @@ class AbuseFilterViewList extends AbuseFilterView { $out = $this->getOutput(); $request = $this->getRequest(); $config = $this->getConfig(); + $user = $this->getUser(); // Show filter performance statistics $this->showStatus(); @@ -18,18 +21,27 @@ class AbuseFilterViewList extends AbuseFilterView { $out->addWikiMsg( 'abusefilter-intro' ); // New filter button - if ( $this->canEdit() ) { + if ( AbuseFilter::canEdit( $user ) ) { $out->enableOOUI(); - $link = new OOUI\ButtonWidget( [ - 'label' => $this->msg( 'abusefilter-new' )->text(), - 'href' => $this->getTitle( 'new' )->getFullURL(), + $buttons = new OOUI\HorizontalLayout( [ + 'items' => [ + new OOUI\ButtonWidget( [ + 'label' => $this->msg( 'abusefilter-new' )->text(), + 'href' => $this->getTitle( 'new' )->getFullURL(), + ] ), + new OOUI\ButtonWidget( [ + 'label' => $this->msg( 'abusefilter-import-button' )->text(), + 'href' => $this->getTitle( 'import' )->getFullURL(), + ] ) + ] ] ); - $out->addHTML( $link ); + $out->addHTML( $buttons ); } $conds = []; $deleted = $request->getVal( 'deletedfilters' ); $furtherOptions = $request->getArray( 'furtheroptions', [] ); + '@phan-var array $furtherOptions'; // Backward compatibility with old links if ( $request->getBool( 'hidedisabled' ) ) { $furtherOptions[] = 'hidedisabled'; @@ -45,22 +57,22 @@ class AbuseFilterViewList extends AbuseFilterView { } $scope = $request->getVal( 'rulescope', $defaultscope ); - $searchEnabled = $this->canViewPrivate() && !( + $searchEnabled = AbuseFilter::canViewPrivate( $user ) && !( $config->get( 'AbuseFilterCentralDB' ) !== null && !$config->get( 'AbuseFilterIsCentral' ) && - $scope == 'global' ); + $scope === 'global' ); if ( $searchEnabled ) { - $querypattern = $request->getVal( 'querypattern' ); + $querypattern = $request->getVal( 'querypattern', '' ); $searchmode = $request->getVal( 'searchoption', 'LIKE' ); } else { $querypattern = ''; $searchmode = ''; } - if ( $deleted == 'show' ) { + if ( $deleted === 'show' ) { // Nothing - } elseif ( $deleted == 'only' ) { + } elseif ( $deleted === 'only' ) { $conds['af_deleted'] = 1; } else { // hide, or anything else. @@ -75,79 +87,57 @@ class AbuseFilterViewList extends AbuseFilterView { $conds['af_hidden'] = 0; } - if ( $scope == 'local' ) { + if ( $scope === 'local' ) { $conds['af_global'] = 0; - } elseif ( $scope == 'global' ) { + } elseif ( $scope === 'global' ) { $conds['af_global'] = 1; } - $dbr = wfGetDB( DB_REPLICA ); - if ( $querypattern !== '' ) { - if ( $searchmode !== 'LIKE' ) { - // Check regex pattern validity - Wikimedia\suppressWarnings(); - $validreg = preg_match( '/' . $querypattern . '/', null ); - Wikimedia\restoreWarnings(); + // Check the search pattern. Filtering the results is done in AbuseFilterPager + $error = null; + if ( !in_array( $searchmode, [ 'LIKE', 'RLIKE', 'IRLIKE' ] ) ) { + $error = 'abusefilter-list-invalid-searchmode'; + } elseif ( $searchmode !== 'LIKE' && !StringUtils::isValidPCRERegex( "/$querypattern/" ) ) { + $error = 'abusefilter-list-regexerror'; + } - if ( $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() - ); + if ( $error !== null ) { + $out->addHTML( + Xml::tags( + 'p', + null, + Html::errorBox( $this->msg( $error )->escaped() ) + ) + ); + + // Reset the conditions in case of error + $conds = [ 'af_deleted' => 0 ]; + $querypattern = ''; } } $this->showList( - $conds, compact( 'deleted', 'furtherOptions', 'querypattern', 'searchmode', - 'scope', - 'searchEnabled' - ) + 'scope' + ), + $conds ); } /** - * @param array $conds * @param array $optarray + * @param array $conds */ - public function showList( $conds = [ 'af_deleted' => 0 ], $optarray = [] ) { + private function showList( array $optarray, array $conds = [ 'af_deleted' => 0 ] ) { + $user = $this->getUser(); $config = $this->getConfig(); + $centralDB = $config->get( 'AbuseFilterCentralDB' ); + $dbIsCentral = $config->get( 'AbuseFilterIsCentral' ); $this->getOutput()->addHTML( Xml::tags( 'h2', null, $this->msg( 'abusefilter-list' )->parse() ) ); @@ -155,15 +145,10 @@ class AbuseFilterViewList extends AbuseFilterView { $deleted = $optarray['deleted']; $furtherOptions = $optarray['furtherOptions']; $scope = $optarray['scope']; - $searchEnabled = $optarray['searchEnabled']; $querypattern = $optarray['querypattern']; $searchmode = $optarray['searchmode']; - if ( - $config->get( 'AbuseFilterCentralDB' ) !== null - && !$config->get( 'AbuseFilterIsCentral' ) - && $scope == 'global' - ) { + if ( $centralDB !== null && !$dbIsCentral && $scope === 'global' ) { $pager = new GlobalAbuseFilterPager( $this, $conds, @@ -174,31 +159,20 @@ class AbuseFilterViewList extends AbuseFilterView { $this, $conds, $this->linkRenderer, - [ $querypattern, $searchmode ] + $querypattern, + $searchmode ); } // 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 ( $config->get( 'AbuseFilterCentralDB' ) !== null ) { + if ( $centralDB !== null ) { $optionsMsg = [ 'abusefilter-list-options-scope-local' => 'local', 'abusefilter-list-options-scope-global' => 'global', ]; - if ( $config->get( 'AbuseFilterIsCentral' ) ) { + if ( $dbIsCentral ) { // For central wiki: add third scope option $optionsMsg['abusefilter-list-options-scope-all'] = 'all'; } @@ -212,6 +186,19 @@ class AbuseFilterViewList extends AbuseFilterView { ]; } + $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, + ]; + $formDescriptor['furtheroptions'] = [ 'name' => 'furtheroptions', 'type' => 'multiselect', @@ -224,11 +211,12 @@ class AbuseFilterViewList extends AbuseFilterView { 'default' => $furtherOptions ]; - // ToDo: Since this is only for saving space, we should convert it to use a 'hide-if' - if ( $searchEnabled ) { + if ( AbuseFilter::canViewPrivate( $user ) ) { + $globalEnabled = $centralDB !== null && !$dbIsCentral; $formDescriptor['querypattern'] = [ 'name' => 'querypattern', 'type' => 'text', + 'hide-if' => $globalEnabled ? [ '===', 'rulescope', 'global' ] : [], 'label-message' => 'abusefilter-list-options-searchfield', 'placeholder' => $this->msg( 'abusefilter-list-options-searchpattern' )->text(), 'default' => $querypattern @@ -239,6 +227,9 @@ class AbuseFilterViewList extends AbuseFilterView { 'type' => 'radio', 'flatlist' => true, 'label-message' => 'abusefilter-list-options-searchoptions', + 'hide-if' => $globalEnabled ? + [ 'OR', [ '===', 'querypattern', '' ], $formDescriptor['querypattern']['hide-if'] ] : + [ '===', 'querypattern', '' ], 'options-messages' => [ 'abusefilter-list-options-search-like' => 'LIKE', 'abusefilter-list-options-search-rlike' => 'RLIKE', @@ -265,37 +256,39 @@ class AbuseFilterViewList extends AbuseFilterView { ->prepareForm() ->displayForm( false ); - $this->getOutput()->addHTML( - $pager->getNavigationBar() . - $pager->getBody() . - $pager->getNavigationBar() - ); + $this->getOutput()->addParserOutputContent( $pager->getFullOutput() ); } /** - * Show stats + * Generates a summary of filter activity using the internal statistics. */ public function showStatus() { - $stash = ObjectCache::getMainStashInstance(); - $overflow_count = (int)$stash->get( AbuseFilter::filterLimitReachedKey() ); - $match_count = (int)$stash->get( AbuseFilter::filterMatchesKey() ); - $total_count = 0; + $stash = MediaWikiServices::getInstance()->getMainObjectStash(); + + $totalCount = 0; + $matchCount = 0; + $overflowCount = 0; foreach ( $this->getConfig()->get( 'AbuseFilterValidGroups' ) as $group ) { - $total_count += (int)$stash->get( AbuseFilter::filterUsedKey( $group ) ); + $profile = $stash->get( AbuseFilter::filterProfileGroupKey( $group ) ); + if ( $profile !== false ) { + $totalCount += $profile[ 'total' ]; + $overflowCount += $profile[ 'overflow' ]; + $matchCount += $profile[ 'matches' ]; + } } - if ( $total_count > 0 ) { - $overflow_percent = sprintf( "%.2f", 100 * $overflow_count / $total_count ); - $match_percent = sprintf( "%.2f", 100 * $match_count / $total_count ); + if ( $totalCount > 0 ) { + $overflowPercent = round( 100 * $overflowCount / $totalCount, 2 ); + $matchPercent = round( 100 * $matchCount / $totalCount, 2 ); $status = $this->msg( 'abusefilter-status' ) ->numParams( - $total_count, - $overflow_count, - $overflow_percent, + $totalCount, + $overflowCount, + $overflowPercent, $this->getConfig()->get( 'AbuseFilterConditionLimit' ), - $match_count, - $match_percent + $matchCount, + $matchPercent )->parse(); $status = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-status' ], $status ); diff --git a/AbuseFilter/includes/Views/AbuseFilterViewRevert.php b/AbuseFilter/includes/Views/AbuseFilterViewRevert.php index 38a0d073..ab873b22 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewRevert.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewRevert.php @@ -1,7 +1,28 @@ <?php +use MediaWiki\Block\DatabaseBlock; +use MediaWiki\MediaWikiServices; + class AbuseFilterViewRevert extends AbuseFilterView { - public $origPeriodStart, $origPeriodEnd, $mPeriodStart, $mPeriodEnd; + /** + * @var string The start time of the lookup period + */ + public $origPeriodStart; + /** + * @var string The end time of the lookup period + */ + public $origPeriodEnd; + /** + * @var string|null The same as $origPeriodStart + */ + public $mPeriodStart; + /** + * @var string|null The same as $origPeriodEnd + */ + public $mPeriodEnd; + /** + * @var string|null The reason provided for the revert + */ public $mReason; /** @@ -14,10 +35,17 @@ class AbuseFilterViewRevert extends AbuseFilterView { $user = $this->getUser(); $out = $this->getOutput(); - if ( !$user->isAllowed( 'abusefilter-revert' ) ) { + if ( !MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $user, 'abusefilter-revert' ) + ) { throw new PermissionsError( 'abusefilter-revert' ); } + $block = $user->getBlock(); + if ( $block && $block->isSitewide() ) { + throw new UserBlockedError( $block ); + } + $this->loadParameters(); if ( $this->attemptRevert() ) { @@ -33,8 +61,8 @@ class AbuseFilterViewRevert extends AbuseFilterView { $max = wfTimestampNow(); $filterLink = $this->linkRenderer->makeLink( - SpecialPage::getTitleFor( 'AbuseFilter', intval( $filter ) ), - $lang->formatNum( intval( $filter ) ) + SpecialPage::getTitleFor( 'AbuseFilter', $filter ), + $lang->formatNum( $filter ) ); $searchFields = []; $searchFields['filterid'] = [ @@ -77,10 +105,12 @@ class AbuseFilterViewRevert extends AbuseFilterView { $results = $this->doLookup(); $list = []; + $context = $this->getContext(); foreach ( $results as $result ) { - $displayActions = array_map( - [ 'AbuseFilter', 'getActionDisplay' ], - $result['actions'] ); + $displayActions = []; + foreach ( $result['actions'] as $action ) { + $displayActions[] = AbuseFilter::getActionDisplay( $action, $context ); + } $msg = $this->msg( 'abusefilter-revert-preview-item' ) ->params( @@ -146,7 +176,7 @@ class AbuseFilterViewRevert extends AbuseFilterView { } /** - * @return array + * @return array[] */ public function doLookup() { $periodStart = $this->mPeriodStart; @@ -157,14 +187,13 @@ class AbuseFilterViewRevert extends AbuseFilterView { $dbr = wfGetDB( DB_REPLICA ); - if ( $periodStart ) { + if ( $periodStart !== null ) { $conds[] = 'afl_timestamp >= ' . $dbr->addQuotes( $dbr->timestamp( $periodStart ) ); } - if ( $periodEnd ) { + if ( $periodEnd !== null ) { $conds[] = 'afl_timestamp <= ' . $dbr->addQuotes( $dbr->timestamp( $periodEnd ) ); } - // All but afl_filter, afl_ip, afl_deleted, afl_patrolled_by, afl_rev_id and afl_log_id $selectFields = [ 'afl_id', 'afl_user', @@ -213,10 +242,10 @@ class AbuseFilterViewRevert extends AbuseFilterView { $request = $this->getRequest(); $this->origPeriodStart = $request->getText( 'wpPeriodStart' ); - $this->mPeriodStart = strtotime( $this->origPeriodStart ); + $this->mPeriodStart = strtotime( $this->origPeriodStart ) ?: null; $this->origPeriodEnd = $request->getText( 'wpPeriodEnd' ); - $this->mPeriodEnd = strtotime( $this->origPeriodEnd ); - $this->mSubmit = $request->getVal( 'submit' ); + $this->mPeriodEnd = strtotime( $this->origPeriodEnd ) ?: null; + $this->mSubmit = $request->getBool( 'submit' ); $this->mReason = $request->getVal( 'wpReason' ); } @@ -258,8 +287,8 @@ class AbuseFilterViewRevert extends AbuseFilterView { public function revertAction( $action, $result ) { switch ( $action ) { case 'block': - $block = Block::newFromTarget( $result['user'] ); - if ( !( $block && $block->getBy() == AbuseFilter::getFilterUser()->getId() ) ) { + $block = DatabaseBlock::newFromTarget( $result['user'] ); + if ( !( $block && $block->getBy() === AbuseFilter::getFilterUser()->getId() ) ) { // Not blocked by abuse filter return false; } @@ -275,17 +304,16 @@ class AbuseFilterViewRevert extends AbuseFilterView { $logEntry->publish( $logEntry->insert() ); return true; case 'blockautopromote': - ObjectCache::getMainStashInstance()->delete( - AbuseFilter::autoPromoteBlockKey( User::newFromId( $result['userid'] ) ) - ); - return true; + $target = User::newFromId( $result['userid'] ); + $msg = $this->msg( + 'abusefilter-revert-reason', $this->mPage->mFilter, $this->mReason + )->inContentLanguage()->text(); + + return AbuseFilter::unblockAutopromote( $target, $this->getUser(), $msg ); case 'degroup': // Pull the user's groups from the vars. $oldGroups = $result['vars']->getVar( 'user_groups' )->toNative(); - $oldGroups = array_diff( - $oldGroups, - array_intersect( $oldGroups, User::getImplicitGroups() ) - ); + $oldGroups = array_diff( $oldGroups, User::getImplicitGroups() ); $rows = []; foreach ( $oldGroups as $group ) { diff --git a/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php b/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php index 168933ec..89ae088a 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php @@ -1,11 +1,45 @@ <?php +use MediaWiki\Extension\AbuseFilter\VariableGenerator\RCVariableGenerator; + class AbuseFilterViewTestBatch extends AbuseFilterView { - // Hard-coded for now. + /** + * @var int The limit of changes to test, hard coded for now + */ protected static $mChangeLimit = 100; - public $mShowNegative, $mTestPeriodStart, $mTestPeriodEnd, $mTestPage; - public $mTestUser, $mExcludeBots, $mTestAction; + /** + * @var bool Whether to show changes that don't trigger the specified pattern + */ + public $mShowNegative; + /** + * @var string The start time of the lookup period + */ + public $mTestPeriodStart; + /** + * @var string The end time of the lookup period + */ + public $mTestPeriodEnd; + /** + * @var string The page of which edits we're interested in + */ + public $mTestPage; + /** + * @var string The user whose actions we want to test + */ + public $mTestUser; + /** + * @var bool Whether to exclude bot edits + */ + public $mExcludeBots; + /** + * @var string The action (performed by the user) we want to search for + */ + public $mTestAction; + /** + * @var string The text of the rule to test changes against + */ + private $testPattern; /** * Shows the page @@ -13,9 +47,7 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { public function show() { $out = $this->getOutput(); - AbuseFilter::disableConditionLimit(); - - if ( !$this->canViewPrivate() ) { + if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) { $out->addWikiMsg( 'abusefilter-mustviewprivateoredit' ); return; } @@ -29,14 +61,13 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { $output = ''; $output .= $this->buildEditBox( - $this->mFilter, - 'wpTestFilter', + $this->testPattern, true, true, false ) . "\n"; - $output .= AbuseFilter::buildFilterLoader(); + $output .= $this->buildFilterLoader(); $output = Xml::tags( 'div', [ 'id' => 'mw-abusefilter-test-editor' ], $output ); $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); @@ -54,8 +85,8 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { $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 + $this->msg( 'abusefilter-test-search-type-createaccount' )->text() => 'createaccount', + $this->msg( 'abusefilter-test-search-type-upload' )->text() => 'upload' ] ]; $formFields['wpTestUser'] = [ @@ -126,8 +157,10 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { public function doTest() { // Quick syntax check. $out = $this->getOutput(); - $result = AbuseFilter::checkSyntax( $this->mFilter ); - if ( $result !== true ) { + $parser = AbuseFilter::getDefaultParser(); + + $validSyntax = $parser->checkSyntax( $this->testPattern ); + if ( $validSyntax !== true ) { $out->addWikiMsg( 'abusefilter-test-syntaxerr' ); return; } @@ -160,18 +193,15 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { } } - $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; } + $action = $this->mTestAction !== '0' ? $this->mTestAction : false; + $conds[] = $this->buildTestConditions( $dbr, $action ); + // Get our ChangesList - $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->mFilter ); + $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->testPattern ); $output = $changesList->beginRecentChangesList(); $rcQuery = RecentChange::getQueryInfo(); @@ -186,19 +216,24 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { $counter = 1; + $contextUser = $this->getUser(); + $parser->toggleConditionLimit( false ); foreach ( $res as $row ) { - $vars = AbuseFilter::getVarsFromRCRow( $row ); + $vars = new AbuseFilterVariableHolder(); + $rc = RecentChange::newFromRow( $row ); + $varGenerator = new RCVariableGenerator( $vars, $rc, $contextUser ); + $vars = $varGenerator->getVars(); if ( !$vars ) { continue; } - $result = AbuseFilter::checkConditions( $this->mFilter, $vars ); + $parser->setVariables( $vars ); + $result = $parser->checkConditions( $this->testPattern ); if ( $result || $this->mShowNegative ) { // Stash result in RC item - $rc = RecentChange::newFromRow( $row ); - /** @suppress PhanUndeclaredProperty for $rc->filterResult, which isn't a big deal */ + // @phan-suppress-next-line PhanUndeclaredProperty not a big deal $rc->filterResult = $result; $rc->counter = $counter++; $output .= $changesList->recentChangesLine( $rc, false ); @@ -216,7 +251,7 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { public function loadParameters() { $request = $this->getRequest(); - $this->mFilter = $request->getText( 'wpTestFilter' ); + $this->testPattern = $request->getText( 'wpFilterRules' ); $this->mShowNegative = $request->getBool( 'wpShowNegative' ); $testUsername = $request->getText( 'wpTestUser' ); $this->mTestPeriodEnd = $request->getText( 'wpTestPeriodEnd' ); @@ -225,12 +260,12 @@ class AbuseFilterViewTestBatch extends AbuseFilterView { $this->mExcludeBots = $request->getBool( 'wpExcludeBots' ); $this->mTestAction = $request->getText( 'wpTestAction' ); - if ( !$this->mFilter + if ( !$this->testPattern && count( $this->mParams ) > 1 && is_numeric( $this->mParams[1] ) ) { $dbr = wfGetDB( DB_REPLICA ); - $this->mFilter = $dbr->selectField( 'abuse_filter', + $this->testPattern = $dbr->selectField( 'abuse_filter', 'af_pattern', [ 'af_id' => $this->mParams[1] ], __METHOD__ diff --git a/AbuseFilter/includes/Views/AbuseFilterViewTools.php b/AbuseFilter/includes/Views/AbuseFilterViewTools.php index 44ee0eb4..70c1091f 100644 --- a/AbuseFilter/includes/Views/AbuseFilterViewTools.php +++ b/AbuseFilter/includes/Views/AbuseFilterViewTools.php @@ -9,7 +9,7 @@ class AbuseFilterViewTools extends AbuseFilterView { $out->enableOOUI(); $request = $this->getRequest(); - if ( !$this->canViewPrivate() ) { + if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) { $out->addWikiMsg( 'abusefilter-mustviewprivateoredit' ); return; } @@ -20,8 +20,7 @@ class AbuseFilterViewTools extends AbuseFilterView { // Expression evaluator $eval = ''; $eval .= $this->buildEditBox( - $request->getText( 'wpTestExpr' ), - 'wpTestExpr', + $request->getText( 'wpFilterRules' ), true, false, false @@ -31,32 +30,35 @@ class AbuseFilterViewTools extends AbuseFilterView { Xml::tags( 'p', null, new OOUI\ButtonInputWidget( [ 'label' => $this->msg( 'abusefilter-tools-submitexpr' )->text(), - 'id' => 'mw-abusefilter-submitexpr' + 'id' => 'mw-abusefilter-submitexpr', + 'flags' => [ 'primary', 'progressive' ] ] ) ); - $eval .= Xml::element( 'p', [ 'id' => 'mw-abusefilter-expr-result' ], ' ' ); + $eval .= Xml::element( 'pre', [ 'id' => 'mw-abusefilter-expr-result' ], ' ' ); $eval = Xml::fieldset( $this->msg( 'abusefilter-tools-expr' )->text(), $eval ); $out->addHTML( $eval ); $out->addModules( 'ext.abuseFilter.tools' ); - // Hacky little box to re-enable autoconfirmed if it got disabled - $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 ); + if ( AbuseFilter::canEdit( $this->getUser() ) ) { + // Hacky little box to re-enable autoconfirmed if it got disabled + $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 9d4fd8c3..517ea804 100644 --- a/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php +++ b/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php @@ -1,5 +1,7 @@ <?php +use MediaWiki\Extension\AbuseFilter\VariableGenerator\RCVariableGenerator; + class ApiAbuseFilterCheckMatch extends ApiBase { /** * @see ApiBase::execute @@ -9,34 +11,25 @@ class ApiAbuseFilterCheckMatch extends ApiBase { $this->requireOnlyOneParameter( $params, 'vars', 'rcid', 'logid' ); // "Anti-DoS" - if ( !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) { + if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) { $this->dieWithError( 'apierror-abusefilter-canttest', 'permissiondenied' ); } $vars = null; if ( $params['vars'] ) { - $vars = new AbuseFilterVariableHolder; $pairs = FormatJson::decode( $params['vars'], true ); - foreach ( $pairs as $name => $value ) { - $vars->setVar( $name, $value ); - } + $vars = AbuseFilterVariableHolder::newFromArray( $pairs ); } elseif ( $params['rcid'] ) { - $dbr = wfGetDB( DB_REPLICA ); - $rcQuery = RecentChange::getQueryInfo(); - $row = $dbr->selectRow( - $rcQuery['tables'], - $rcQuery['fields'], - [ 'rc_id' => $params['rcid'] ], - __METHOD__, - [], - $rcQuery['joins'] - ); + $rc = RecentChange::newFromId( $params['rcid'] ); - if ( !$row ) { + if ( !$rc ) { $this->dieWithError( [ 'apierror-nosuchrcid', $params['rcid'] ] ); } - $vars = AbuseFilter::getVarsFromRCRow( $row ); + $vars = new AbuseFilterVariableHolder(); + // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141 + $varGenerator = new RCVariableGenerator( $vars, $rc, $this->getUser() ); + $vars = $varGenerator->getVars(); } elseif ( $params['logid'] ) { $dbr = wfGetDB( DB_REPLICA ); $row = $dbr->selectRow( @@ -52,14 +45,19 @@ class ApiAbuseFilterCheckMatch extends ApiBase { $vars = AbuseFilter::loadVarDump( $row->afl_var_dump ); } + if ( $vars === null ) { + throw new LogicException( 'Impossible.' ); + } - if ( AbuseFilter::checkSyntax( $params[ 'filter' ] ) !== true ) { + $parser = AbuseFilter::getDefaultParser(); + if ( $parser->checkSyntax( $params[ 'filter' ] ) !== true ) { $this->dieWithError( 'apierror-abusefilter-badsyntax', 'badsyntax' ); } + $parser->setVariables( $vars ); $result = [ ApiResult::META_BC_BOOLS => [ 'result' ], - 'result' => AbuseFilter::checkConditions( $params['filter'], $vars ), + 'result' => $parser->checkConditions( $params['filter'] ), ]; $this->getResult()->addValue( diff --git a/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php b/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php index 213fb904..819906ac 100644 --- a/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php +++ b/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php @@ -7,12 +7,12 @@ class ApiAbuseFilterCheckSyntax extends ApiBase { */ public function execute() { // "Anti-DoS" - if ( !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) { + if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) { $this->dieWithError( 'apierror-abusefilter-cantcheck', 'permissiondenied' ); } $params = $this->extractRequestParams(); - $result = AbuseFilter::checkSyntax( $params[ 'filter' ] ); + $result = AbuseFilter::getDefaultParser()->checkSyntax( $params[ 'filter' ] ); $r = []; if ( $result === true ) { diff --git a/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php b/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php index 18701670..5b707012 100644 --- a/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php +++ b/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php @@ -1,15 +1,51 @@ <?php +use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator; + class ApiAbuseFilterEvalExpression extends ApiBase { /** * @see ApiBase::execute() */ public function execute() { + // "Anti-DoS" + if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) { + $this->dieWithError( 'apierror-abusefilter-canteval', 'permissiondenied' ); + } + $params = $this->extractRequestParams(); - $result = AbuseFilter::evaluateExpression( $params['expression'] ); + $status = $this->evaluateExpression( $params['expression'] ); + if ( !$status->isGood() ) { + $this->dieWithError( $status->getErrors()[0] ); + } else { + $res = $status->getValue(); + $res = $params['prettyprint'] ? AbuseFilter::formatVar( $res ) : $res; + $this->getResult()->addValue( + null, + $this->getModuleName(), + ApiResult::addMetadataToResultVars( [ 'result' => $res ] ) + ); + } + } + + /** + * @param string $expr + * @return Status + */ + private function evaluateExpression( string $expr ) : Status { + $parser = AbuseFilter::getDefaultParser(); + if ( $parser->checkSyntax( $expr ) !== true ) { + return Status::newFatal( 'abusefilter-tools-syntax-error' ); + } + + $vars = new AbuseFilterVariableHolder(); + // Generic vars are the only ones available + $generator = new VariableGenerator( $vars ); + $vars = $generator->addGenericVars()->getVariableHolder(); + $vars->setVar( 'timestamp', wfTimestamp( TS_UNIX ) ); + $parser->setVariables( $vars ); - $this->getResult()->addValue( null, $this->getModuleName(), [ 'result' => $result ] ); + return Status::newGood( $parser->evaluateExpression( $expr ) ); } /** @@ -21,6 +57,9 @@ class ApiAbuseFilterEvalExpression extends ApiBase { 'expression' => [ ApiBase::PARAM_REQUIRED => true, ], + 'prettyprint' => [ + ApiBase::PARAM_TYPE => 'boolean' + ] ]; } @@ -32,6 +71,8 @@ class ApiAbuseFilterEvalExpression extends ApiBase { return [ 'action=abusefilterevalexpression&expression=lcase("FOO")' => 'apihelp-abusefilterevalexpression-example-1', + 'action=abusefilterevalexpression&expression=lcase("FOO")&prettyprint=1' + => 'apihelp-abusefilterevalexpression-example-2', ]; } } diff --git a/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php b/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php index 195e72d3..e39da558 100644 --- a/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php +++ b/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php @@ -8,9 +8,9 @@ class ApiAbuseFilterUnblockAutopromote extends ApiBase { $this->checkUserRightsAny( 'abusefilter-modify' ); $params = $this->extractRequestParams(); - $user = User::newFromName( $params['user'] ); + $target = User::newFromName( $params['user'] ); - if ( $user === false ) { + if ( $target === false ) { $encParamName = $this->encodeParamName( 'user' ); $this->dieWithError( [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $params['user'] ) ], @@ -18,16 +18,20 @@ class ApiAbuseFilterUnblockAutopromote extends ApiBase { ); } - $key = AbuseFilter::autoPromoteBlockKey( $user ); - $stash = ObjectCache::getMainStashInstance(); - if ( !$stash->get( $key ) ) { - $this->dieWithError( [ 'abusefilter-reautoconfirm-none', $user->getName() ], 'notsuspended' ); + $block = $this->getUser()->getBlock(); + if ( $block && $block->isSitewide() ) { + $this->dieBlocked( $block ); } - $stash->delete( $key ); + $msg = $this->msg( 'abusefilter-tools-restoreautopromote' )->inContentLanguage()->text(); + $res = AbuseFilter::unblockAutopromote( $target, $this->getUser(), $msg ); - $res = [ 'user' => $params['user'] ]; - $this->getResult()->addValue( null, $this->getModuleName(), $res ); + if ( $res === false ) { + $this->dieWithError( [ 'abusefilter-reautoconfirm-none', $target->getName() ], 'notsuspended' ); + } + + $finalResult = [ 'user' => $params['user'] ]; + $this->getResult()->addValue( null, $this->getModuleName(), $finalResult ); } /** diff --git a/AbuseFilter/includes/api/ApiAbuseLogPrivateDetails.php b/AbuseFilter/includes/api/ApiAbuseLogPrivateDetails.php new file mode 100644 index 00000000..0554c942 --- /dev/null +++ b/AbuseFilter/includes/api/ApiAbuseLogPrivateDetails.php @@ -0,0 +1,110 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * API module to allow accessing private details (the user's IP) from AbuseLog entries + * + * @ingroup API + * @ingroup Extensions + */ +class ApiAbuseLogPrivateDetails extends ApiBase { + /** + * @inheritDoc + */ + public function mustBePosted() { + return true; + } + + /** + * @inheritDoc + */ + public function isWriteMode() { + return true; + } + + /** + * @inheritDoc + */ + public function needsToken() { + return 'csrf'; + } + + /** + * @inheritDoc + */ + public function execute() { + $user = $this->getUser(); + + if ( !SpecialAbuseLog::canSeePrivateDetails( $user ) ) { + $this->dieWithError( 'abusefilter-log-cannot-see-privatedetails' ); + } + $params = $this->extractRequestParams(); + + if ( !SpecialAbuseLog::checkPrivateDetailsAccessReason( $params['reason'] ) ) { + // Double check, in case we add some extra validation + $this->dieWithError( 'abusefilter-noreason' ); + } + $status = SpecialAbuseLog::getPrivateDetailsRow( $user, $params['logid'] ); + if ( !$status->isGood() ) { + $this->dieWithError( $status->getErrors()[0] ); + } + $row = $status->getValue(); + // Log accessing private details + if ( $this->getConfig()->get( 'AbuseFilterLogPrivateDetailsAccess' ) ) { + SpecialAbuseLog::addPrivateDetailsAccessLogEntry( + $params['logid'], + $params['reason'], + $user + ); + } + + $result = [ + 'log-id' => $params['logid'], + 'user' => $row->afl_user_text, + 'filter-id' => $row->af_id, + 'filter-description' => $row->af_public_comments, + 'ip-address' => $row->afl_ip !== '' ? $row->afl_ip : null + ]; + $this->getResult()->addValue( null, $this->getModuleName(), $result ); + } + + /** + * @inheritDoc + */ + public function getAllowedParams() { + return [ + 'logid' => [ + ApiBase::PARAM_TYPE => 'integer' + ], + 'reason' => [ + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_REQUIRED => $this->getConfig()->get( 'AbuseFilterPrivateDetailsForceReason' ), + ] + ]; + } + + /** + * @inheritDoc + */ + protected function getExamplesMessages() { + return [ + 'action=abuselogprivatedetails&logid=1&reason=example&token=ABC123' + => 'apihelp-abuselogprivatedetails-example-1' + ]; + } +} diff --git a/AbuseFilter/includes/api/ApiQueryAbuseFilters.php b/AbuseFilter/includes/api/ApiQueryAbuseFilters.php index cafe20d1..54e28997 100644 --- a/AbuseFilter/includes/api/ApiQueryAbuseFilters.php +++ b/AbuseFilter/includes/api/ApiQueryAbuseFilters.php @@ -34,7 +34,7 @@ class ApiQueryAbuseFilters extends ApiQueryBase { * @param ApiQuery $query * @param string $moduleName */ - public function __construct( $query, $moduleName ) { + public function __construct( ApiQuery $query, $moduleName ) { parent::__construct( $query, $moduleName, 'abf' ); } @@ -79,7 +79,7 @@ class ApiQueryAbuseFilters extends ApiQueryBase { $this->addWhereRange( 'af_id', $params['dir'], $params['startid'], $params['endid'] ); - if ( !is_null( $params['show'] ) ) { + if ( $params['show'] !== null ) { $show = array_flip( $params['show'] ); /* Check for conflicting parameters. */ @@ -100,18 +100,19 @@ class ApiQueryAbuseFilters extends ApiQueryBase { $res = $this->select( __METHOD__ ); - $showhidden = $user->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ); + $showhidden = AbuseFilter::canViewPrivate( $user ); $count = 0; foreach ( $res as $row ) { + $filterId = intval( $row->af_id ); if ( ++$count > $params['limit'] ) { // We've had enough - $this->setContinueEnumParameter( 'startid', $row->af_id ); + $this->setContinueEnumParameter( 'startid', $filterId ); break; } $entry = []; if ( $fld_id ) { - $entry['id'] = intval( $row->af_id ); + $entry['id'] = $filterId; } if ( $fld_desc ) { $entry['description'] = $row->af_public_comments; @@ -149,7 +150,7 @@ class ApiQueryAbuseFilters extends ApiQueryBase { if ( $entry ) { $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry ); if ( !$fit ) { - $this->setContinueEnumParameter( 'startid', $row->af_id ); + $this->setContinueEnumParameter( 'startid', $filterId ); break; } } diff --git a/AbuseFilter/includes/api/ApiQueryAbuseLog.php b/AbuseFilter/includes/api/ApiQueryAbuseLog.php index bf085318..0a088b22 100644 --- a/AbuseFilter/includes/api/ApiQueryAbuseLog.php +++ b/AbuseFilter/includes/api/ApiQueryAbuseLog.php @@ -23,6 +23,9 @@ * http://www.gnu.org/copyleft/gpl.html */ +use MediaWiki\MediaWikiServices; +use Wikimedia\IPUtils; + /** * Query module to list abuse log entries. * @@ -34,22 +37,18 @@ class ApiQueryAbuseLog extends ApiQueryBase { * @param ApiQuery $query * @param string $moduleName */ - public function __construct( $query, $moduleName ) { + public function __construct( ApiQuery $query, $moduleName ) { parent::__construct( $query, $moduleName, 'afl' ); } /** - * @see ApiQueryBase::execute() + * @inheritDoc */ public function execute() { - $user = $this->getUser(); - $errors = $this->getTitle()->getUserPermissionsErrors( - 'abusefilter-log', $user, true, [ 'ns-specialprotected' ] ); - if ( count( $errors ) ) { - $this->dieStatus( $this->errorArrayToStatus( $errors ) ); - return; - } + // Same check as in SpecialAbuseLog + $this->checkUserRightsAny( 'abusefilter-log' ); + $user = $this->getUser(); $params = $this->extractRequestParams(); $prop = array_flip( $params['prop'] ); @@ -71,14 +70,16 @@ class ApiQueryAbuseLog extends ApiQueryBase { } // Match permissions for viewing events on private filters to SpecialAbuseLog (bug 42814) if ( $params['filter'] && - !( AbuseFilterView::canViewPrivate() || $user->isAllowed( 'abusefilter-log-private' ) ) + !( AbuseFilter::canViewPrivate( $user ) || + $this->getPermissionManager()->userHasRight( $user, 'abusefilter-log-private' ) ) ) { // A specific filter parameter is set but the user isn't allowed to view all filters if ( !is_array( $params['filter'] ) ) { $params['filter'] = [ $params['filter'] ]; } foreach ( $params['filter'] as $filter ) { - if ( AbuseFilter::filterHidden( $filter ) ) { + list( $filterID, $global ) = AbuseFilter::splitGlobalName( $filter ); + if ( AbuseFilter::filterHidden( $filterID, $global ) ) { $this->dieWithError( [ 'apierror-permissiondenied', $this->msg( 'action-abusefilter-log-private' ) ] ); @@ -114,18 +115,15 @@ class ApiQueryAbuseLog extends ApiQueryBase { $this->addWhereRange( 'afl_timestamp', $params['dir'], $params['start'], $params['end'] ); - $db = $this->getDB(); - $notDeletedCond = SpecialAbuseLog::getNotDeletedCond( $db ); - if ( isset( $params['user'] ) ) { $u = User::newFromName( $params['user'] ); if ( $u ) { // Username normalisation $params['user'] = $u->getName(); $userId = $u->getId(); - } elseif ( IP::isIPAddress( $params['user'] ) ) { + } elseif ( IPUtils::isIPAddress( $params['user'] ) ) { // It's an IP, sanitize it - $params['user'] = IP::sanitizeIP( $params['user'] ); + $params['user'] = IPUtils::sanitizeIP( $params['user'] ); $userId = 0; } @@ -141,17 +139,19 @@ class ApiQueryAbuseLog extends ApiQueryBase { } } - $this->addWhereIf( [ 'afl_filter' => $params['filter'] ], isset( $params['filter'] ) ); - $this->addWhereIf( $notDeletedCond, !SpecialAbuseLog::canSeeHidden() ); + if ( isset( $params['filter'] ) && $params['filter'] !== [] ) { + $this->addWhere( [ 'afl_filter' => $params['filter'] ] ); + } + $this->addWhereIf( [ 'afl_deleted' => 0 ], !SpecialAbuseLog::canSeeHidden( $user ) ); 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 ) ) { + if ( $title !== null ) { $titleObj = Title::newFromText( $title ); - if ( is_null( $titleObj ) ) { + if ( $titleObj === null ) { $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] ); } $this->addWhereFld( 'afl_namespace', $titleObj->getNamespace() ); @@ -168,15 +168,19 @@ class ApiQueryAbuseLog extends ApiQueryBase { break; } $hidden = SpecialAbuseLog::isHidden( $row ); - if ( $hidden === true && !SpecialAbuseLog::canSeeHidden() ) { + if ( $hidden === true && !SpecialAbuseLog::canSeeHidden( $user ) ) { continue; - } elseif ( $hidden === 'implicit' ) { - $rev = Revision::newFromId( $row->afl_rev_id ); - if ( !$rev->userCan( Revision::SUPPRESSED_ALL, $user ) ) { + } + if ( $hidden === 'implicit' ) { + $revRec = MediaWikiServices::getInstance() + ->getRevisionLookup() + ->getRevisionById( (int)$row->afl_rev_id ); + if ( !AbuseFilter::userCanViewRev( $revRec, $user ) ) { continue; } } - $canSeeDetails = SpecialAbuseLog::canSeeDetails( $row->afl_filter ); + list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter ); + $canSeeDetails = SpecialAbuseLog::canSeeDetails( $user, $filterID, $global ); $entry = []; if ( $fld_ids ) { @@ -184,9 +188,8 @@ class ApiQueryAbuseLog extends ApiQueryBase { $entry['filter_id'] = $canSeeDetails ? $row->afl_filter : ''; } if ( $fld_filter ) { - $globalIndex = AbuseFilter::decodeGlobalName( $row->afl_filter ); - if ( $globalIndex ) { - $entry['filter'] = AbuseFilter::getGlobalFilterDescription( $globalIndex ); + if ( $global ) { + $entry['filter'] = AbuseFilter::getGlobalFilterDescription( $filterID ); } else { $entry['filter'] = $row->af_public_comments; } @@ -207,7 +210,7 @@ class ApiQueryAbuseLog extends ApiQueryBase { if ( $fld_result ) { $entry['result'] = $row->afl_actions; } - if ( $fld_revid && !is_null( $row->afl_rev_id ) ) { + if ( $fld_revid && $row->afl_rev_id !== null ) { $entry['revid'] = $canSeeDetails ? $row->afl_rev_id : ''; } if ( $fld_timestamp ) { @@ -218,11 +221,7 @@ class ApiQueryAbuseLog extends ApiQueryBase { $entry['details'] = []; if ( $canSeeDetails ) { $vars = AbuseFilter::loadVarDump( $row->afl_var_dump ); - if ( $vars instanceof AbuseFilterVariableHolder ) { - $entry['details'] = $vars->exportAllVars(); - } else { - $entry['details'] = array_change_key_case( $vars, CASE_LOWER ); - } + $entry['details'] = $vars->exportAllVars(); } } @@ -269,7 +268,11 @@ class ApiQueryAbuseLog extends ApiQueryBase { 'title' => null, 'filter' => [ ApiBase::PARAM_TYPE => 'string', - ApiBase::PARAM_ISMULTI => true + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_HELP_MSG => [ + 'apihelp-query+abuselog-param-filter', + AbuseFilter::GLOBAL_FILTER_PREFIX + ] ], 'limit' => [ ApiBase::PARAM_DFLT => 10, @@ -301,6 +304,7 @@ class ApiQueryAbuseLog extends ApiQueryBase { ]; $params['prop'][ApiBase::PARAM_DFLT] .= '|wiki'; $params['prop'][ApiBase::PARAM_TYPE][] = 'wiki'; + $params['filter'][ApiBase::PARAM_HELP_MSG] = 'apihelp-query+abuselog-param-filter-central'; } return $params; } diff --git a/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php b/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php index 02b13755..cbae7a9d 100644 --- a/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php +++ b/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php @@ -1,13 +1,20 @@ <?php class AbuseFilterExaminePager extends ReverseChronologicalPager { - public $mChangesList, $mPage; + /** + * @var AbuseFilterChangesList Our changes list + */ + public $mChangesList; + /** + * @var AbuseFilterViewExamine The associated view + */ + public $mPage; /** * @param AbuseFilterViewExamine $page * @param AbuseFilterChangesList $changesList */ - public function __construct( $page, $changesList ) { + public function __construct( AbuseFilterViewExamine $page, AbuseFilterChangesList $changesList ) { parent::__construct(); $this->mChangesList = $changesList; $this->mPage = $page; diff --git a/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php b/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php index 265c97f1..550b6fab 100644 --- a/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php +++ b/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php @@ -3,22 +3,32 @@ use MediaWiki\Linker\LinkRenderer; class AbuseFilterHistoryPager extends TablePager { - public $mFilter, $mPage, $mUser; + /** + * @var int The filter ID + */ + public $mFilter; + /** + * @var AbuseFilterViewHistory The associated page + */ + public $mPage; + /** + * @var string The user whose changes we're looking up for + */ + public $mUser; - protected $linkRenderer; /** - * @param string $filter + * @param int $filter * @param AbuseFilterViewHistory $page * @param string $user User name * @param LinkRenderer $linkRenderer */ - public function __construct( $filter, $page, $user, $linkRenderer ) { + public function __construct( int $filter, AbuseFilterViewHistory $page, $user, + LinkRenderer $linkRenderer ) { + parent::__construct( $page->getContext(), $linkRenderer ); $this->mFilter = $filter; $this->mPage = $page; $this->mUser = $user; $this->mDefaultDirection = true; - $this->linkRenderer = $linkRenderer; - parent::__construct( $this->mPage->getContext() ); } /** @@ -44,7 +54,6 @@ class AbuseFilterHistoryPager extends TablePager { if ( !$this->mFilter ) { // awful hack $headers = [ 'afh_filter' => 'abusefilter-history-filterid' ] + $headers; - unset( $headers['afh_comments'] ); } foreach ( $headers as &$msg ) { @@ -61,20 +70,21 @@ class AbuseFilterHistoryPager extends TablePager { */ public function formatValue( $name, $value ) { $lang = $this->getLanguage(); + $linkRenderer = $this->getLinkRenderer(); $row = $this->mCurrentRow; switch ( $name ) { case 'afh_filter': - $formatted = $this->linkRenderer->makeLink( - SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->afh_filter ) ), + $formatted = $linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', $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( + $formatted = $linkRenderer->makeLink( $title, $lang->timeanddate( $row->afh_timestamp, true ) ); @@ -88,7 +98,7 @@ class AbuseFilterHistoryPager extends TablePager { $formatted = htmlspecialchars( $value, ENT_QUOTES, 'UTF-8', false ); break; case 'afh_flags': - $formatted = AbuseFilter::formatFlags( $value ); + $formatted = AbuseFilter::formatFlags( $value, $lang ); break; case 'afh_actions': $actions = unserialize( $value ); @@ -96,7 +106,7 @@ class AbuseFilterHistoryPager extends TablePager { $display_actions = ''; foreach ( $actions as $action => $parameters ) { - $displayAction = AbuseFilter::formatAction( $action, $parameters ); + $displayAction = AbuseFilter::formatAction( $action, $parameters, $lang ); $display_actions .= Xml::tags( 'li', null, $displayAction ); } $display_actions = Xml::tags( 'ul', null, $display_actions ); @@ -104,15 +114,36 @@ class AbuseFilterHistoryPager extends TablePager { $formatted = $display_actions; break; case 'afh_id': + // Set a link to a diff with the previous version if this isn't the first edit to the filter. + // Like in AbuseFilterViewDiff, don't show it if the user cannot see private filters and any + // of the versions is hidden. $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() ) + // @todo This is subpar, it should be cached at least. Should we also hide actions? + $dbr = wfGetDB( DB_REPLICA ); + $oldFlags = $dbr->selectField( + 'abuse_filter_history', + 'afh_flags', + [ + 'afh_filter' => $row->afh_filter, + 'afh_id <' . $dbr->addQuotes( $row->afh_id ), + ], + __METHOD__, + [ 'ORDER BY' => 'afh_id DESC' ] ); + if ( AbuseFilter::canViewPrivate( $this->getUser() ) || + ( + !in_array( 'hidden', explode( ',', $row->afh_flags ) ) && + !in_array( 'hidden', explode( ',', $oldFlags ) ) + ) + ) { + $title = $this->mPage->getTitle( + 'history/' . $row->afh_filter . "/diff/prev/$value" ); + $formatted = $linkRenderer->makeLink( + $title, + new HtmlArmor( $this->msg( 'abusefilter-history-diff' )->parse() ) + ); + } } break; default: @@ -120,29 +151,6 @@ class AbuseFilterHistoryPager extends TablePager { 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; } @@ -185,9 +193,7 @@ class AbuseFilterHistoryPager extends TablePager { $info['conds']['afh_filter'] = $this->mFilter; } - if ( !$this->getUser()->isAllowedAny( - 'abusefilter-modify', 'abusefilter-view-private' ) - ) { + if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) { // Hide data the user can't see. $info['conds']['af_hidden'] = 0; } @@ -219,6 +225,36 @@ class AbuseFilterHistoryPager extends TablePager { } /** + * @see TablePager::getCellAttrs + * + * @param string $field + * @param string $value + * @return array + */ + public function getCellAttrs( $field, $value ) { + $row = $this->mCurrentRow; + $mappings = array_flip( AbuseFilter::HISTORY_MAPPINGS ) + + [ 'afh_actions' => 'actions', 'afh_id' => 'id' ]; + $changed = explode( ',', $row->afh_changed_fields ); + + $fieldChanged = false; + if ( $field === 'afh_flags' ) { + // The field is changed if any of these filters are in the $changed array. + $filters = [ 'af_enabled', 'af_hidden', 'af_deleted', 'af_global' ]; + if ( count( array_intersect( $filters, $changed ) ) ) { + $fieldChanged = true; + } + } elseif ( in_array( $mappings[$field], $changed ) ) { + $fieldChanged = true; + } + + $class = $fieldChanged ? ' mw-abusefilter-history-changed' : ''; + $attrs = parent::getCellAttrs( $field, $value ); + $attrs['class'] .= $class; + return $attrs; + } + + /** * Title used for self-links. * * @return Title diff --git a/AbuseFilter/includes/pagers/AbuseFilterPager.php b/AbuseFilter/includes/pagers/AbuseFilterPager.php index 2092b850..482f5d78 100644 --- a/AbuseFilter/includes/pagers/AbuseFilterPager.php +++ b/AbuseFilter/includes/pagers/AbuseFilterPager.php @@ -1,6 +1,7 @@ <?php use MediaWiki\Linker\LinkRenderer; +use Wikimedia\AtEase\AtEase; /** * Class to build paginated filter list @@ -8,24 +9,43 @@ use MediaWiki\Linker\LinkRenderer; class AbuseFilterPager extends TablePager { /** - * @var LinkRenderer + * @var AbuseFilterViewList The associated page */ - protected $linkRenderer; - - public $mPage, $mConds, $mQuery; + public $mPage; + /** + * @var array Query WHERE conditions + */ + public $mConds; + /** + * @var string The pattern being searched + */ + private $mSearchPattern; + /** + * @var string The pattern search mode (LIKE, RLIKE or IRLIKE) + */ + private $mSearchMode; /** * @param AbuseFilterViewList $page * @param array $conds * @param LinkRenderer $linkRenderer - * @param array $query + * @param string $searchPattern Empty string if no pattern was specified + * @param string $searchMode */ - public function __construct( $page, $conds, $linkRenderer, $query ) { + public function __construct( + AbuseFilterViewList $page, + $conds, + LinkRenderer $linkRenderer, + string $searchPattern, + string $searchMode + ) { $this->mPage = $page; $this->mConds = $conds; - $this->linkRenderer = $linkRenderer; - $this->mQuery = $query; - parent::__construct( $this->mPage->getContext() ); + $this->mSearchPattern = $searchPattern; + $this->mSearchMode = $searchMode; + // needs to be at the end, some attributes are needed by methods + // called from ancestors' constructors + parent::__construct( $page->getContext(), $linkRenderer ); } /** @@ -56,6 +76,61 @@ class AbuseFilterPager extends TablePager { } /** + * @inheritDoc + * This is the same as the parent implementation if no search pattern was specified. + * Otherwise, it does a query with no limit and then slices the results à la ContribsPager. + */ + public function reallyDoQuery( $offset, $limit, $order ) { + if ( !strlen( $this->mSearchPattern ) ) { + return parent::reallyDoQuery( $offset, $limit, $order ); + } + + list( $tables, $fields, $conds, $fname, $options, $join_conds ) = + $this->buildQueryInfo( $offset, $limit, $order ); + + unset( $options['LIMIT'] ); + $res = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds ); + + $filtered = []; + foreach ( $res as $row ) { + if ( $this->matchesPattern( $row->af_pattern ) ) { + $filtered[ $row->af_id ] = $row; + } + } + + // sort results and enforce limit like ContribsPager + if ( $order === self::QUERY_ASCENDING ) { + ksort( $filtered ); + } else { + krsort( $filtered ); + } + $filtered = array_slice( $filtered, 0, $limit ); + $filtered = array_values( $filtered ); + return new FakeResultWrapper( $filtered ); + } + + /** + * Check whether $subject matches the given $pattern. + * + * @param string $subject + * @return bool + * @throws LogicException + */ + private function matchesPattern( $subject ) { + $pattern = $this->mSearchPattern; + switch ( $this->mSearchMode ) { + case 'RLIKE': + return (bool)preg_match( "/$pattern/u", $subject ); + case 'IRLIKE': + return (bool)preg_match( "/$pattern/ui", $subject ); + case 'LIKE': + return mb_stripos( $subject, $pattern ) !== false; + default: + throw new LogicException( "Unknown search type {$this->mSearchMode}" ); + } + } + + /** * @see Pager::getFieldNames() * @return array */ @@ -75,11 +150,12 @@ class AbuseFilterPager extends TablePager { 'af_hidden' => 'abusefilter-list-visibility', ]; - if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) { + $user = $this->getUser(); + if ( SpecialAbuseLog::canSeeDetails( $user ) ) { $headers['af_hit_count'] = 'abusefilter-list-hitcount'; } - if ( AbuseFilterView::canViewPrivate() && !empty( $this->mQuery[0] ) ) { + if ( AbuseFilter::canViewPrivate( $user ) && $this->mSearchPattern !== '' ) { $headers['af_pattern'] = 'abusefilter-list-pattern'; } @@ -101,79 +177,29 @@ class AbuseFilterPager extends TablePager { */ public function formatValue( $name, $value ) { $lang = $this->getLanguage(); + $user = $this->getUser(); + $linkRenderer = $this->getLinkRenderer(); $row = $this->mCurrentRow; switch ( $name ) { case 'af_id': - return $this->linkRenderer->makeLink( - SpecialPage::getTitleFor( 'AbuseFilter', intval( $value ) ), + return $linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', $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; + return $this->getHighlightedPattern( $row ); case 'af_public_comments': - return $this->linkRenderer->makeLink( - SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->af_id ) ), + return $linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ), $value ); case 'af_actions': $actions = explode( ',', $value ); $displayActions = []; + $context = $this->getContext(); foreach ( $actions as $action ) { - $displayActions[] = AbuseFilter::getActionDisplay( $action ); + $displayActions[] = AbuseFilter::getActionDisplay( $action, $context ); } return $lang->commaList( $displayActions ); case 'af_enabled': @@ -198,10 +224,13 @@ class AbuseFilterPager extends TablePager { $msg = $value ? 'abusefilter-hidden' : 'abusefilter-unhidden'; return $this->msg( $msg )->parse(); case 'af_hit_count': - if ( SpecialAbuseLog::canSeeDetails( $row->af_id, $row->af_hidden ) ) { + // Global here is used to determine whether the log entry is for an external, global + // filter, but all filters shown on Special:AbuseFilter are local. + $global = false; + if ( SpecialAbuseLog::canSeeDetails( $user, $row->af_id, $global, $row->af_hidden ) ) { $count_display = $this->msg( 'abusefilter-hitcount' ) ->numParams( $value )->text(); - $link = $this->linkRenderer->makeKnownLink( + $link = $linkRenderer->makeKnownLink( SpecialPage::getTitleFor( 'AbuseLog' ), $count_display, [], @@ -239,7 +268,7 @@ class AbuseFilterPager extends TablePager { ) )->params( wfEscapeWikiText( $row->af_user_text ) - )->parse(); + )->parse(); case 'af_group': return AbuseFilter::nameGroup( $value ); default: @@ -248,6 +277,65 @@ class AbuseFilterPager extends TablePager { } /** + * Get the filter pattern with <b> elements surrounding the searched pattern + * + * @param stdClass $row + * @return string + */ + private function getHighlightedPattern( stdClass $row ) { + $maxLen = 50; + if ( $this->mSearchMode === 'LIKE' ) { + $position = mb_stripos( $row->af_pattern, $this->mSearchPattern ); + $length = mb_strlen( $this->mSearchPattern ); + } else { + $regex = '/' . $this->mSearchPattern . '/u'; + if ( $this->mSearchMode === 'IRLIKE' ) { + $regex .= 'i'; + } + + $matches = []; + AtEase::suppressWarnings(); + $check = preg_match( + $regex, + $row->af_pattern, + $matches + ); + AtEase::restoreWarnings(); + // This may happen in case of catastrophic backtracking, or regexps matching + // the empty string. + if ( $check === false || strlen( $matches[0] ) === 0 ) { + return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) ); + } + + $length = mb_strlen( $matches[0] ); + $position = mb_strpos( $row->af_pattern, $matches[0] ); + } + + $remaining = $maxLen - $length; + if ( $remaining <= 0 ) { + $pattern = '<b>' . + htmlspecialchars( mb_substr( $row->af_pattern, $position, $maxLen ) ) . + '</b>'; + } else { + // Center the snippet on the matched string + $minoffset = max( $position - round( $remaining / 2 ), 0 ); + $pattern = mb_substr( $row->af_pattern, $minoffset, $maxLen ); + $pattern = + htmlspecialchars( mb_substr( $pattern, 0, $position - $minoffset ) ) . + '<b>' . + htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length ) ) . + '</b>' . + htmlspecialchars( mb_substr( + $pattern, + $position - $minoffset + $length, + $remaining - ( $position - $minoffset + $length ) + ) + ); + } + return $pattern; + } + + /** * @return string */ public function getDefaultSort() { @@ -258,7 +346,7 @@ class AbuseFilterPager extends TablePager { * @return string */ public function getTableClass() { - return 'TablePager mw-abusefilter-list-scrollable'; + return parent::getTableClass() . ' mw-abusefilter-list-scrollable'; } /** @@ -288,9 +376,18 @@ class AbuseFilterPager extends TablePager { 'af_hidden', 'af_group', ]; - if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) { + if ( SpecialAbuseLog::canSeeDetails( $this->getUser() ) ) { $sortable_fields[] = 'af_hit_count'; + $sortable_fields[] = 'af_public_comments'; } return in_array( $name, $sortable_fields ); } + + /** + * @see IndexPager::getExtraSortFields + * @return array + */ + public function getExtraSortFields() { + return [ 'af_enabled' => 'af_deleted' ]; + } } diff --git a/AbuseFilter/includes/pagers/AbuseLogPager.php b/AbuseFilter/includes/pagers/AbuseLogPager.php index 5c4f1a66..95ae8543 100644 --- a/AbuseFilter/includes/pagers/AbuseLogPager.php +++ b/AbuseFilter/includes/pagers/AbuseLogPager.php @@ -1,5 +1,6 @@ <?php +use MediaWiki\Cache\LinkBatchFactory; use Wikimedia\Rdbms\IResultWrapper; class AbuseLogPager extends ReverseChronologicalPager { @@ -13,14 +14,29 @@ class AbuseLogPager extends ReverseChronologicalPager { */ public $mConds; + /** @var LinkBatchFactory */ + private $linkBatchFactory; + + /** @var bool */ + private $joinWithArchive; + /** * @param SpecialAbuseLog $form * @param array $conds + * @param LinkBatchFactory $linkBatchFactory + * @param bool $joinWithArchive */ - public function __construct( $form, $conds = [] ) { + public function __construct( + SpecialAbuseLog $form, + array $conds, + LinkBatchFactory $linkBatchFactory, + bool $joinWithArchive = false + ) { + parent::__construct( $form->getContext(), $form->getLinkRenderer() ); $this->mForm = $form; $this->mConds = $conds; - parent::__construct(); + $this->linkBatchFactory = $linkBatchFactory; + $this->joinWithArchive = $joinWithArchive; } /** @@ -38,21 +54,45 @@ class AbuseLogPager extends ReverseChronologicalPager { $conds = $this->mConds; $info = [ - 'tables' => [ 'abuse_filter_log', 'abuse_filter' ], - 'fields' => '*', + 'tables' => [ 'abuse_filter_log', 'abuse_filter', 'revision' ], + 'fields' => [ + $this->mDb->tableName( 'abuse_filter_log' ) . '.*', + $this->mDb->tableName( 'abuse_filter' ) . '.*', + 'rev_id', + ], 'conds' => $conds, - 'join_conds' => - [ 'abuse_filter' => + 'join_conds' => [ + 'abuse_filter' => [ + 'LEFT JOIN', + 'af_id=afl_filter', + ], + 'revision' => [ + 'LEFT JOIN', [ - 'LEFT JOIN', - 'af_id=afl_filter', - ], + 'afl_wiki IS NULL', + 'afl_rev_id IS NOT NULL', + 'rev_id=afl_rev_id', + ] ], + ], ]; - if ( !$this->mForm->canSeeHidden() ) { - $db = $this->mDb; - $info['conds'][] = SpecialAbuseLog::getNotDeletedCond( $db ); + if ( $this->joinWithArchive ) { + $info['tables'][] = 'archive'; + $info['fields'][] = 'ar_timestamp'; + $info['join_conds']['archive'] = [ + 'LEFT JOIN', + [ + 'afl_wiki IS NULL', + 'afl_rev_id IS NOT NULL', + 'rev_id IS NULL', + 'ar_rev_id=afl_rev_id', + ] + ]; + } + + if ( !$this->mForm->canSeeHidden( $this->getUser() ) ) { + $info['conds']['afl_deleted'] = 0; } return $info; @@ -66,7 +106,7 @@ class AbuseLogPager extends ReverseChronologicalPager { return; } - $lb = new LinkBatch(); + $lb = $this->linkBatchFactory->newLinkBatch(); $lb->setCaller( __METHOD__ ); foreach ( $result as $row ) { // Only for local wiki results diff --git a/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php b/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php index 30173475..9c8033ce 100644 --- a/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php +++ b/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php @@ -11,8 +11,8 @@ class GlobalAbuseFilterPager extends AbuseFilterPager { * @param array $conds * @param LinkRenderer $linkRenderer */ - public function __construct( $page, $conds, $linkRenderer ) { - parent::__construct( $page, $conds, $linkRenderer, [ '', 'LIKE' ] ); + public function __construct( AbuseFilterViewList $page, $conds, LinkRenderer $linkRenderer ) { + parent::__construct( $page, $conds, $linkRenderer, '', 'LIKE' ); $this->mDb = wfGetDB( DB_REPLICA, [], $this->getConfig()->get( 'AbuseFilterCentralDB' ) ); } @@ -30,12 +30,13 @@ class GlobalAbuseFilterPager extends AbuseFilterPager { case 'af_id': return $lang->formatNum( intval( $value ) ); case 'af_public_comments': - return $this->getOutput()->parseInline( $value ); + return $this->getOutput()->parseInlineAsInterface( $value ); case 'af_actions': $actions = explode( ',', $value ); $displayActions = []; + $context = $this->getContext(); foreach ( $actions as $action ) { - $displayActions[] = AbuseFilter::getActionDisplay( $action ); + $displayActions[] = AbuseFilter::getActionDisplay( $action, $context ); } return $lang->commaList( $displayActions ); case 'af_enabled': diff --git a/AbuseFilter/includes/parser/AFPData.php b/AbuseFilter/includes/parser/AFPData.php index de8d8270..de7c1978 100644 --- a/AbuseFilter/includes/parser/AFPData.php +++ b/AbuseFilter/includes/parser/AFPData.php @@ -2,16 +2,23 @@ class AFPData { // Datatypes - const DINT = 'int'; - const DSTRING = 'string'; - const DNULL = 'null'; - const DBOOL = 'bool'; - const DFLOAT = 'float'; - const DARRAY = 'array'; - - // Translation table mapping shell-style wildcards to PCRE equivalents. - // Derived from <http://www.php.net/manual/en/function.fnmatch.php#100207> - private static $wildcardMap = [ + public const DINT = 'int'; + public const DSTRING = 'string'; + public const DNULL = 'null'; + public const DBOOL = 'bool'; + public const DFLOAT = 'float'; + public const DARRAY = 'array'; + // Special purpose type for non-initialized stuff + public const DUNDEFINED = 'undefined'; + // Special purpose for creating instances that will be populated later + public const DEMPTY = 'empty'; + + /** + * Translation table mapping shell-style wildcards to PCRE equivalents. + * Derived from <http://www.php.net/manual/en/function.fnmatch.php#100207> + * @internal + */ + public const WILDCARD_MAP = [ '\*' => '.*', '\+' => '\+', '\-' => '\-', @@ -23,14 +30,40 @@ class AFPData { '\]' => ']', ]; + /** + * @var string One of the D* const from this class + * @private Use $this->getType() + */ public $type; + /** + * @var mixed|null|AFPData[] The actual data contained in this object + * @private Use $this->getData() + */ public $data; /** + * @return string + */ + public function getType() { + return $this->type; + } + + /** + * @return AFPData[]|mixed|null + */ + public function getData() { + return $this->data; + } + + /** * @param string $type - * @param mixed|null $val + * @param AFPData[]|mixed|null $val */ - public function __construct( $type = self::DNULL, $val = null ) { + public function __construct( $type, $val = null ) { + if ( ( $type === self::DUNDEFINED || $type === self::DEMPTY ) && $val !== null ) { + // Sanity + throw new InvalidArgumentException( 'DUNDEFINED and DEMPTY cannot have a non-null value' ); + } $this->type = $type; $this->data = $val; } @@ -41,61 +74,57 @@ class AFPData { * @throws AFPException */ public static function newFromPHPVar( $var ) { - if ( is_string( $var ) ) { - return new AFPData( self::DSTRING, $var ); - } elseif ( is_int( $var ) ) { - return new AFPData( self::DINT, $var ); - } elseif ( is_float( $var ) ) { - return new AFPData( self::DFLOAT, $var ); - } elseif ( is_bool( $var ) ) { - return new AFPData( self::DBOOL, $var ); - } elseif ( is_array( $var ) ) { - $result = []; - foreach ( $var as $item ) { - $result[] = self::newFromPHPVar( $item ); - } - - return new AFPData( self::DARRAY, $result ); - } elseif ( is_null( $var ) ) { - return new AFPData(); - } else { - throw new AFPException( - 'Data type ' . gettype( $var ) . ' is not supported by AbuseFilter' - ); + switch ( gettype( $var ) ) { + case 'string': + return new AFPData( self::DSTRING, $var ); + case 'integer': + return new AFPData( self::DINT, $var ); + case 'double': + return new AFPData( self::DFLOAT, $var ); + case 'boolean': + return new AFPData( self::DBOOL, $var ); + case 'array': + $result = []; + foreach ( $var as $item ) { + $result[] = self::newFromPHPVar( $item ); + } + return new AFPData( self::DARRAY, $result ); + case 'NULL': + return new AFPData( self::DNULL ); + default: + throw new AFPException( + 'Data type ' . gettype( $var ) . ' is not supported by AbuseFilter' + ); } } /** - * @return AFPData - */ - public function dup() { - return new AFPData( $this->type, $this->data ); - } - - /** * @param AFPData $orig * @param string $target * @return AFPData */ - public static function castTypes( $orig, $target ) { - if ( $orig->type == $target ) { - return $orig->dup(); + public static function castTypes( AFPData $orig, $target ) { + if ( $orig->type === $target ) { + return $orig; + } + if ( $orig->type === self::DUNDEFINED ) { + // This case should be handled at a higher level, to avoid implicitly relying on what + // this method will do for the specific case. + throw new AFPException( 'Refusing to cast DUNDEFINED to something else' ); } - if ( $target == self::DNULL ) { - return new AFPData(); + if ( $target === self::DNULL ) { + // We don't expose any method to cast to null. And, actually, should we? + return new AFPData( self::DNULL ); } - if ( $orig->type == self::DARRAY ) { - if ( $target == self::DBOOL ) { + if ( $orig->type === self::DARRAY ) { + if ( $target === self::DBOOL ) { return new AFPData( self::DBOOL, (bool)count( $orig->data ) ); - } - if ( $target == self::DFLOAT ) { + } elseif ( $target === self::DFLOAT ) { return new AFPData( self::DFLOAT, floatval( count( $orig->data ) ) ); - } - if ( $target == self::DINT ) { - return new AFPData( self::DINT, intval( count( $orig->data ) ) ); - } - if ( $target == self::DSTRING ) { + } elseif ( $target === self::DINT ) { + return new AFPData( self::DINT, count( $orig->data ) ); + } elseif ( $target === self::DSTRING ) { $s = ''; foreach ( $orig->data as $item ) { $s .= $item->toString() . "\n"; @@ -105,97 +134,72 @@ class AFPData { } } - if ( $target == self::DBOOL ) { + if ( $target === self::DBOOL ) { return new AFPData( self::DBOOL, (bool)$orig->data ); - } - if ( $target == self::DFLOAT ) { + } elseif ( $target === self::DFLOAT ) { return new AFPData( self::DFLOAT, floatval( $orig->data ) ); - } - if ( $target == self::DINT ) { + } elseif ( $target === self::DINT ) { return new AFPData( self::DINT, intval( $orig->data ) ); - } - if ( $target == self::DSTRING ) { + } elseif ( $target === self::DSTRING ) { return new AFPData( self::DSTRING, strval( $orig->data ) ); - } - if ( $target == self::DARRAY ) { + } elseif ( $target === self::DARRAY ) { + // We don't expose any method to cast to array return new AFPData( self::DARRAY, [ $orig ] ); } + throw new AFPException( 'Cannot cast ' . $orig->type . " to $target." ); } /** - * @param AFPData $value - * @return AFPData - */ - public static function boolInvert( $value ) { - return new AFPData( self::DBOOL, !$value->toBool() ); - } - - /** - * @param AFPData $base - * @param AFPData $exponent - * @return AFPData - */ - public static function pow( $base, $exponent ) { - $res = pow( $base->toNumber(), $exponent->toNumber() ); - if ( $res === (int)$res ) { - return new AFPData( self::DINT, $res ); - } else { - return new AFPData( self::DFLOAT, $res ); - } - } - - /** - * @param AFPData $a - * @param AFPData $b * @return AFPData */ - public static function keywordIn( $a, $b ) { - $a = $a->toString(); - $b = $b->toString(); - - if ( $a == '' || $b == '' ) { - return new AFPData( self::DBOOL, false ); + public function boolInvert() { + if ( $this->type === self::DUNDEFINED ) { + return new AFPData( self::DUNDEFINED ); } - - return new AFPData( self::DBOOL, strpos( $b, $a ) !== false ); + return new AFPData( self::DBOOL, !$this->toBool() ); } /** - * @param AFPData $a - * @param AFPData $b + * @param AFPData $exponent * @return AFPData */ - public static function keywordContains( $a, $b ) { - $a = $a->toString(); - $b = $b->toString(); - - if ( $a == '' || $b == '' ) { - return new AFPData( self::DBOOL, false ); + public function pow( AFPData $exponent ) { + if ( $this->type === self::DUNDEFINED || $exponent->type === self::DUNDEFINED ) { + return new AFPData( self::DUNDEFINED ); } + $res = pow( $this->toNumber(), $exponent->toNumber() ); + $type = is_int( $res ) ? self::DINT : self::DFLOAT; - return new AFPData( self::DBOOL, strpos( $a, $b ) !== false ); + return new AFPData( $type, $res ); } /** - * @param AFPData $d1 * @param AFPData $d2 * @param bool $strict whether to also check types * @return bool + * @throws AFPException if $this or $d2 is a DUNDEFINED. This shouldn't happen, because this method + * only returns a boolean, and thus the type of the result has already been decided and cannot + * be changed to be a DUNDEFINED from here. + * @internal */ - 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; + public function equals( AFPData $d2, $strict = false ) { + if ( $this->type === self::DUNDEFINED || $d2->type === self::DUNDEFINED ) { + throw new AFPException( + __METHOD__ . " got a DUNDEFINED. This should be handled at a higher level" + ); + } elseif ( $this->type !== self::DARRAY && $d2->type !== self::DARRAY ) { + $typecheck = $this->type === $d2->type || !$strict; + return $typecheck && $this->toString() === $d2->toString(); + } elseif ( $this->type === self::DARRAY && $d2->type === self::DARRAY ) { + $data1 = $this->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 ) { + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Array type + if ( $data1[$i]->equals( $data2[$i], $strict ) === false ) { return false; } } @@ -205,10 +209,11 @@ class AFPData { 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; + if ( $this->type === self::DARRAY && count( $this->data ) === 0 ) { + return ( $d2->type === self::DBOOL && $d2->toBool() === false ) || $d2->type === self::DNULL; + } elseif ( $d2->type === self::DARRAY && count( $d2->data ) === 0 ) { + return ( $this->type === self::DBOOL && $this->toBool() === false ) || + $this->type === self::DNULL; } else { return false; } @@ -216,138 +221,80 @@ class 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'; - Wikimedia\suppressWarnings(); - $result = preg_match( $pattern, $str ); - Wikimedia\restoreWarnings(); - - return new AFPData( self::DBOOL, (bool)$result ); - } - - /** - * @param AFPData $str - * @param AFPData $regex - * @param int $pos - * @param bool $insensitive - * @return AFPData - * @throws Exception - */ - public static function keywordRegex( $str, $regex, $pos, $insensitive = false ) { - $str = $str->toString(); - $pattern = $regex->toString(); - - $pattern = preg_replace( '!(\\\\\\\\)*(\\\\)?/!', '$1\/', $pattern ); - $pattern = "/$pattern/u"; - - if ( $insensitive ) { - $pattern .= 'i'; - } - - Wikimedia\suppressWarnings(); - $result = preg_match( $pattern, $str ); - Wikimedia\restoreWarnings(); - if ( $result === false ) { - throw new AFPUserVisibleException( - 'regexfailure', - $pos, - [ $pattern ] - ); - } - - return new AFPData( self::DBOOL, (bool)$result ); - } - - /** - * @param AFPData $str - * @param AFPData $regex - * @param int $pos - * @return AFPData - */ - public static function keywordRegexInsensitive( $str, $regex, $pos ) { - return self::keywordRegex( $str, $regex, $pos, true ); - } - - /** - * @param AFPData $data - * @return AFPData - */ - public static function unaryMinus( $data ) { - if ( $data->type == self::DINT ) { - return new AFPData( $data->type, -$data->toInt() ); + public function unaryMinus() { + if ( $this->type === self::DUNDEFINED ) { + return new AFPData( self::DUNDEFINED ); + } elseif ( $this->type === self::DINT ) { + return new AFPData( $this->type, -$this->toInt() ); } else { - return new AFPData( $data->type, -$data->toFloat() ); + $type = $this->type === self::DEMPTY ? self::DNULL : $this->type; + return new AFPData( $type, -$this->toFloat() ); } } /** - * @param AFPData $a * @param AFPData $b * @param string $op * @return AFPData * @throws AFPException */ - public static function boolOp( $a, $b, $op ) { - $a = $a->toBool(); - $b = $b->toBool(); - if ( $op == '|' ) { + public function boolOp( AFPData $b, $op ) { + $a = $this->type === self::DUNDEFINED ? false : $this->toBool(); + $b = $b->type === self::DUNDEFINED ? false : $b->toBool(); + + if ( $op === '|' ) { return new AFPData( self::DBOOL, $a || $b ); - } - if ( $op == '&' ) { + } elseif ( $op === '&' ) { return new AFPData( self::DBOOL, $a && $b ); - } - if ( $op == '^' ) { + } elseif ( $op === '^' ) { return new AFPData( self::DBOOL, $a xor $b ); } // Should never happen. + // @codeCoverageIgnoreStart throw new AFPException( "Invalid boolean operation: {$op}" ); + // @codeCoverageIgnoreEnd } /** - * @param AFPData $a * @param AFPData $b * @param string $op * @return AFPData * @throws AFPException */ - public static function compareOp( $a, $b, $op ) { - if ( $op == '==' || $op == '=' ) { - return new AFPData( self::DBOOL, self::equals( $a, $b ) ); + public function compareOp( AFPData $b, $op ) { + if ( $this->type === self::DUNDEFINED || $b->type === self::DUNDEFINED ) { + return new AFPData( self::DUNDEFINED ); } - if ( $op == '!=' ) { - return new AFPData( self::DBOOL, !self::equals( $a, $b ) ); + if ( $op === '==' || $op === '=' ) { + return new AFPData( self::DBOOL, $this->equals( $b ) ); + } elseif ( $op === '!=' ) { + return new AFPData( self::DBOOL, !$this->equals( $b ) ); + } elseif ( $op === '===' ) { + return new AFPData( self::DBOOL, $this->equals( $b, true ) ); + } elseif ( $op === '!==' ) { + return new AFPData( self::DBOOL, !$this->equals( $b, true ) ); } - if ( $op == '===' ) { - return new AFPData( self::DBOOL, self::equals( $a, $b, true ) ); - } - if ( $op == '!==' ) { - return new AFPData( self::DBOOL, !self::equals( $a, $b, true ) ); - } - $a = $a->toString(); + + $a = $this->toString(); $b = $b->toString(); - if ( $op == '>' ) { + if ( $op === '>' ) { return new AFPData( self::DBOOL, $a > $b ); - } - if ( $op == '<' ) { + } elseif ( $op === '<' ) { return new AFPData( self::DBOOL, $a < $b ); - } - if ( $op == '>=' ) { + } elseif ( $op === '>=' ) { return new AFPData( self::DBOOL, $a >= $b ); - } - if ( $op == '<=' ) { + } elseif ( $op === '<=' ) { return new AFPData( self::DBOOL, $a <= $b ); } // Should never happen + // @codeCoverageIgnoreStart throw new AFPException( "Invalid comparison operation: {$op}" ); + // @codeCoverageIgnoreEnd } /** - * @param AFPData $a * @param AFPData $b * @param string $op * @param int $pos @@ -355,68 +302,114 @@ class AFPData { * @throws AFPUserVisibleException * @throws AFPException */ - public static function mulRel( $a, $b, $op, $pos ) { - $a = $a->toNumber(); + public function mulRel( AFPData $b, $op, $pos ) { + if ( $b->type === self::DUNDEFINED ) { + // The LHS type is checked later, because we first need to ensure we're not + // dividing or taking modulo by 0 (and that should throw regardless of whether + // the LHS is undefined). + return new AFPData( self::DUNDEFINED ); + } + $b = $b->toNumber(); - if ( $op != '*' && $b == 0 ) { - throw new AFPUserVisibleException( 'dividebyzero', $pos, [ $a ] ); + if ( + ( $op === '/' && (float)$b === 0.0 ) || + ( $op === '%' && (int)$b === 0 ) + ) { + $lhs = $this->type === self::DUNDEFINED ? 0 : $this->toNumber(); + throw new AFPUserVisibleException( 'dividebyzero', $pos, [ $lhs ] ); + } + + if ( $this->type === self::DUNDEFINED ) { + return new AFPData( self::DUNDEFINED ); } + $a = $this->toNumber(); - if ( $op == '*' ) { + if ( $op === '*' ) { $data = $a * $b; - } elseif ( $op == '/' ) { + } elseif ( $op === '/' ) { $data = $a / $b; - } elseif ( $op == '%' ) { + } elseif ( $op === '%' ) { $data = $a % $b; } else { // Should never happen + // @codeCoverageIgnoreStart throw new AFPException( "Invalid multiplication-related operation: {$op}" ); + // @codeCoverageIgnoreEnd } - if ( $data === (int)$data ) { - $data = intval( $data ); - $type = self::DINT; - } else { - $data = floatval( $data ); - $type = self::DFLOAT; - } + $type = is_int( $data ) ? self::DINT : self::DFLOAT; return new AFPData( $type, $data ); } /** - * @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::DARRAY && $b->type == self::DARRAY ) { - return new AFPData( self::DARRAY, array_merge( $a->toArray(), $b->toArray() ) ); + public function sum( AFPData $b ) { + if ( $this->type === self::DUNDEFINED || $b->type === self::DUNDEFINED ) { + return new AFPData( self::DUNDEFINED ); + } elseif ( $this->type === self::DSTRING || $b->type === self::DSTRING ) { + return new AFPData( self::DSTRING, $this->toString() . $b->toString() ); + } elseif ( $this->type === self::DARRAY && $b->type === self::DARRAY ) { + return new AFPData( self::DARRAY, array_merge( $this->toArray(), $b->toArray() ) ); } else { - $res = $a->toNumber() + $b->toNumber(); - if ( $res === (int)$res ) { - return new AFPData( self::DINT, $res ); - } else { - return new AFPData( self::DFLOAT, $res ); - } + $res = $this->toNumber() + $b->toNumber(); + $type = is_int( $res ) ? self::DINT : self::DFLOAT; + + return new AFPData( $type, $res ); } } /** - * @param AFPData $a * @param AFPData $b * @return AFPData */ - public static function sub( $a, $b ) { - $res = $a->toNumber() - $b->toNumber(); - if ( $res === (int)$res ) { - return new AFPData( self::DINT, $res ); - } else { - return new AFPData( self::DFLOAT, $res ); + public function sub( AFPData $b ) { + if ( $this->type === self::DUNDEFINED || $b->type === self::DUNDEFINED ) { + return new AFPData( self::DUNDEFINED ); + } + $res = $this->toNumber() - $b->toNumber(); + $type = is_int( $res ) ? self::DINT : self::DFLOAT; + + return new AFPData( $type, $res ); + } + + /** + * Check whether this instance contains the DUNDEFINED type, recursively + * @return bool + */ + public function hasUndefined() : bool { + if ( $this->type === self::DUNDEFINED ) { + return true; + } + if ( $this->type === self::DARRAY ) { + foreach ( $this->data as $el ) { + if ( $el->hasUndefined() ) { + return true; + } + } + } + return false; + } + + /** + * Return a clone of this instance where DUNDEFINED is replaced with DNULL + * @return $this + */ + public function cloneAsUndefinedReplacedWithNull() : self { + if ( $this->type === self::DUNDEFINED ) { + return new self( self::DNULL ); + } + if ( $this->type === self::DARRAY ) { + $data = []; + foreach ( $this->data as $el ) { + $data[] = $el->cloneAsUndefinedReplacedWithNull(); + } + return new self( self::DARRAY, $data ); } + return clone $this; } /** Convert shorteners */ @@ -444,9 +437,13 @@ class AFPData { return $output; case self::DNULL: + case self::DUNDEFINED: + case self::DEMPTY: return null; default: + // @codeCoverageIgnoreStart throw new MWException( "Unknown type" ); + // @codeCoverageIgnoreEnd } } @@ -482,7 +479,13 @@ class AFPData { * @return int|float */ public function toNumber() { - return $this->type == self::DINT ? $this->toInt() : $this->toFloat(); + // Types that can be cast to int + $intLikeTypes = [ + self::DINT, + self::DBOOL, + self::DNULL + ]; + return in_array( $this->type, $intLikeTypes, true ) ? $this->toInt() : $this->toFloat(); } /** diff --git a/AbuseFilter/includes/parser/AFPParserState.php b/AbuseFilter/includes/parser/AFPParserState.php index 453948d1..ee345d94 100644 --- a/AbuseFilter/includes/parser/AFPParserState.php +++ b/AbuseFilter/includes/parser/AFPParserState.php @@ -7,7 +7,7 @@ class AFPParserState { * @param AFPToken $token * @param int $pos */ - public function __construct( $token, $pos ) { + public function __construct( AFPToken $token, $pos ) { $this->token = $token; $this->pos = $pos; } diff --git a/AbuseFilter/includes/parser/AFPSyntaxTree.php b/AbuseFilter/includes/parser/AFPSyntaxTree.php new file mode 100644 index 00000000..ffca3eb9 --- /dev/null +++ b/AbuseFilter/includes/parser/AFPSyntaxTree.php @@ -0,0 +1,27 @@ +<?php + +/** + * A class representing a whole AST generated by AFPTreeParser, holding AFPTreeNode's. This wrapper + * could be expanded in the future. For now, it's mostly useful for typehints, and to have an + * evalTree function in the CachingParser. + */ +class AFPSyntaxTree { + /** + * @var AFPTreeNode|null + */ + private $rootNode; + + /** + * @param AFPTreeNode|null $root + */ + public function __construct( AFPTreeNode $root = null ) { + $this->rootNode = $root; + } + + /** + * @return AFPTreeNode|null + */ + public function getRoot() { + return $this->rootNode; + } +} diff --git a/AbuseFilter/includes/parser/AFPToken.php b/AbuseFilter/includes/parser/AFPToken.php index 897f5ded..78fdd7c3 100644 --- a/AbuseFilter/includes/parser/AFPToken.php +++ b/AbuseFilter/includes/parser/AFPToken.php @@ -36,20 +36,29 @@ * * Atom (A) - return value */ class AFPToken { - const TNONE = 'T_NONE'; - const TID = 'T_ID'; - const TKEYWORD = 'T_KEYWORD'; - const TSTRING = 'T_STRING'; - const TINT = 'T_INT'; - const TFLOAT = 'T_FLOAT'; - const TOP = 'T_OP'; - const TBRACE = 'T_BRACE'; - const TSQUAREBRACKET = 'T_SQUARE_BRACKET'; - const TCOMMA = 'T_COMMA'; - const TSTATEMENTSEPARATOR = 'T_STATEMENT_SEPARATOR'; + public const TNONE = 'T_NONE'; + public const TID = 'T_ID'; + public const TKEYWORD = 'T_KEYWORD'; + public const TSTRING = 'T_STRING'; + public const TINT = 'T_INT'; + public const TFLOAT = 'T_FLOAT'; + public const TOP = 'T_OP'; + public const TBRACE = 'T_BRACE'; + public const TSQUAREBRACKET = 'T_SQUARE_BRACKET'; + public const TCOMMA = 'T_COMMA'; + public const TSTATEMENTSEPARATOR = 'T_STATEMENT_SEPARATOR'; + /** + * @var string One of the T* constant from this class + */ public $type; + /** + * @var mixed|null The actual value of the token + */ public $value; + /** + * @var int The code offset where this token is found + */ public $pos; /** diff --git a/AbuseFilter/includes/parser/AFPTransitionBase.php b/AbuseFilter/includes/parser/AFPTransitionBase.php new file mode 100644 index 00000000..e784a2a5 --- /dev/null +++ b/AbuseFilter/includes/parser/AFPTransitionBase.php @@ -0,0 +1,143 @@ +<?php + +use MediaWiki\Extension\AbuseFilter\KeywordsManager; + +/** + * Base parse-related class to be used while the old parser is being phased out + * + * @internal This is a temporary class until things are settled down + * @property KeywordsManager $keywordsManager + */ +abstract class AFPTransitionBase { + public const FUNCTIONS = [ + 'lcase' => 'funcLc', + 'ucase' => 'funcUc', + 'length' => 'funcLen', + 'string' => 'castString', + 'int' => 'castInt', + 'float' => 'castFloat', + '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', + 'str_replace' => 'funcStrReplace', + 'rescape' => 'funcStrRegexEscape', + 'set' => 'funcSetVar', + 'set_var' => 'funcSetVar', + 'sanitize' => 'funcSanitize', + ]; + + /** + * The minimum and maximum amount of arguments required by each function. + * @var int[][] + */ + public const FUNC_ARG_COUNT = [ + 'lcase' => [ 1, 1 ], + 'ucase' => [ 1, 1 ], + 'length' => [ 1, 1 ], + 'string' => [ 1, 1 ], + 'int' => [ 1, 1 ], + 'float' => [ 1, 1 ], + 'bool' => [ 1, 1 ], + 'norm' => [ 1, 1 ], + 'ccnorm' => [ 1, 1 ], + 'ccnorm_contains_any' => [ 2, INF ], + 'ccnorm_contains_all' => [ 2, INF ], + 'specialratio' => [ 1, 1 ], + 'rmspecials' => [ 1, 1 ], + 'rmdoubles' => [ 1, 1 ], + 'rmwhitespace' => [ 1, 1 ], + 'count' => [ 1, 2 ], + 'rcount' => [ 1, 2 ], + 'get_matches' => [ 2, 2 ], + 'ip_in_range' => [ 2, 2 ], + 'contains_any' => [ 2, INF ], + 'contains_all' => [ 2, INF ], + 'equals_to_any' => [ 2, INF ], + 'substr' => [ 2, 3 ], + 'strlen' => [ 1, 1 ], + 'strpos' => [ 2, 3 ], + 'str_replace' => [ 3, 3 ], + 'rescape' => [ 1, 1 ], + 'set' => [ 2, 2 ], + 'set_var' => [ 2, 2 ], + 'sanitize' => [ 1, 1 ], + ]; + + /** + * @var int The position of the current token + */ + protected $mPos; + + /** + * Check that a built-in function has been provided the right amount of arguments + * + * @param array $args The arguments supplied to the function + * @param string $func The function name + * @throws AFPUserVisibleException + */ + protected function checkArgCount( $args, $func ) { + if ( !array_key_exists( $func, self::FUNC_ARG_COUNT ) ) { + // @codeCoverageIgnoreStart + throw new InvalidArgumentException( "$func is not a valid function." ); + // @codeCoverageIgnoreEnd + } + list( $min, $max ) = self::FUNC_ARG_COUNT[ $func ]; + if ( count( $args ) < $min ) { + throw new AFPUserVisibleException( + $min === 1 ? 'noparams' : 'notenoughargs', + $this->mPos, + [ $func, $min, count( $args ) ] + ); + } elseif ( count( $args ) > $max ) { + throw new AFPUserVisibleException( + 'toomanyargs', + $this->mPos, + [ $func, $max, count( $args ) ] + ); + } + } + + /** + * Check whether the given name is a reserved identifier, e.g. the name of a built-in variable, + * function, or keyword. + * + * @param string $name + * @return bool + */ + protected function isReservedIdentifier( $name ) { + return $this->keywordsManager->varExists( $name ) || + array_key_exists( $name, self::FUNCTIONS ) || + // We need to check for true, false, if/then/else etc. because, even if they have a different + // AFPToken type, they may be used inside set/set_var() + in_array( $name, AbuseFilterTokenizer::KEYWORDS, true ); + } + + /** + * @param string $fname + * @return bool + */ + protected function functionIsVariadic( $fname ) { + if ( !array_key_exists( $fname, self::FUNC_ARG_COUNT ) ) { + // @codeCoverageIgnoreStart + throw new InvalidArgumentException( "Function $fname is not valid" ); + // @codeCoverageIgnoreEnd + } + return self::FUNC_ARG_COUNT[$fname][1] === INF; + } +} diff --git a/AbuseFilter/includes/parser/AFPTreeNode.php b/AbuseFilter/includes/parser/AFPTreeNode.php index a3c2a063..65dc5a7f 100644 --- a/AbuseFilter/includes/parser/AFPTreeNode.php +++ b/AbuseFilter/includes/parser/AFPTreeNode.php @@ -10,7 +10,7 @@ class AFPTreeNode { // SEMICOLON is a many-children node, denoting that the nodes have to be // evaluated in order and the last value has to be returned. - const SEMICOLON = 'SEMICOLON'; + public const SEMICOLON = 'SEMICOLON'; // ASSIGNMENT (formerly known as SET) is a node which is responsible for // assigning values to variables. ASSIGNMENT is a (variable name [string], @@ -18,47 +18,47 @@ class AFPTreeNode { // 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 ARRAY_APPEND = 'ARRAY_APPEND'; + public const ASSIGNMENT = 'ASSIGNMENT'; + public const INDEX_ASSIGNMENT = 'INDEX_ASSIGNMENT'; + public 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, - // evaluated-in-false), all tree nodes. - const CONDITIONAL = 'CONDITIONAL'; + // construct. The format is (condition, evaluated-if-true, evaluated-in-false). + // The first two are tree nodes, the last one can be a node, or null if there's no else. + public const CONDITIONAL = 'CONDITIONAL'; // LOGIC is a logic operator accepted by AFPData::boolOp. The format is // (operation, left operand, right operand). - const LOGIC = 'LOGIC'; + public const LOGIC = 'LOGIC'; // COMPARE is a comparison operator accepted by AFPData::boolOp. The format is // (operation, left operand, right operand). - const COMPARE = 'COMPARE'; + public const COMPARE = 'COMPARE'; // SUM_REL is either '+' or '-'. The format is (operation, left operand, // right operand). - const SUM_REL = 'SUM_REL'; + public const SUM_REL = 'SUM_REL'; // MUL_REL is a multiplication-related operation accepted by AFPData::mulRel. // The format is (operation, left operand, right operand). - const MUL_REL = 'MUL_REL'; + public const MUL_REL = 'MUL_REL'; // POW is an exponentiation operator. The format is (base, exponent). - const POW = 'POW'; + public const POW = 'POW'; // BOOL_INVERT is a boolean inversion operator. The format is (operand). - const BOOL_INVERT = 'BOOL_INVERT'; + public const BOOL_INVERT = 'BOOL_INVERT'; // KEYWORD_OPERATOR is one of the binary keyword operators supported by the // filter language. The format is (keyword, left operand, right operand). - const KEYWORD_OPERATOR = 'KEYWORD_OPERATOR'; + public const KEYWORD_OPERATOR = 'KEYWORD_OPERATOR'; // UNARY is either unary minus or unary plus. The format is (operator, operand). - const UNARY = 'UNARY'; + public const UNARY = 'UNARY'; // ARRAY_INDEX is an operation of accessing an array by an offset. The format // is (array, offset). - const ARRAY_INDEX = 'ARRAY_INDEX'; + public const ARRAY_INDEX = 'ARRAY_INDEX'; // Since parenthesis only manipulate precedence of the operators, they are // not explicitly represented in the tree. @@ -66,15 +66,15 @@ class AFPTreeNode { // FUNCTION_CALL is an invocation of built-in function. The format is a // tuple where the first element is a function name, and all subsequent // elements are the arguments. - const FUNCTION_CALL = 'FUNCTION_CALL'; + public const FUNCTION_CALL = 'FUNCTION_CALL'; // 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'; + public const ARRAY_DEFINITION = 'ARRAY_DEFINITION'; // ATOM is a node representing a literal. The only element of $children is a // token corresponding to the literal. - const ATOM = 'ATOM'; + public const ATOM = 'ATOM'; /** @var string Type of the node, one of the constants above */ public $type; @@ -86,29 +86,92 @@ class AFPTreeNode { */ public $children; - // Position used for error reporting. + /** @var int Position used for error reporting. */ public $position; /** + * @var string[] Names of the variables assigned in this node or any of its descendants + * @todo We could change this to be an instance of a new AFPScope class (holding a var map) + * if we'll have the need to store other scope-specific data, + * see <https://phabricator.wikimedia.org/T230982#5475400>. + */ + private $innerAssignments = []; + + /** * @param string $type - * @param AFPTreeNode[]|string[]|AFPToken $children + * @param (AFPTreeNode|null)[]|string[]|AFPToken $children * @param int $position */ public function __construct( $type, $children, $position ) { $this->type = $type; $this->children = $children; $this->position = $position; + $this->populateInnerAssignments(); + } + + /** + * Save in this node all the variable names used in the children, and in this node if it's an + * assignment-related node. Note that this doesn't check whether the variable is custom or builtin: + * this is already checked when calling setUserVariable. + * In case we'll ever need to store other data in a node, or maybe even a Scope object, this could + * be moved to a different class which could also re-visit the whole AST. + */ + private function populateInnerAssignments() { + if ( $this->type === self::ATOM ) { + return; + } + + if ( + $this->type === self::ASSIGNMENT || + $this->type === self::INDEX_ASSIGNMENT || + $this->type === self::ARRAY_APPEND + ) { + $this->innerAssignments = [ $this->children[0] ]; + } elseif ( + $this->type === self::FUNCTION_CALL && + in_array( $this->children[0], [ 'set', 'set_var' ] ) && + // If unset, parsing will fail when checking arguments + isset( $this->children[1] ) + ) { + $varnameNode = $this->children[1]; + if ( $varnameNode->type !== self::ATOM ) { + // Shouldn't happen since variable variables are not allowed + // @codeCoverageIgnoreStart + throw new AFPException( "Got non-atom type {$varnameNode->type} for set_var" ); + // @codeCoverageIgnoreEnd + } + $this->innerAssignments = [ $varnameNode->children->value ]; + } + + // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach ATOM excluded above + foreach ( $this->children as $child ) { + if ( $child instanceof self ) { + $this->innerAssignments = array_merge( $this->innerAssignments, $child->getInnerAssignments() ); + } + } + } + + /** + * @return string[] + */ + public function getInnerAssignments() : array { + return $this->innerAssignments; } /** * @return string + * @codeCoverageIgnore */ public function toDebugString() { return implode( "\n", $this->toDebugStringInner() ); } + /** + * @return array + * @codeCoverageIgnore + */ private function toDebugStringInner() { - if ( $this->type == self::ATOM ) { + if ( $this->type === self::ATOM ) { return [ "ATOM({$this->children->type} {$this->children->value})" ]; } @@ -117,6 +180,7 @@ class AFPTreeNode { }; $lines = [ "{$this->type}" ]; + // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here foreach ( $this->children as $subnode ) { if ( $subnode instanceof AFPTreeNode ) { $sublines = array_map( $align, $subnode->toDebugStringInner() ); diff --git a/AbuseFilter/includes/parser/AFPTreeParser.php b/AbuseFilter/includes/parser/AFPTreeParser.php index 4fa35356..b4befd0f 100644 --- a/AbuseFilter/includes/parser/AFPTreeParser.php +++ b/AbuseFilter/includes/parser/AFPTreeParser.php @@ -5,33 +5,82 @@ * evaluating it into different passes, allowing the parse tree to be cached. * * @file + * @phan-file-suppress PhanPossiblyInfiniteRecursionSameParams Recursion controlled by class props */ +use MediaWiki\Extension\AbuseFilter\KeywordsManager; +use Psr\Log\LoggerInterface; + /** * A parser that transforms the text of the filter into a parse tree. */ -class AFPTreeParser { - // The tokenized representation of the filter parsed. +class AFPTreeParser extends AFPTransitionBase { + /** + * @var array[] Contains the AFPTokens for the code being parsed + */ public $mTokens; + /** + * @var AFPToken The current token + */ + public $mCur; + /** + * @var string|null The ID of the filter being parsed, if available. Can also be "global-$ID" + */ + protected $mFilter; - // Current token handled by the parser and its position. - public $mCur, $mPos; + public const CACHE_VERSION = 2; - const CACHE_VERSION = 2; + /** + * @var BagOStuff Used to cache tokens + */ + protected $cache; /** - * Create a new instance + * @var LoggerInterface Used for debugging */ - public function __construct() { + protected $logger; + + /** + * @var IBufferingStatsdDataFactory + */ + protected $statsd; + + /** @var KeywordsManager */ + protected $keywordsManager; + + /** + * @param BagOStuff $cache + * @param LoggerInterface $logger Used for debugging + * @param IBufferingStatsdDataFactory $statsd + * @param KeywordsManager $keywordsManager + */ + public function __construct( + BagOStuff $cache, + LoggerInterface $logger, + IBufferingStatsdDataFactory $statsd, + KeywordsManager $keywordsManager + ) { + $this->cache = $cache; + $this->logger = $logger; + $this->statsd = $statsd; + $this->keywordsManager = $keywordsManager; $this->resetState(); } /** + * @param string $filter + */ + public function setFilter( $filter ) { + $this->mFilter = $filter; + } + + /** * Resets the state */ public function resetState() { $this->mTokens = []; $this->mPos = 0; + $this->mFilter = null; } /** @@ -42,6 +91,16 @@ class AFPTreeParser { } /** + * Get the next token. This is similar to move() but doesn't change class members, + * allowing to look ahead without rolling back the state. + * + * @return AFPToken + */ + protected function getNextToken() { + return $this->mTokens[$this->mPos][0]; + } + + /** * getState() function allows parser state to be rollbacked to several tokens * back. * @@ -67,26 +126,37 @@ class AFPTreeParser { * * @param string $code * @throws AFPUserVisibleException - * @return AFPTreeNode|null + * @return AFPSyntaxTree */ - public function parse( $code ) { - $this->mTokens = AbuseFilterTokenizer::tokenize( $code ); + public function parse( $code ) : AFPSyntaxTree { + $tokenizer = new AbuseFilterTokenizer( $this->cache, $this->logger ); + $this->mTokens = $tokenizer->getTokens( $code ); $this->mPos = 0; - return $this->doLevelEntry(); + return $this->buildSyntaxTree(); + } + + /** + * @return AFPSyntaxTree + */ + public function buildSyntaxTree() : AFPSyntaxTree { + $startTime = microtime( true ); + $root = $this->doLevelEntry(); + $this->statsd->timing( 'abusefilter_cachingParser_buildtree', microtime( true ) - $startTime ); + return new AFPSyntaxTree( $root ); } /* Levels */ /** * Handles unexpected characters after the expression. - * @return AFPTreeNode|null + * @return AFPTreeNode|null Null only if no statements * @throws AFPUserVisibleException */ protected function doLevelEntry() { $result = $this->doLevelSemicolon(); - if ( $this->mCur->type != AFPToken::TNONE ) { + if ( $this->mCur->type !== AFPToken::TNONE ) { throw new AFPUserVisibleException( 'unexpectedatend', $this->mPos, [ $this->mCur->type ] @@ -108,23 +178,27 @@ class AFPTreeParser { $this->move(); $position = $this->mPos; - if ( $this->mCur->type == AFPToken::TNONE ) { + if ( + $this->mCur->type === AFPToken::TNONE || + ( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value == ')' ) + ) { + // Handle special cases which the other parser handled in doLevelAtom break; } // Allow empty statements. - if ( $this->mCur->type == AFPToken::TSTATEMENTSEPARATOR ) { + if ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR ) { continue; } $statements[] = $this->doLevelSet(); $position = $this->mPos; - } while ( $this->mCur->type == AFPToken::TSTATEMENTSEPARATOR ); + } while ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR ); // Flatten the tree if possible. - if ( count( $statements ) == 0 ) { + if ( count( $statements ) === 0 ) { return null; - } elseif ( count( $statements ) == 1 ) { + } elseif ( count( $statements ) === 1 ) { return $statements[0]; } else { return new AFPTreeNode( AFPTreeNode::SEMICOLON, $statements, $position ); @@ -138,15 +212,16 @@ class AFPTreeParser { * @throws AFPUserVisibleException */ protected function doLevelSet() { - if ( $this->mCur->type == AFPToken::TID ) { - $varname = $this->mCur->value; + if ( $this->mCur->type === AFPToken::TID ) { + $varname = (string)$this->mCur->value; // Speculatively parse the assignment statement assuming it can // potentially be an assignment, but roll back if it isn't. + // @todo Use $this->getNextToken for clearer code $initialState = $this->getState(); $this->move(); - if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) { + if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) { $position = $this->mPos; $this->move(); $value = $this->doLevelSet(); @@ -154,24 +229,24 @@ class AFPTreeParser { return new AFPTreeNode( AFPTreeNode::ASSIGNMENT, [ $varname, $value ], $position ); } - if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) { + if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) { $this->move(); - if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) { $index = 'append'; } else { // Parse index offset. $this->setState( $initialState ); $this->move(); $index = $this->doLevelSemicolon(); - if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) { + if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, [ ']', $this->mCur->type, $this->mCur->value ] ); } } $this->move(); - if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) { + if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) { $position = $this->mPos; $this->move(); $value = $this->doLevelSet(); @@ -203,12 +278,12 @@ class AFPTreeParser { * @throws AFPUserVisibleException */ protected function doLevelConditions() { - if ( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'if' ) { + if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'if' ) { $position = $this->mPos; $this->move(); $condition = $this->doLevelBoolOps(); - if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'then' ) ) { + if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'then' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, [ @@ -222,21 +297,14 @@ class AFPTreeParser { $valueIfTrue = $this->doLevelConditions(); - if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'else' ) ) { - throw new AFPUserVisibleException( 'expectednotfound', - $this->mPos, - [ - 'else', - $this->mCur->type, - $this->mCur->value - ] - ); + if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'else' ) { + $this->move(); + $valueIfFalse = $this->doLevelConditions(); + } else { + $valueIfFalse = null; } - $this->move(); - - $valueIfFalse = $this->doLevelConditions(); - if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'end' ) ) { + if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'end' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, [ @@ -256,12 +324,12 @@ class AFPTreeParser { } $condition = $this->doLevelBoolOps(); - if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '?' ) { + if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '?' ) { $position = $this->mPos; $this->move(); $valueIfTrue = $this->doLevelConditions(); - if ( !( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':' ) ) { + if ( !( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, [ @@ -292,7 +360,7 @@ class AFPTreeParser { protected function doLevelBoolOps() { $leftOperand = $this->doLevelCompares(); $ops = [ '&', '|', '^' ]; - while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { $op = $this->mCur->value; $position = $this->mPos; $this->move(); @@ -315,9 +383,16 @@ class AFPTreeParser { */ protected function doLevelCompares() { $leftOperand = $this->doLevelSumRels(); - $ops = [ '==', '===', '!=', '!==', '<', '>', '<=', '>=', '=' ]; - while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $equalityOps = [ '==', '===', '!=', '!==', '=' ]; + $orderOps = [ '<', '>', '<=', '>=' ]; + // Only allow either a single operation, or a combination of a single equalityOps and a single + // orderOps. This resembles what PHP does, and allows `a < b == c` while rejecting `a < b < c` + $allowedOps = array_merge( $equalityOps, $orderOps ); + while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $allowedOps ) ) { $op = $this->mCur->value; + $allowedOps = in_array( $op, $equalityOps ) ? + array_diff( $allowedOps, $equalityOps ) : + array_diff( $allowedOps, $orderOps ); $position = $this->mPos; $this->move(); $rightOperand = $this->doLevelSumRels(); @@ -338,7 +413,7 @@ class AFPTreeParser { protected function doLevelSumRels() { $leftOperand = $this->doLevelMulRels(); $ops = [ '+', '-' ]; - while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { $op = $this->mCur->value; $position = $this->mPos; $this->move(); @@ -360,7 +435,7 @@ class AFPTreeParser { protected function doLevelMulRels() { $leftOperand = $this->doLevelPow(); $ops = [ '*', '/', '%' ]; - while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { $op = $this->mCur->value; $position = $this->mPos; $this->move(); @@ -381,7 +456,7 @@ class AFPTreeParser { */ protected function doLevelPow() { $base = $this->doLevelBoolInvert(); - while ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '**' ) { + while ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '**' ) { $position = $this->mPos; $this->move(); $exponent = $this->doLevelBoolInvert(); @@ -396,7 +471,7 @@ class AFPTreeParser { * @return AFPTreeNode */ protected function doLevelBoolInvert() { - if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '!' ) { + if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '!' ) { $position = $this->mPos; $this->move(); $argument = $this->doLevelKeywordOperators(); @@ -414,8 +489,8 @@ class AFPTreeParser { protected function doLevelKeywordOperators() { $leftOperand = $this->doLevelUnarys(); $keyword = strtolower( $this->mCur->value ); - if ( $this->mCur->type == AFPToken::TKEYWORD && - isset( AbuseFilterParser::$mKeywords[$keyword] ) + if ( $this->mCur->type === AFPToken::TKEYWORD && + isset( AbuseFilterParser::KEYWORDS[$keyword] ) ) { $position = $this->mPos; $this->move(); @@ -438,7 +513,7 @@ class AFPTreeParser { */ protected function doLevelUnarys() { $op = $this->mCur->value; - if ( $this->mCur->type == AFPToken::TOP && ( $op == "+" || $op == "-" ) ) { + if ( $this->mCur->type === AFPToken::TOP && ( $op === "+" || $op === "-" ) ) { $position = $this->mPos; $this->move(); $argument = $this->doLevelArrayElements(); @@ -455,12 +530,12 @@ class AFPTreeParser { */ protected function doLevelArrayElements() { $array = $this->doLevelParenthesis(); - while ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) { + while ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) { $position = $this->mPos; $index = $this->doLevelSemicolon(); $array = new AFPTreeNode( AFPTreeNode::ARRAY_INDEX, [ $array, $index ], $position ); - if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) { + if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, [ ']', $this->mCur->type, $this->mCur->value ] ); } @@ -477,10 +552,22 @@ class AFPTreeParser { * @throws AFPUserVisibleException */ protected function doLevelParenthesis() { - if ( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == '(' ) { + if ( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === '(' ) { + $next = $this->getNextToken(); + if ( $next->type === AFPToken::TBRACE && $next->value === ')' ) { + // Empty parentheses are never allowed + throw new AFPUserVisibleException( + 'unexpectedtoken', + $this->mPos, + [ + $this->mCur->type, + $this->mCur->value + ] + ); + } $result = $this->doLevelSemicolon(); - if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == ')' ) ) { + if ( !( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === ')' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, @@ -502,13 +589,13 @@ class AFPTreeParser { * @throws AFPUserVisibleException */ protected function doLevelFunction() { - if ( $this->mCur->type == AFPToken::TID && - isset( AbuseFilterParser::$mFunctions[$this->mCur->value] ) + if ( $this->mCur->type === AFPToken::TID && + isset( AbuseFilterParser::FUNCTIONS[$this->mCur->value] ) ) { $func = $this->mCur->value; $position = $this->mPos; $this->move(); - if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != '(' ) { + if ( $this->mCur->type !== AFPToken::TBRACE || $this->mCur->value !== '(' ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, [ @@ -519,12 +606,47 @@ class AFPTreeParser { ); } + if ( ( $func === 'set' || $func === 'set_var' ) ) { + $state = $this->getState(); + $this->move(); + $next = $this->getNextToken(); + if ( + $this->mCur->type !== AFPToken::TSTRING || + ( + $next->type !== AFPToken::TCOMMA && + // Let this fail later, when checking parameters count + !( $next->type === AFPToken::TBRACE && $next->value === ')' ) + ) + ) { + throw new AFPUserVisibleException( 'variablevariable', $this->mPos, [] ); + } else { + $this->setState( $state ); + } + } + $args = []; - do { - $args[] = $this->doLevelSemicolon(); - } while ( $this->mCur->type == AFPToken::TCOMMA ); + $next = $this->getNextToken(); + if ( $next->type !== AFPToken::TBRACE || $next->value !== ')' ) { + do { + $thisArg = $this->doLevelSemicolon(); + if ( $thisArg === null && !$this->functionIsVariadic( $func ) ) { + throw new AFPUserVisibleException( + 'unexpectedtoken', + $this->mPos, + [ + $this->mCur->type, + $this->mCur->value + ] + ); + } elseif ( $thisArg !== null ) { + $args[] = $thisArg; + } + } while ( $this->mCur->type === AFPToken::TCOMMA ); + } else { + $this->move(); + } - if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) { + if ( $this->mCur->type !== AFPToken::TBRACE || $this->mCur->value !== ')' ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, [ @@ -534,6 +656,11 @@ class AFPTreeParser { ] ); } + // Giving too few arguments to a function is a pretty common error. If we check it here + // (as well as at runtime, for OCD), we can make checkSyntax only try to build the AST, as + // there would be way less runtime errors. Moreover, this check will also be performed inside + // skipped branches, e.g. the discarded if/else branch. + $this->checkArgCount( $args, $func ); $this->move(); array_unshift( $args, $func ); @@ -552,6 +679,8 @@ class AFPTreeParser { $tok = $this->mCur->value; switch ( $this->mCur->type ) { case AFPToken::TID: + $this->checkLogDeprecatedVar( strtolower( $tok ) ); + // Fallthrough intended case AFPToken::TSTRING: case AFPToken::TFLOAT: case AFPToken::TINT: @@ -570,20 +699,20 @@ class AFPTreeParser { ); /** @noinspection PhpMissingBreakStatementInspection */ case AFPToken::TSQUAREBRACKET: - if ( $this->mCur->value == '[' ) { + if ( $this->mCur->value === '[' ) { $array = []; while ( true ) { $this->move(); - if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) { break; } $array[] = $this->doLevelSet(); - if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) { break; } - if ( $this->mCur->type != AFPToken::TCOMMA ) { + if ( $this->mCur->type !== AFPToken::TCOMMA ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, @@ -609,6 +738,20 @@ class AFPTreeParser { } $this->move(); + // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable + // @phan-suppress-next-line PhanTypeMismatchReturnNullable Until phan can understand the switch return $result; } + + /** + * Given a variable name, check if the variable is deprecated. If it is, log the use. + * Do that here, and not every time the AST is eval'ed. This means less logging, but more + * performance. + * @param string $varname + */ + protected function checkLogDeprecatedVar( $varname ) { + if ( $this->keywordsManager->isVarDeprecated( $varname ) ) { + $this->logger->debug( "Deprecated variable $varname used in filter {$this->mFilter}." ); + } + } } diff --git a/AbuseFilter/includes/parser/AFPUserVisibleException.php b/AbuseFilter/includes/parser/AFPUserVisibleException.php index ab4b2264..0ffce7b1 100644 --- a/AbuseFilter/includes/parser/AFPUserVisibleException.php +++ b/AbuseFilter/includes/parser/AFPUserVisibleException.php @@ -20,9 +20,23 @@ class AFPUserVisibleException extends AFPException { $this->mPosition = $position; $this->mParams = $params; - // Exception message text for logs should be in English. - $msg = $this->getMessageObj()->inLanguage( 'en' )->useDatabase( false )->text(); - parent::__construct( $msg ); + parent::__construct( $exception_id ); + } + + /** + * Change the message of the exception to a localized version + */ + public function setLocalizedMessage() { + $this->message = $this->getMessageObj()->text(); + } + + /** + * Returns the error message in English for use in logs + * + * @return string + */ + public function getMessageForLogs() { + return $this->getMessageObj()->inLanguage( 'en' )->useDatabase( false )->text(); } /** @@ -39,6 +53,8 @@ class AFPUserVisibleException extends AFPException { // abusefilter-exception-overridebuiltin, abusefilter-exception-outofbounds // abusefilter-exception-notarray, abusefilter-exception-unclosedcomment // abusefilter-exception-invalidiprange, abusefilter-exception-disabledvar + // abusefilter-exception-variablevariable, abusefilter-exception-toomanyargs + // abusefilter-exception-negativeoffset return wfMessage( 'abusefilter-exception-' . $this->mExceptionID, $this->mPosition, ...$this->mParams diff --git a/AbuseFilter/includes/parser/AbuseFilterCachingParser.php b/AbuseFilter/includes/parser/AbuseFilterCachingParser.php index ef634fd4..b5bc0cb9 100644 --- a/AbuseFilter/includes/parser/AbuseFilterCachingParser.php +++ b/AbuseFilter/includes/parser/AbuseFilterCachingParser.php @@ -6,8 +6,15 @@ * * It currently inherits AbuseFilterParser in order to avoid code duplication. * In future, this code will replace current AbuseFilterParser entirely. + * + * @todo Override checkSyntax and make it only try to build the AST. That would mean faster results, + * and no need to mess with DUNDEFINED and the like. However, we must first try to reduce the + * amount of runtime-only exceptions, and try to detect them in the AFPTreeParser instead. + * Otherwise, people may be able to save a broken filter without the syntax check reporting that. */ class AbuseFilterCachingParser extends AbuseFilterParser { + private const CACHE_VERSION = 1; + /** * Return the generated version of the parser for cache invalidation * purposes. Automatically tracks list of all functions and invalidates the @@ -21,10 +28,11 @@ class AbuseFilterCachingParser extends AbuseFilterParser { } $versionKey = [ + self::CACHE_VERSION, AFPTreeParser::CACHE_VERSION, AbuseFilterTokenizer::CACHE_VERSION, - array_keys( AbuseFilterParser::$mFunctions ), - array_keys( AbuseFilterParser::$mKeywords ), + array_keys( AbuseFilterParser::FUNCTIONS ), + array_keys( AbuseFilterParser::KEYWORDS ), ]; $version = hash( 'sha256', serialize( $versionKey ) ); @@ -35,36 +43,64 @@ class AbuseFilterCachingParser extends AbuseFilterParser { * Resets the state of the parser */ public function resetState() { - $this->mVars = new AbuseFilterVariableHolder; + $this->mVariables = new AbuseFilterVariableHolder( $this->keywordsManager ); $this->mCur = new AFPToken(); + $this->mCondCount = 0; + $this->mAllowShort = true; } /** * @param string $code * @return AFPData */ - public function intEval( $code ) { - static $cache = null; - if ( !$cache ) { - $cache = ObjectCache::getLocalServerInstance( 'hash' ); + public function intEval( $code ) : AFPData { + $startTime = microtime( true ); + $tree = $this->getTree( $code ); + + $res = $this->evalTree( $tree ); + + if ( $res->getType() === AFPData::DUNDEFINED ) { + $res = new AFPData( AFPData::DBOOL, false ); } + $this->statsd->timing( 'abusefilter_cachingParser_full', microtime( true ) - $startTime ); + return $res; + } - $tree = $cache->getWithSetCallback( - $cache->makeGlobalKey( + /** + * @param string $code + * @return AFPSyntaxTree + */ + private function getTree( $code ) : AFPSyntaxTree { + return $this->cache->getWithSetCallback( + $this->cache->makeGlobalKey( __CLASS__, self::getCacheVersion(), hash( 'sha256', $code ) ), - $cache::TTL_DAY, + BagOStuff::TTL_DAY, function () use ( $code ) { - $parser = new AFPTreeParser(); - return $parser->parse( $code ) ?: false; + $parser = new AFPTreeParser( $this->cache, $this->logger, $this->statsd, $this->keywordsManager ); + $parser->setFilter( $this->mFilter ); + return $parser->parse( $code ); } ); + } + + /** + * @param AFPSyntaxTree $tree + * @return AFPData + */ + private function evalTree( AFPSyntaxTree $tree ) : AFPData { + $startTime = microtime( true ); + $root = $tree->getRoot(); + + if ( !$root ) { + return new AFPData( AFPData::DNULL ); + } - return $tree - ? $this->evalNode( $tree ) - : new AFPData( AFPData::DNULL, null ); + $ret = $this->evalNode( $root ); + $this->statsd->timing( 'abusefilter_cachingParser_eval', microtime( true ) - $startTime ); + return $ret; } /** @@ -76,7 +112,7 @@ class AbuseFilterCachingParser extends AbuseFilterParser { * @throws AFPUserVisibleException * @throws MWException */ - public function evalNode( AFPTreeNode $node ) { + private 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. @@ -103,55 +139,56 @@ class AbuseFilterCachingParser extends AbuseFilterParser { case "false": return new AFPData( AFPData::DBOOL, false ); case "null": - return new AFPData(); + return new AFPData( AFPData::DNULL ); } // Fallthrough intended default: + // @codeCoverageIgnoreStart throw new AFPException( "Unknown token provided in the ATOM node" ); + // @codeCoverageIgnoreEnd } case AFPTreeNode::ARRAY_DEFINITION: - $items = array_map( [ $this, 'evalNode' ], $node->children ); + $items = []; + // Foreach is usually faster than array_map + // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here + foreach ( $node->children as $el ) { + $items[] = $this->evalNode( $el ); + } return new AFPData( AFPData::DARRAY, $items ); case AFPTreeNode::FUNCTION_CALL: $functionName = $node->children[0]; $args = array_slice( $node->children, 1 ); - $func = self::$mFunctions[$functionName]; - $dataArgs = array_map( [ $this, 'evalNode' ], $args ); - - /** @noinspection PhpToStringImplementationInspection */ - $funcHash = md5( $func . serialize( $dataArgs ) ); - - if ( isset( self::$funcCache[$funcHash] ) && - !in_array( $func, self::$ActiveFunctions ) - ) { - $result = self::$funcCache[$funcHash]; - } else { - AbuseFilter::triggerLimiter(); - $result = self::$funcCache[$funcHash] = $this->$func( $dataArgs ); - } - - if ( count( self::$funcCache ) > 1000 ) { - self::$funcCache = []; + $dataArgs = []; + // Foreach is usually faster than array_map + foreach ( $args as $arg ) { + $dataArgs[] = $this->evalNode( $arg ); } - return $result; - + return $this->callFunc( $functionName, $dataArgs ); case AFPTreeNode::ARRAY_INDEX: list( $array, $offset ) = $node->children; $array = $this->evalNode( $array ); - if ( $array->type != AFPData::DARRAY ) { - throw new AFPUserVisibleException( 'notarray', $node->position, [] ); + // Note: we MUST evaluate the offset to ensure it is valid, regardless + // of $array! + $offset = $this->evalNode( $offset )->toInt(); + + if ( $array->getType() === AFPData::DUNDEFINED ) { + return new AFPData( AFPData::DUNDEFINED ); } - $offset = $this->evalNode( $offset )->toInt(); + if ( $array->getType() !== AFPData::DARRAY ) { + throw new AFPUserVisibleException( 'notarray', $node->position, [] ); + } $array = $array->toArray(); if ( count( $array ) <= $offset ) { throw new AFPUserVisibleException( 'outofbounds', $node->position, [ $offset, count( $array ) ] ); + } elseif ( $offset < 0 ) { + throw new AFPUserVisibleException( 'negativeindex', $node->position, [ $offset ] ); } return $array[$offset]; @@ -159,38 +196,33 @@ class AbuseFilterCachingParser extends AbuseFilterParser { case AFPTreeNode::UNARY: list( $operation, $argument ) = $node->children; $argument = $this->evalNode( $argument ); - if ( $operation == '-' ) { - return AFPData::unaryMinus( $argument ); + if ( $operation === '-' ) { + return $argument->unaryMinus(); } return $argument; case AFPTreeNode::KEYWORD_OPERATOR: list( $keyword, $leftOperand, $rightOperand ) = $node->children; - $func = self::$mKeywords[$keyword]; $leftOperand = $this->evalNode( $leftOperand ); $rightOperand = $this->evalNode( $rightOperand ); - AbuseFilter::triggerLimiter(); - $result = AFPData::$func( $leftOperand, $rightOperand, $node->position ); - - return $result; + return $this->callKeyword( $keyword, $leftOperand, $rightOperand ); case AFPTreeNode::BOOL_INVERT: list( $argument ) = $node->children; $argument = $this->evalNode( $argument ); - return AFPData::boolInvert( $argument ); + return $argument->boolInvert(); case AFPTreeNode::POW: list( $base, $exponent ) = $node->children; $base = $this->evalNode( $base ); $exponent = $this->evalNode( $exponent ); - return AFPData::pow( $base, $exponent ); + return $base->pow( $exponent ); case AFPTreeNode::MUL_REL: list( $op, $leftOperand, $rightOperand ) = $node->children; $leftOperand = $this->evalNode( $leftOperand ); $rightOperand = $this->evalNode( $rightOperand ); - // FIXME - return AFPData::mulRel( $leftOperand, $rightOperand, $op, 0 ); + return $leftOperand->mulRel( $rightOperand, $op, $node->position ); case AFPTreeNode::SUM_REL: list( $op, $leftOperand, $rightOperand ) = $node->children; @@ -198,38 +230,51 @@ class AbuseFilterCachingParser extends AbuseFilterParser { $rightOperand = $this->evalNode( $rightOperand ); switch ( $op ) { case '+': - return AFPData::sum( $leftOperand, $rightOperand ); + return $leftOperand->sum( $rightOperand ); case '-': - return AFPData::sub( $leftOperand, $rightOperand ); + return $leftOperand->sub( $rightOperand ); default: + // @codeCoverageIgnoreStart throw new AFPException( "Unknown sum-related operator: {$op}" ); + // @codeCoverageIgnoreEnd } case AFPTreeNode::COMPARE: list( $op, $leftOperand, $rightOperand ) = $node->children; $leftOperand = $this->evalNode( $leftOperand ); $rightOperand = $this->evalNode( $rightOperand ); - AbuseFilter::triggerLimiter(); - return AFPData::compareOp( $leftOperand, $rightOperand, $op ); + $this->raiseCondCount(); + return $leftOperand->compareOp( $rightOperand, $op ); case AFPTreeNode::LOGIC: list( $op, $leftOperand, $rightOperand ) = $node->children; $leftOperand = $this->evalNode( $leftOperand ); - $value = $leftOperand->toBool(); + $value = $leftOperand->getType() === AFPData::DUNDEFINED ? false : $leftOperand->toBool(); // Short-circuit. - if ( ( !$value && $op == '&' ) || ( $value && $op == '|' ) ) { + if ( ( !$value && $op === '&' ) || ( $value && $op === '|' ) ) { + if ( $rightOperand instanceof AFPTreeNode ) { + $this->maybeDiscardNode( $rightOperand ); + } return $leftOperand; } $rightOperand = $this->evalNode( $rightOperand ); - return AFPData::boolOp( $leftOperand, $rightOperand, $op ); + return $leftOperand->boolOp( $rightOperand, $op ); case AFPTreeNode::CONDITIONAL: list( $condition, $valueIfTrue, $valueIfFalse ) = $node->children; $condition = $this->evalNode( $condition ); - if ( $condition->toBool() ) { + $isTrue = $condition->getType() === AFPData::DUNDEFINED ? false : $condition->toBool(); + if ( $isTrue ) { + if ( $valueIfFalse !== null ) { + $this->maybeDiscardNode( $valueIfFalse ); + } return $this->evalNode( $valueIfTrue ); } else { - return $this->evalNode( $valueIfFalse ); + $this->maybeDiscardNode( $valueIfTrue ); + return $valueIfFalse !== null + ? $this->evalNode( $valueIfFalse ) + // We assume null as default if the else is missing + : new AFPData( AFPData::DNULL ); } case AFPTreeNode::ASSIGNMENT: @@ -241,45 +286,120 @@ class AbuseFilterCachingParser extends AbuseFilterParser { case AFPTreeNode::INDEX_ASSIGNMENT: list( $varName, $offset, $value ) = $node->children; - $array = $this->mVars->getVar( $varName ); - if ( $array->type != AFPData::DARRAY ) { - throw new AFPUserVisibleException( 'notarray', $node->position, [] ); + if ( $this->isReservedIdentifier( $varName ) ) { + throw new AFPUserVisibleException( 'overridebuiltin', $node->position, [ $varName ] ); } + $array = $this->getVarValue( $varName ); - $offset = $this->evalNode( $offset )->toInt(); - - $array = $array->toArray(); - if ( count( $array ) <= $offset ) { - throw new AFPUserVisibleException( 'outofbounds', $node->position, - [ $offset, count( $array ) ] ); + $value = $this->evalNode( $value ); + if ( $array->getType() !== AFPData::DUNDEFINED ) { + // If it's a DUNDEFINED, leave it as is + if ( $array->getType() !== AFPData::DARRAY ) { + throw new AFPUserVisibleException( 'notarray', $node->position, [] ); + } + + $offset = $this->evalNode( $offset )->toInt(); + + $array = $array->toArray(); + if ( count( $array ) <= $offset ) { + throw new AFPUserVisibleException( 'outofbounds', $node->position, + [ $offset, count( $array ) ] ); + } elseif ( $offset < 0 ) { + throw new AFPUserVisibleException( 'negativeindex', $node->position, [ $offset ] ); + } + + $array[$offset] = $value; + $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) ); } - $array[$offset] = $this->evalNode( $value ); - $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) ); return $value; case AFPTreeNode::ARRAY_APPEND: list( $varName, $value ) = $node->children; - $array = $this->mVars->getVar( $varName ); - if ( $array->type != AFPData::DARRAY ) { - throw new AFPUserVisibleException( 'notarray', $node->position, [] ); + if ( $this->isReservedIdentifier( $varName ) ) { + throw new AFPUserVisibleException( 'overridebuiltin', $node->position, [ $varName ] ); } - $array = $array->toArray(); - $array[] = $this->evalNode( $value ); - $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) ); + $array = $this->getVarValue( $varName ); + $value = $this->evalNode( $value ); + if ( $array->getType() !== AFPData::DUNDEFINED ) { + // If it's a DUNDEFINED, leave it as is + if ( $array->getType() !== AFPData::DARRAY ) { + throw new AFPUserVisibleException( 'notarray', $node->position, [] ); + } + + $array = $array->toArray(); + $array[] = $value; + $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) ); + } return $value; case AFPTreeNode::SEMICOLON: $lastValue = null; + // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here foreach ( $node->children as $statement ) { $lastValue = $this->evalNode( $statement ); } + // @phan-suppress-next-next-line PhanTypeMismatchReturnNullable Can never be null because + // empty statements are discarded in AFPTreeParser return $lastValue; default: + // @codeCoverageIgnoreStart throw new AFPException( "Unknown node type passed: {$node->type}" ); + // @codeCoverageIgnoreEnd } } + + /** + * Given a node that we don't need to evaluate, decide what to do with it. The nodes passed in + * will usually be discarded by short-circuit evaluation. If we allow it, then we just hoist + * the variables assigned in any descendant of the node. Otherwise, we fully evaluate the node. + * + * @param AFPTreeNode $node + */ + private function maybeDiscardNode( AFPTreeNode $node ) { + if ( $this->mAllowShort ) { + $this->discardWithHoisting( $node ); + } else { + $this->evalNode( $node ); + } + } + + /** + * Intended to be used for short-circuit as a solution for T214674. + * Given a node, check it and its children; if there are assignments of non-existing variables, + * hoist them. In case of index assignment or array append, the old value is always erased and + * overwritten with a DUNDEFINED. This is used to allow stuff like: + * false & ( var := 'foo' ); var == 2 + * or + * if ( false ) then ( var := 'foo' ) else ( 1 ) end; var == 2 + * where `false` is something evaluated as false at runtime. + * + * @note This method doesn't check whether the variable exists in case of index assignments. + * Hence, in `false & (nonexistent[] := 2)`, `nonexistent` would be hoisted without errors. + * However, that would by caught by checkSyntax, so we can avoid checking here: we'd need + * way more context than we currently have. + * + * @param AFPTreeNode $node + */ + private function discardWithHoisting( AFPTreeNode $node ) { + foreach ( $node->getInnerAssignments() as $name ) { + if ( + !$this->mVariables->varIsSet( $name ) || + $this->mVariables->getVar( $name )->getType() === AFPData::DARRAY + ) { + $this->setUserVariable( $name, new AFPData( AFPData::DUNDEFINED ) ); + } + } + } + + /** + * @inheritDoc + * This parser should not log, because that's handled in AFPTreeParser + */ + protected function logsDeprecatedVars() { + return false; + } } diff --git a/AbuseFilter/includes/parser/AbuseFilterParser.php b/AbuseFilter/includes/parser/AbuseFilterParser.php index 89ddea0f..aed48007 100644 --- a/AbuseFilter/includes/parser/AbuseFilterParser.php +++ b/AbuseFilter/includes/parser/AbuseFilterParser.php @@ -1,58 +1,78 @@ <?php +use MediaWiki\Extension\AbuseFilter\KeywordsManager; +use Psr\Log\LoggerInterface; +use Wikimedia\AtEase\AtEase; use Wikimedia\Equivset\Equivset; -use MediaWiki\Logger\LoggerFactory; +use Wikimedia\IPUtils; -class AbuseFilterParser { - public $mCode, $mTokens, $mPos, $mShortCircuit, $mAllowShort, $mLen; - /** @var AFPToken The current token */ +class AbuseFilterParser extends AFPTransitionBase { + /** + * @var array[] Contains the AFPTokens for the code being parsed + */ + public $mTokens; + /** + * @var bool Are we inside a short circuit evaluation? + */ + public $mShortCircuit; + /** + * @var bool Are we allowed to use short-circuit evaluation? + */ + public $mAllowShort; + /** + * @var AFPToken The current token + */ public $mCur; - /** * @var AbuseFilterVariableHolder */ - public $mVars; - - // length,lcase,ucase,ccnorm,rmdoubles,specialratio,rmspecials,norm,count,get_matches - public static $mFunctions = [ - 'lcase' => 'funcLc', - 'ucase' => 'funcUc', - 'length' => 'funcLen', - 'string' => 'castString', - 'int' => 'castInt', - 'float' => 'castFloat', - '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', - 'str_replace' => 'funcStrReplace', - 'rescape' => 'funcStrRegexEscape', - 'set' => 'funcSetVar', - 'set_var' => 'funcSetVar', - 'sanitize' => 'funcSanitize', - ]; + public $mVariables; + + /** + * @var int The current amount of conditions being consumed + */ + protected $mCondCount; + + /** + * @var bool Whether the condition limit is enabled. + */ + protected $condLimitEnabled = true; + + /** + * @var string|null The ID of the filter being parsed, if available. Can also be "global-$ID" + */ + protected $mFilter; + /** + * @var bool Whether we can allow retrieving _builtin_ variables not included in $this->mVariables + */ + protected $allowMissingVariables = false; + + /** + * @var BagOStuff Used to cache the AST (in CachingParser) and the tokens + */ + protected $cache; + /** + * @var LoggerInterface Used for debugging + */ + protected $logger; + /** + * @var Language Content language, used for language-dependent functions + */ + protected $contLang; + /** + * @var IBufferingStatsdDataFactory + */ + protected $statsd; + + /** @var KeywordsManager */ + protected $keywordsManager; // Functions that affect parser state, and shouldn't be cached. - public static $ActiveFunctions = [ + public const ACTIVE_FUNCTIONS = [ 'funcSetVar', ]; - public static $mKeywords = [ + public const KEYWORDS = [ 'in' => 'keywordIn', 'like' => 'keywordLike', 'matches' => 'keywordLike', @@ -62,7 +82,10 @@ class AbuseFilterParser { 'regex' => 'keywordRegex', ]; - public static $funcCache = []; + /** + * @var array Cached results of functions + */ + protected $funcCache = []; /** * @var Equivset @@ -72,12 +95,93 @@ class AbuseFilterParser { /** * Create a new instance * + * @param Language $contLang Content language, used for language-dependent function + * @param BagOStuff $cache Used to cache the AST (in CachingParser) and the tokens + * @param LoggerInterface $logger Used for debugging + * @param KeywordsManager $keywordsManager * @param AbuseFilterVariableHolder|null $vars */ - public function __construct( $vars = null ) { + public function __construct( + Language $contLang, + BagOStuff $cache, + LoggerInterface $logger, + KeywordsManager $keywordsManager, + AbuseFilterVariableHolder $vars = null + ) { + $this->contLang = $contLang; + $this->cache = $cache; + $this->logger = $logger; + $this->statsd = new NullStatsdDataFactory; + $this->keywordsManager = $keywordsManager; $this->resetState(); - if ( $vars instanceof AbuseFilterVariableHolder ) { - $this->mVars = $vars; + if ( $vars ) { + $this->mVariables = $vars; + } + $this->mVariables->setLogger( $logger ); + } + + /** + * @param string $filter + */ + public function setFilter( $filter ) { + $this->mFilter = $filter; + } + + /** + * @param BagOStuff $cache + */ + public function setCache( BagOStuff $cache ) { + $this->cache = $cache; + } + + /** + * @param LoggerInterface $logger + */ + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * @param IBufferingStatsdDataFactory $statsd + */ + public function setStatsd( IBufferingStatsdDataFactory $statsd ) { + $this->statsd = $statsd; + } + + /** + * @return int + */ + public function getCondCount() { + return $this->mCondCount; + } + + /** + * Reset the conditions counter + */ + public function resetCondCount() { + $this->mCondCount = 0; + } + + /** + * For use in batch scripts and the like + * + * @param bool $enable True to enable the limit, false to disable it + */ + public function toggleConditionLimit( $enable ) { + $this->condLimitEnabled = $enable; + } + + /** + * @param int $val The amount to increase the conditions count of. + * @throws MWException + */ + protected function raiseCondCount( $val = 1 ) { + global $wgAbuseFilterConditionLimit; + + $this->mCondCount += $val; + + if ( $this->condLimitEnabled && $this->mCondCount > $wgAbuseFilterConditionLimit ) { + throw new MWException( 'Condition limit reached.' ); } } @@ -85,35 +189,101 @@ class AbuseFilterParser { * Resets the state of the parser. */ public function resetState() { - $this->mCode = ''; $this->mTokens = []; - $this->mVars = new AbuseFilterVariableHolder; + $this->mVariables = new AbuseFilterVariableHolder( $this->keywordsManager ); $this->mPos = 0; $this->mShortCircuit = false; $this->mAllowShort = true; + $this->mCondCount = 0; + $this->mFilter = null; + } + + /** + * Clears the array of cached function results + */ + public function clearFuncCache() { + $this->funcCache = []; } /** + * @param AbuseFilterVariableHolder $vars + */ + public function setVariables( AbuseFilterVariableHolder $vars ) { + $this->mVariables = $vars; + } + + /** + * Check the syntax of $filter, throwing an exception if invalid * @param string $filter - * @return true|array True when successful, otherwise a two-element array with exception message - * and character position of the syntax error + * @return true When successful + * @throws AFPUserVisibleException */ - public function checkSyntax( $filter ) { + public function checkSyntaxThrow( string $filter ) { + $this->allowMissingVariables = true; $origAS = $this->mAllowShort; try { $this->mAllowShort = false; - $this->parse( $filter ); - } catch ( AFPUserVisibleException $excep ) { + $this->intEval( $filter ); + } finally { $this->mAllowShort = $origAS; - - return [ $excep->getMessageObj()->text(), $excep->mPosition ]; + $this->allowMissingVariables = false; } - $this->mAllowShort = $origAS; return true; } /** + * Check the syntax of $filter, without throwing + * + * @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( string $filter ) { + try { + $res = $this->checkSyntaxThrow( $filter ); + } catch ( AFPUserVisibleException $excep ) { + $res = [ $excep->getMessageObj()->text(), $excep->mPosition ]; + } + return $res; + } + + /** + * This is the main entry point. It checks the given conditions and returns whether + * they match. In case of bad syntax, this is always logged, and $ignoreError can + * be used to determine whether this method should throw. + * + * @param string $conds + * @param bool $ignoreError + * @param string|null $filter The ID of the filter being parsed + * @return bool + * @throws Exception + */ + public function checkConditions( string $conds, $ignoreError = true, $filter = null ) : bool { + try { + $result = $this->parse( $conds ); + } catch ( Exception $excep ) { + $result = false; + + if ( $excep instanceof AFPUserVisibleException ) { + $msg = $excep->getMessageForLogs(); + $excep->setLocalizedMessage(); + } else { + $msg = $excep->getMessage(); + } + + $extraInfo = $filter !== null ? " for filter $filter" : ''; + $this->logger->warning( "AbuseFilter parser error$extraInfo: $msg" ); + + if ( !$ignoreError ) { + throw $excep; + } + } + + return $result; + } + + /** * Move to the next token */ protected function move() { @@ -121,6 +291,16 @@ class AbuseFilterParser { } /** + * Get the next token. This is similar to move() but doesn't change class members, + * allowing to look ahead without rolling back the state. + * + * @return AFPToken + */ + protected function getNextToken() { + return $this->mTokens[$this->mPos][0]; + } + + /** * getState() function allows parser state to be rollbacked to several tokens back * @return AFPParserState */ @@ -142,17 +322,52 @@ class AbuseFilterParser { */ protected function skipOverBraces() { $braces = 1; - while ( $this->mCur->type != AFPToken::TNONE && $braces > 0 ) { + while ( $this->mCur->type !== AFPToken::TNONE && $braces > 0 ) { $this->move(); - if ( $this->mCur->type == AFPToken::TBRACE ) { - if ( $this->mCur->value == '(' ) { + if ( $this->mCur->type === AFPToken::TBRACE ) { + if ( $this->mCur->value === '(' ) { $braces++; - } elseif ( $this->mCur->value == ')' ) { + } elseif ( $this->mCur->value === ')' ) { $braces--; } + } elseif ( $this->mCur->type === AFPToken::TID ) { + // T214674, define non-existing variables. @see docs of + // AbuseFilterCachingParser::discardWithHoisting for a detailed explanation of this branch + $next = $this->getNextToken(); + if ( + in_array( $this->mCur->value, [ 'set', 'set_var' ] ) && + $next->type === AFPToken::TBRACE && $next->value === '(' + ) { + // This is for setter functions. + $this->move(); + $braces++; + $next = $this->getNextToken(); + if ( $next->type === AFPToken::TSTRING ) { + if ( !$this->mVariables->varIsSet( $next->value ) ) { + $this->setUserVariable( $next->value, new AFPData( AFPData::DUNDEFINED ) ); + } + } + } else { + // Simple assignment with := + $varname = $this->mCur->value; + $next = $this->getNextToken(); + if ( $next->type === AFPToken::TOP && $next->value === ':=' ) { + if ( !$this->mVariables->varIsSet( $varname ) ) { + $this->setUserVariable( $varname, new AFPData( AFPData::DUNDEFINED ) ); + } + } elseif ( $next->type === AFPToken::TSQUAREBRACKET && $next->value === '[' ) { + if ( !$this->mVariables->varIsSet( $varname ) ) { + throw new AFPUserVisibleException( 'unrecognisedvar', + $next->pos, + [ $varname ] + ); + } + $this->setUserVariable( $varname, new AFPData( AFPData::DUNDEFINED ) ); + } + } } } - if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == ')' ) ) { + if ( !( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === ')' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ ')' ] ); } } @@ -170,7 +385,7 @@ class AbuseFilterParser { * @return string */ public function evaluateExpression( $filter ) { - return $this->intEval( $filter )->toString(); + return $this->intEval( $filter )->toNative(); } /** @@ -178,16 +393,21 @@ class AbuseFilterParser { * @return AFPData */ public function intEval( $code ) { + $startTime = microtime( true ); // Reset all class members to their default value - $this->mCode = $code; - $this->mTokens = AbuseFilterTokenizer::tokenize( $code ); + $tokenizer = new AbuseFilterTokenizer( $this->cache, $this->logger ); + $this->mTokens = $tokenizer->getTokens( $code ); $this->mPos = 0; - $this->mLen = strlen( $code ); $this->mShortCircuit = false; - $result = new AFPData(); + $result = new AFPData( AFPData::DEMPTY ); $this->doLevelEntry( $result ); + if ( $result->getType() === AFPData::DUNDEFINED ) { + $result = new AFPData( AFPData::DBOOL, false ); + } + + $this->statsd->timing( 'abusefilter_oldparser_full', microtime( true ) - $startTime ); return $result; } @@ -202,7 +422,7 @@ class AbuseFilterParser { protected function doLevelEntry( &$result ) { $this->doLevelSemicolon( $result ); - if ( $this->mCur->type != AFPToken::TNONE ) { + if ( $this->mCur->type !== AFPToken::TNONE ) { throw new AFPUserVisibleException( 'unexpectedatend', $this->mCur->pos, [ $this->mCur->type ] @@ -218,10 +438,10 @@ class AbuseFilterParser { protected function doLevelSemicolon( &$result ) { do { $this->move(); - if ( $this->mCur->type != AFPToken::TSTATEMENTSEPARATOR ) { + if ( $this->mCur->type !== AFPToken::TSTATEMENTSEPARATOR ) { $this->doLevelSet( $result ); } - } while ( $this->mCur->type == AFPToken::TSTATEMENTSEPARATOR ); + } while ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR ); } /** @@ -231,57 +451,76 @@ class AbuseFilterParser { * @throws AFPUserVisibleException */ protected function doLevelSet( &$result ) { - if ( $this->mCur->type == AFPToken::TID ) { + if ( $this->mCur->type === AFPToken::TID ) { $varname = $this->mCur->value; $prev = $this->getState(); $this->move(); - if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) { + if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) { $this->move(); + $checkEmpty = $result->getType() === AFPData::DEMPTY; $this->doLevelSet( $result ); + if ( $checkEmpty && $result->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'var assignment' ); + } $this->setUserVariable( $varname, $result ); return; - } elseif ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) { - if ( !$this->mVars->varIsSet( $varname ) ) { - throw new AFPUserVisibleException( 'unrecognisedvar', - $this->mCur->pos, - [ $varname ] - ); - } - $array = $this->mVars->getVar( $varname ); - if ( $array->type != AFPData::DARRAY ) { + } elseif ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) { + // We allow builtin variables to both check for override (e.g. added_lines[] :='x') + // and for T198531 + $array = $this->getVarValue( $varname ); + if ( + $array->getType() !== AFPData::DARRAY && $array->getType() !== AFPData::DUNDEFINED + // NOTE: we cannot throw for overridebuiltin yet, in case this turns out not to be an assignment. + && !$this->keywordsManager->varExists( $varname ) + ) { throw new AFPUserVisibleException( 'notarray', $this->mCur->pos, [] ); } - $array = $array->toArray(); + $this->move(); - if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) { $idx = 'new'; } else { $this->setState( $prev ); $this->move(); - $idx = new AFPData(); + $idx = new AFPData( AFPData::DEMPTY ); $this->doLevelSemicolon( $idx ); $idx = $idx->toInt(); - if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) { + if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ ']', $this->mCur->type, $this->mCur->value ] ); } - if ( count( $array ) <= $idx ) { - throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos, - [ $idx, count( $result->data ) ] ); + if ( $array->getType() === AFPData::DARRAY ) { + if ( count( $array->toArray() ) <= $idx ) { + throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos, + [ $idx, count( $array->getData() ) ] ); + } elseif ( $idx < 0 ) { + throw new AFPUserVisibleException( 'negativeindex', $this->mCur->pos, [ $idx ] ); + } } } $this->move(); - if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) { + if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) { + if ( $this->isReservedIdentifier( $varname ) ) { + // Ideally we should've aborted before trying to parse the index + throw new AFPUserVisibleException( 'overridebuiltin', $this->mCur->pos, [ $varname ] ); + } $this->move(); + if ( $this->mCur->type === AFPToken::TNONE ) { + $this->logEmptyOperand( 'array assignment' ); + } $this->doLevelSet( $result ); - if ( $idx === 'new' ) { - $array[] = $result; - } else { - $array[$idx] = $result; + if ( $array->getType() === AFPData::DARRAY ) { + // If it's a DUNDEFINED, leave it as is + $array = $array->toArray(); + if ( $idx === 'new' ) { + $array[] = $result; + } else { + $array[$idx] = $result; + } + $this->setUserVariable( $varname, new AFPData( AFPData::DARRAY, $array ) ); } - $this->setUserVariable( $varname, new AFPData( AFPData::DARRAY, $array ) ); return; } else { @@ -299,13 +538,14 @@ class AbuseFilterParser { * * @param AFPData &$result * @throws AFPUserVisibleException + * @suppress PhanPossiblyUndeclaredVariable */ protected function doLevelConditions( &$result ) { - if ( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'if' ) { + if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'if' ) { $this->move(); $this->doLevelBoolOps( $result ); - if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'then' ) ) { + if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'then' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ @@ -317,42 +557,35 @@ class AbuseFilterParser { } $this->move(); - $r1 = new AFPData(); - $r2 = new AFPData(); + $r1 = new AFPData( AFPData::DEMPTY ); + $r2 = new AFPData( AFPData::DEMPTY ); - $isTrue = $result->toBool(); + $isTrue = $result->getType() === AFPData::DUNDEFINED ? false : $result->toBool(); if ( !$isTrue ) { - $scOrig = $this->mShortCircuit; - $this->mShortCircuit = $this->mAllowShort; + $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true ); } $this->doLevelConditions( $r1 ); if ( !$isTrue ) { $this->mShortCircuit = $scOrig; } - if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'else' ) ) { - throw new AFPUserVisibleException( 'expectednotfound', - $this->mCur->pos, - [ - 'else', - $this->mCur->type, - $this->mCur->value - ] - ); - } - $this->move(); + if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'else' ) { + $this->move(); - if ( $isTrue ) { - $scOrig = $this->mShortCircuit; - $this->mShortCircuit = $this->mAllowShort; - } - $this->doLevelConditions( $r2 ); - if ( $isTrue ) { - $this->mShortCircuit = $scOrig; + if ( $isTrue ) { + $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true ); + } + $this->doLevelConditions( $r2 ); + if ( $isTrue ) { + $this->mShortCircuit = $scOrig; + } + } else { + // DNULL is assumed as default in case of a missing else + $r2 = new AFPData( AFPData::DNULL ); } - if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'end' ) ) { + if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'end' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ @@ -364,30 +597,30 @@ class AbuseFilterParser { } $this->move(); - if ( $result->toBool() ) { + $isTrue = $result->getType() === AFPData::DUNDEFINED ? false : $result->toBool(); + if ( $isTrue ) { $result = $r1; } else { $result = $r2; } } else { $this->doLevelBoolOps( $result ); - if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '?' ) { + if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '?' ) { $this->move(); - $r1 = new AFPData(); - $r2 = new AFPData(); + $r1 = new AFPData( AFPData::DEMPTY ); + $r2 = new AFPData( AFPData::DEMPTY ); - $isTrue = $result->toBool(); + $isTrue = $result->getType() === AFPData::DUNDEFINED ? false : $result->toBool(); if ( !$isTrue ) { - $scOrig = $this->mShortCircuit; - $this->mShortCircuit = $this->mAllowShort; + $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true ); } $this->doLevelConditions( $r1 ); if ( !$isTrue ) { $this->mShortCircuit = $scOrig; } - if ( !( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':' ) ) { + if ( !( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ @@ -400,10 +633,12 @@ class AbuseFilterParser { $this->move(); if ( $isTrue ) { - $scOrig = $this->mShortCircuit; - $this->mShortCircuit = $this->mAllowShort; + $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true ); } $this->doLevelConditions( $r2 ); + if ( $r2->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'ternary else' ); + } if ( $isTrue ) { $this->mShortCircuit = $scOrig; } @@ -425,24 +660,29 @@ class AbuseFilterParser { protected function doLevelBoolOps( &$result ) { $this->doLevelCompares( $result ); $ops = [ '&', '|', '^' ]; - while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { $op = $this->mCur->value; $this->move(); - $r2 = new AFPData(); + $r2 = new AFPData( AFPData::DEMPTY ); + $curVal = $result->getType() === AFPData::DUNDEFINED ? false : $result->toBool(); // 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; + if ( ( $op === '&' && !$curVal ) || ( $op === '|' && $curVal ) ) { + $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true ); $this->doLevelCompares( $r2 ); - $this->mShortCircuit = $orig; - $result = new AFPData( AFPData::DBOOL, $result->toBool() ); + if ( $r2->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'bool operand' ); + } + $this->mShortCircuit = $scOrig; + $result = new AFPData( AFPData::DBOOL, $curVal ); continue; } $this->doLevelCompares( $r2 ); - - $result = AFPData::boolOp( $result, $r2, $op ); + if ( $r2->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'bool operand' ); + } + $result = $result->boolOp( $r2, $op ); } } @@ -453,18 +693,28 @@ class AbuseFilterParser { */ protected function doLevelCompares( &$result ) { $this->doLevelSumRels( $result ); - $ops = [ '==', '===', '!=', '!==', '<', '>', '<=', '>=', '=' ]; - while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $equalityOps = [ '==', '===', '!=', '!==', '=' ]; + $orderOps = [ '<', '>', '<=', '>=' ]; + // Only allow either a single operation, or a combination of a single equalityOps and a single + // orderOps. This resembles what PHP does, and allows `a < b == c` while rejecting `a < b < c` + $allowedOps = array_merge( $equalityOps, $orderOps ); + while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $allowedOps ) ) { + $allowedOps = in_array( $this->mCur->value, $equalityOps ) ? + array_diff( $allowedOps, $equalityOps ) : + array_diff( $allowedOps, $orderOps ); $op = $this->mCur->value; $this->move(); - $r2 = new AFPData(); + $r2 = new AFPData( AFPData::DEMPTY ); $this->doLevelSumRels( $r2 ); + if ( $r2->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'compare operand' ); + } if ( $this->mShortCircuit ) { // The result doesn't matter. - break; + continue; } - AbuseFilter::triggerLimiter(); - $result = AFPData::compareOp( $result, $r2, $op ); + $this->raiseCondCount(); + $result = $result->compareOp( $r2, $op ); } } @@ -476,20 +726,23 @@ class AbuseFilterParser { protected function doLevelSumRels( &$result ) { $this->doLevelMulRels( $result ); $ops = [ '+', '-' ]; - while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { $op = $this->mCur->value; $this->move(); - $r2 = new AFPData(); + $r2 = new AFPData( AFPData::DEMPTY ); $this->doLevelMulRels( $r2 ); + if ( $r2->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'sum operand' ); + } if ( $this->mShortCircuit ) { // The result doesn't matter. - break; + continue; } - if ( $op == '+' ) { - $result = AFPData::sum( $result, $r2 ); + if ( $op === '+' ) { + $result = $result->sum( $r2 ); } - if ( $op == '-' ) { - $result = AFPData::sub( $result, $r2 ); + if ( $op === '-' ) { + $result = $result->sub( $r2 ); } } } @@ -502,16 +755,19 @@ class AbuseFilterParser { protected function doLevelMulRels( &$result ) { $this->doLevelPow( $result ); $ops = [ '*', '/', '%' ]; - while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { $op = $this->mCur->value; $this->move(); - $r2 = new AFPData(); + $r2 = new AFPData( AFPData::DEMPTY ); $this->doLevelPow( $r2 ); + if ( $r2->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'multiplication operand' ); + } if ( $this->mShortCircuit ) { // The result doesn't matter. - break; + continue; } - $result = AFPData::mulRel( $result, $r2, $op, $this->mCur->pos ); + $result = $result->mulRel( $r2, $op, $this->mCur->pos ); } } @@ -522,15 +778,18 @@ class AbuseFilterParser { */ protected function doLevelPow( &$result ) { $this->doLevelBoolInvert( $result ); - while ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '**' ) { + while ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '**' ) { $this->move(); - $expanent = new AFPData(); + $expanent = new AFPData( AFPData::DEMPTY ); $this->doLevelBoolInvert( $expanent ); + if ( $expanent->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'power operand' ); + } if ( $this->mShortCircuit ) { // The result doesn't matter. - break; + continue; } - $result = AFPData::pow( $result, $expanent ); + $result = $result->pow( $expanent ); } } @@ -540,14 +799,18 @@ class AbuseFilterParser { * @param AFPData &$result */ protected function doLevelBoolInvert( &$result ) { - if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '!' ) { + if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '!' ) { $this->move(); + $checkEmpty = $result->getType() === AFPData::DEMPTY; $this->doLevelSpecialWords( $result ); + if ( $checkEmpty && $result->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'bool inversion' ); + } if ( $this->mShortCircuit ) { // The result doesn't matter. return; } - $result = AFPData::boolInvert( $result ); + $result = $result->boolInvert(); } else { $this->doLevelSpecialWords( $result ); } @@ -561,22 +824,23 @@ class AbuseFilterParser { protected function doLevelSpecialWords( &$result ) { $this->doLevelUnarys( $result ); $keyword = strtolower( $this->mCur->value ); - if ( $this->mCur->type == AFPToken::TKEYWORD - && isset( self::$mKeywords[$keyword] ) + if ( $this->mCur->type === AFPToken::TKEYWORD + && isset( self::KEYWORDS[$keyword] ) ) { - $func = self::$mKeywords[$keyword]; $this->move(); - $r2 = new AFPData(); + $r2 = new AFPData( AFPData::DEMPTY ); $this->doLevelUnarys( $r2 ); + if ( $r2->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'keyword operand' ); + } + if ( $this->mShortCircuit ) { // The result doesn't matter. return; } - AbuseFilter::triggerLimiter(); - - $result = AFPData::$func( $result, $r2, $this->mCur->pos ); + $result = $this->callKeyword( $keyword, $result, $r2 ); } } @@ -587,15 +851,19 @@ class AbuseFilterParser { */ protected function doLevelUnarys( &$result ) { $op = $this->mCur->value; - if ( $this->mCur->type == AFPToken::TOP && ( $op == "+" || $op == "-" ) ) { + if ( $this->mCur->type === AFPToken::TOP && ( $op === "+" || $op === "-" ) ) { $this->move(); + $checkEmpty = $result->getType() === AFPData::DEMPTY; $this->doLevelArrayElements( $result ); + if ( $checkEmpty && $result->getType() === AFPData::DEMPTY ) { + $this->logEmptyOperand( 'unary operand' ); + } if ( $this->mShortCircuit ) { // The result doesn't matter. return; } - if ( $op == '-' ) { - $result = AFPData::unaryMinus( $result ); + if ( $op === '-' ) { + $result = $result->unaryMinus(); } } else { $this->doLevelArrayElements( $result ); @@ -610,20 +878,25 @@ class AbuseFilterParser { */ protected function doLevelArrayElements( &$result ) { $this->doLevelBraces( $result ); - while ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) { - $idx = new AFPData(); + while ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) { + $idx = new AFPData( AFPData::DEMPTY ); $this->doLevelSemicolon( $idx ); - if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) { + if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ ']', $this->mCur->type, $this->mCur->value ] ); } $idx = $idx->toInt(); - if ( $result->type == AFPData::DARRAY ) { - if ( count( $result->data ) <= $idx ) { + if ( $result->getType() === AFPData::DARRAY ) { + if ( count( $result->getData() ) <= $idx ) { throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos, - [ $idx, count( $result->data ) ] ); + [ $idx, count( $result->getData() ) ] ); + } elseif ( $idx < 0 ) { + throw new AFPUserVisibleException( 'negativeindex', $this->mCur->pos, [ $idx ] ); } - $result = $result->data[$idx]; + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Guaranteed to be array + $result = $result->getData()[$idx]; + } elseif ( $result->getType() === AFPData::DUNDEFINED ) { + $result = new AFPData( AFPData::DUNDEFINED ); } else { throw new AFPUserVisibleException( 'notarray', $this->mCur->pos, [] ); } @@ -638,20 +911,29 @@ class AbuseFilterParser { * @throws AFPUserVisibleException */ protected function doLevelBraces( &$result ) { - if ( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == '(' ) { - if ( $this->mShortCircuit ) { - $this->skipOverBraces(); + if ( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === '(' ) { + $next = $this->getNextToken(); + if ( $next->type === AFPToken::TBRACE && $next->value === ')' ) { + $this->logEmptyOperand( 'parenthesized expression' ); + // We don't need DUNDEFINED here + $this->move(); + $this->move(); } else { - $this->doLevelSemicolon( $result ); - } - if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == ')' ) ) { - throw new AFPUserVisibleException( - 'expectednotfound', - $this->mCur->pos, - [ ')', $this->mCur->type, $this->mCur->value ] - ); + if ( $this->mShortCircuit ) { + $result = new AFPData( AFPData::DUNDEFINED ); + $this->skipOverBraces(); + } else { + $this->doLevelSemicolon( $result ); + } + if ( !( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === ')' ) ) { + throw new AFPUserVisibleException( + 'expectednotfound', + $this->mCur->pos, + [ ')', $this->mCur->type, $this->mCur->value ] + ); + } + $this->move(); } - $this->move(); } else { $this->doLevelFunction( $result ); } @@ -664,10 +946,10 @@ class AbuseFilterParser { * @throws AFPUserVisibleException */ protected function doLevelFunction( &$result ) { - if ( $this->mCur->type == AFPToken::TID && isset( self::$mFunctions[$this->mCur->value] ) ) { - $func = self::$mFunctions[$this->mCur->value]; + if ( $this->mCur->type === AFPToken::TID && isset( self::FUNCTIONS[$this->mCur->value] ) ) { + $fname = $this->mCur->value; $this->move(); - if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != '(' ) { + if ( $this->mCur->type !== AFPToken::TBRACE || $this->mCur->value !== '(' ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ @@ -679,6 +961,7 @@ class AbuseFilterParser { } if ( $this->mShortCircuit ) { + $result = new AFPData( AFPData::DUNDEFINED ); $this->skipOverBraces(); $this->move(); @@ -687,18 +970,38 @@ class AbuseFilterParser { } $args = []; - $state = $this->getState(); - $this->move(); - if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) { - $this->setState( $state ); + $next = $this->getNextToken(); + if ( $next->type !== AFPToken::TBRACE || $next->value !== ')' ) { + if ( ( $fname === 'set' || $fname === 'set_var' ) ) { + $state = $this->getState(); + $this->move(); + $next = $this->getNextToken(); + if ( + $this->mCur->type !== AFPToken::TSTRING || + ( + $next->type !== AFPToken::TCOMMA && + // Let this fail later, when checking parameters count + !( $next->type === AFPToken::TBRACE && $next->value === ')' ) + ) + ) { + throw new AFPUserVisibleException( 'variablevariable', $this->mCur->pos, [] ); + } else { + $this->setState( $state ); + } + } do { - $r = new AFPData(); + $r = new AFPData( AFPData::DEMPTY ); $this->doLevelSemicolon( $r ); + if ( $r->getType() === AFPData::DEMPTY && !$this->functionIsVariadic( $fname ) ) { + $this->logEmptyOperand( 'non-variadic function argument' ); + } $args[] = $r; - } while ( $this->mCur->type == AFPToken::TCOMMA ); + } while ( $this->mCur->type === AFPToken::TCOMMA ); + } else { + $this->move(); } - if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) { + if ( $this->mCur->type !== AFPToken::TBRACE || $this->mCur->value !== ')' ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ @@ -710,20 +1013,7 @@ class AbuseFilterParser { } $this->move(); - $funcHash = md5( $func . serialize( $args ) ); - - if ( isset( self::$funcCache[$funcHash] ) && - !in_array( $func, self::$ActiveFunctions ) - ) { - $result = self::$funcCache[$funcHash]; - } else { - AbuseFilter::triggerLimiter(); - $result = self::$funcCache[$funcHash] = $this->$func( $args ); - } - - if ( count( self::$funcCache ) > 1000 ) { - self::$funcCache = []; - } + $result = $this->callFunc( $fname, $args ); } else { $this->doLevelAtom( $result ); } @@ -740,10 +1030,11 @@ class AbuseFilterParser { switch ( $this->mCur->type ) { case AFPToken::TID: if ( $this->mShortCircuit ) { - break; + $result = new AFPData( AFPData::DUNDEFINED ); + } else { + $var = strtolower( $tok ); + $result = $this->getVarValue( $var ); } - $var = strtolower( $tok ); - $result = $this->getVarValue( $var ); break; case AFPToken::TSTRING: $result = new AFPData( AFPData::DSTRING, $tok ); @@ -755,12 +1046,12 @@ class AbuseFilterParser { $result = new AFPData( AFPData::DINT, $tok ); break; case AFPToken::TKEYWORD: - if ( $tok == "true" ) { + if ( $tok === "true" ) { $result = new AFPData( AFPData::DBOOL, true ); - } elseif ( $tok == "false" ) { + } elseif ( $tok === "false" ) { $result = new AFPData( AFPData::DBOOL, false ); - } elseif ( $tok == "null" ) { - $result = new AFPData(); + } elseif ( $tok === "null" ) { + $result = new AFPData( AFPData::DNULL ); } else { throw new AFPUserVisibleException( 'unrecognisedkeyword', @@ -773,25 +1064,25 @@ class AbuseFilterParser { // Handled at entry level return; case AFPToken::TBRACE: - if ( $this->mCur->value == ')' ) { + if ( $this->mCur->value === ')' ) { // Handled at the entry level return; } case AFPToken::TSQUAREBRACKET: - if ( $this->mCur->value == '[' ) { + if ( $this->mCur->value === '[' ) { $array = []; while ( true ) { $this->move(); - if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) { break; } - $item = new AFPData(); + $item = new AFPData( AFPData::DEMPTY ); $this->doLevelSet( $item ); $array[] = $item; - if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) { break; } - if ( $this->mCur->type != AFPToken::TCOMMA ) { + if ( $this->mCur->type !== AFPToken::TCOMMA ) { throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, @@ -818,34 +1109,61 @@ class AbuseFilterParser { /* End of levels */ /** + * Check whether a variable exists, being either built-in or user-defined. Doesn't include + * disabled variables. + * + * @param string $varname + * @return bool + */ + protected function varExists( $varname ) { + return $this->keywordsManager->isVarInUse( $varname ) || + $this->mVariables->varIsSet( $varname ); + } + + /** * @param string $var * @return AFPData * @throws AFPUserVisibleException */ protected function getVarValue( $var ) { $var = strtolower( $var ); - $builderValues = AbuseFilter::getBuilderValues(); - $deprecatedVars = AbuseFilter::getDeprecatedVariables(); + $deprecatedVars = $this->keywordsManager->getDeprecatedVariables(); + if ( array_key_exists( $var, $deprecatedVars ) ) { - $logger = LoggerFactory::getInstance( 'AbuseFilterDeprecatedVars' ); - $logger->debug( "AbuseFilter: deprecated variable $var used." ); - $var = $deprecatedVars[$var]; + if ( $this->logsDeprecatedVars() ) { + $this->logger->debug( "Deprecated variable $var used in filter {$this->mFilter}." ); + } + $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 + if ( $this->keywordsManager->isVarDisabled( $var ) ) { throw new AFPUserVisibleException( - $msg, + 'disabledvar', + $this->mCur->pos, + [ $var ] + ); + } + if ( !$this->varExists( $var ) ) { + throw new AFPUserVisibleException( + 'unrecognisedvar', $this->mCur->pos, [ $var ] ); - } else { - return $this->mVars->getVar( $var ); } + + // It's a built-in, non-disabled variable (either set or unset), or a set custom variable + $flags = $this->allowMissingVariables + ? AbuseFilterVariableHolder::GET_LAX + // TODO: This should be GET_STRICT, but that's going to be very hard (see T230256) + : AbuseFilterVariableHolder::GET_BC; + return $this->mVariables->getVar( $var, $flags, $this->mFilter ); + } + + /** + * Whether this parser should log deprecated vars use. + * @return bool + */ + protected function logsDeprecatedVars() { + return true; } /** @@ -854,16 +1172,99 @@ class AbuseFilterParser { * @throws AFPUserVisibleException */ protected function setUserVariable( $name, $value ) { - $builderValues = AbuseFilter::getBuilderValues(); - $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 ) ) { + if ( $this->isReservedIdentifier( $name ) ) { throw new AFPUserVisibleException( 'overridebuiltin', $this->mCur->pos, [ $name ] ); } - $this->mVars->setVar( $name, $value ); + $this->mVariables->setVar( $name, $value ); + } + + /** + * Helper to call a built-in function. + * + * @param string $fname The name of the function as found in the filter code + * @param AFPData[] $args Arguments for the function + * @return AFPData The return value of the function + * @throws InvalidArgumentException if given an invalid func + */ + protected function callFunc( $fname, array $args ) : AFPData { + if ( !array_key_exists( $fname, self::FUNCTIONS ) ) { + // @codeCoverageIgnoreStart + throw new InvalidArgumentException( "$fname is not a valid function." ); + // @codeCoverageIgnoreEnd + } + + $funcHandler = self::FUNCTIONS[$fname]; + $funcHash = md5( $funcHandler . serialize( $args ) ); + + if ( isset( $this->funcCache[$funcHash] ) && + !in_array( $funcHandler, self::ACTIVE_FUNCTIONS ) + ) { + $result = $this->funcCache[$funcHash]; + } else { + $this->checkArgCount( $args, $fname ); + $this->raiseCondCount(); + + // Any undefined argument should be special-cased by the function, but that would be too + // much overhead. We also cannot skip calling the handler in case it's making further + // validation (T234339). So temporarily replace the DUNDEFINED with a DNULL. + // @todo This is subpar. + $hasUndefinedArg = false; + foreach ( $args as $i => $arg ) { + if ( $arg->hasUndefined() ) { + $args[$i] = $arg->cloneAsUndefinedReplacedWithNull(); + $hasUndefinedArg = true; + } + } + if ( $hasUndefinedArg ) { + $this->$funcHandler( $args ); + $result = new AFPData( AFPData::DUNDEFINED ); + } else { + $result = $this->$funcHandler( $args ); + } + $this->funcCache[$funcHash] = $result; + } + + if ( count( $this->funcCache ) > 1000 ) { + // @codeCoverageIgnoreStart + $this->clearFuncCache(); + // @codeCoverageIgnoreEnd + } + return $result; + } + + /** + * Helper to invoke a built-in keyword. Note that this assumes that $kname is + * a valid keyword name. + * + * @param string $kname + * @param AFPData $lhs + * @param AFPData $rhs + * @return AFPData + */ + protected function callKeyword( $kname, AFPData $lhs, AFPData $rhs ) : AFPData { + $func = self::KEYWORDS[$kname]; + $this->raiseCondCount(); + + $hasUndefinedOperand = false; + if ( $lhs->hasUndefined() ) { + $lhs = $lhs->cloneAsUndefinedReplacedWithNull(); + $hasUndefinedOperand = true; + } + if ( $rhs->hasUndefined() ) { + $rhs = $rhs->cloneAsUndefinedReplacedWithNull(); + $hasUndefinedOperand = true; + } + if ( $hasUndefinedOperand ) { + // We need to run the handler with bogus args, see the comment in self::callFunc (T234339) + // @todo Likewise, this is subpar. + // @phan-suppress-next-line PhanParamTooMany Not every function needs the position + $this->$func( $lhs, $rhs, $this->mCur->pos ); + $result = new AFPData( AFPData::DUNDEFINED ); + } else { + // @phan-suppress-next-line PhanParamTooMany Not every function needs the position + $result = $this->$func( $lhs, $rhs, $this->mCur->pos ); + } + return $result; } // Built-in functions @@ -871,76 +1272,43 @@ class AbuseFilterParser { /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcLc( $args ) { - global $wgContLang; - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'lc', 1 ] - ); - } $s = $args[0]->toString(); - return new AFPData( AFPData::DSTRING, $wgContLang->lc( $s ) ); + return new AFPData( AFPData::DSTRING, $this->contLang->lc( $s ) ); } /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcUc( $args ) { - global $wgContLang; - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'uc', 1 ] - ); - } $s = $args[0]->toString(); - return new AFPData( AFPData::DSTRING, $wgContLang->uc( $s ) ); + return new AFPData( AFPData::DSTRING, $this->contLang->uc( $s ) ); } /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcLen( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'len', 1 ] - ); - } - if ( $args[0]->type == AFPData::DARRAY ) { + if ( $args[0]->type === AFPData::DARRAY ) { // Don't use toString on arrays, but count - return new AFPData( AFPData::DINT, count( $args[0]->data ) ); + $val = count( $args[0]->data ); + } else { + $val = mb_strlen( $args[0]->toString(), 'utf-8' ); } - $s = $args[0]->toString(); - return new AFPData( AFPData::DINT, mb_strlen( $s, 'utf-8' ) ); + return new AFPData( AFPData::DINT, $val ); } /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcSpecialRatio( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'specialratio', 1 ] - ); - } $s = $args[0]->toString(); if ( !strlen( $s ) ) { @@ -957,22 +1325,13 @@ class AbuseFilterParser { /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcCount( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'count', 1 ] - ); - } - - if ( $args[0]->type == AFPData::DARRAY && count( $args ) == 1 ) { + if ( $args[0]->type === AFPData::DARRAY && count( $args ) === 1 ) { return new AFPData( AFPData::DINT, count( $args[0]->data ) ); } - if ( count( $args ) == 1 ) { + if ( count( $args ) === 1 ) { $count = count( explode( ',', $args[0]->toString() ) ); } else { $needle = $args[0]->toString(); @@ -993,18 +1352,9 @@ class AbuseFilterParser { * @param array $args * @return AFPData * @throws AFPUserVisibleException - * @throws Exception */ protected function funcRCount( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'rcount', 1 ] - ); - } - - if ( count( $args ) == 1 ) { + if ( count( $args ) === 1 ) { $count = count( explode( ',', $args[0]->toString() ) ); } else { $needle = $args[0]->toString(); @@ -1015,9 +1365,9 @@ class AbuseFilterParser { $needle = "/$needle/u"; // Suppress and restore needed per T177744 - Wikimedia\suppressWarnings(); + AtEase::suppressWarnings(); $count = preg_match_all( $needle, $haystack ); - Wikimedia\restoreWarnings(); + AtEase::restoreWarnings(); if ( $count === false ) { throw new AFPUserVisibleException( @@ -1040,23 +1390,19 @@ class AbuseFilterParser { * @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. + // ToDo: Find a better way to do this. // 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 ); + // Then strip starting parentheses of non-capturing groups, including + // atomics, lookaheads and so on, even if not every of them is supported. + $sanitized = str_replace( '(?', '', $sanitized ); + // And also strip "(*", used with backtracking verbs like (*FAIL) + $sanitized = str_replace( '(*', '', $sanitized ); // Finally create an array of falses with dimension = # of capturing groups $groupscount = substr_count( $sanitized, '(' ) + 1; $falsy = array_fill( 0, $groupscount, false ); @@ -1066,9 +1412,9 @@ class AbuseFilterParser { $needle = "/$needle/u"; // Suppress and restore are here for the same reason as T177744 - Wikimedia\suppressWarnings(); + AtEase::suppressWarnings(); $check = preg_match( $needle, $haystack, $matches ); - Wikimedia\restoreWarnings(); + AtEase::restoreWarnings(); if ( $check === false ) { throw new AFPUserVisibleException( @@ -1090,18 +1436,10 @@ class AbuseFilterParser { * @throws AFPUserVisibleException */ protected function funcIPInRange( $args ) { - if ( count( $args ) < 2 ) { - throw new AFPUserVisibleException( - 'notenoughargs', - $this->mCur->pos, - [ 'ip_in_range', 2, count( $args ) ] - ); - } - $ip = $args[0]->toString(); $range = $args[1]->toString(); - if ( !IP::isValidRange( $range ) ) { + if ( !IPUtils::isValidRange( $range ) ) { throw new AFPUserVisibleException( 'invalidiprange', $this->mCur->pos, @@ -1109,7 +1447,7 @@ class AbuseFilterParser { ); } - $result = IP::isInRange( $ip, $range ); + $result = IPUtils::isInRange( $ip, $range ); return new AFPData( AFPData::DBOOL, $result ); } @@ -1117,16 +1455,8 @@ class AbuseFilterParser { /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcCCNorm( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'ccnorm', 1 ] - ); - } $s = $args[0]->toString(); $s = html_entity_decode( $s, ENT_QUOTES, 'UTF-8' ); @@ -1138,16 +1468,8 @@ class AbuseFilterParser { /** * @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' ); @@ -1159,17 +1481,8 @@ class AbuseFilterParser { /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcContainsAny( $args ) { - if ( count( $args ) < 2 ) { - throw new AFPUserVisibleException( - 'notenoughargs', - $this->mCur->pos, - [ 'contains_any', 2, count( $args ) ] - ); - } - $s = array_shift( $args ); return new AFPData( AFPData::DBOOL, self::contains( $s, $args, true ) ); @@ -1178,17 +1491,8 @@ class AbuseFilterParser { /** * @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 ) ] - ); - } - $s = array_shift( $args ); return new AFPData( AFPData::DBOOL, self::contains( $s, $args, false, false ) ); @@ -1199,17 +1503,8 @@ class AbuseFilterParser { * * @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 ) ); @@ -1220,17 +1515,8 @@ class AbuseFilterParser { * * @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 ) ); @@ -1283,23 +1569,14 @@ class AbuseFilterParser { // 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; + return !$is_any; } /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcEqualsToAny( $args ) { - if ( count( $args ) < 2 ) { - throw new AFPUserVisibleException( - 'notenoughargs', - $this->mCur->pos, - [ 'equals_to_any', 2, count( $args ) ] - ); - } - $s = array_shift( $args ); return new AFPData( AFPData::DBOOL, self::equalsToAny( $s, $args ) ); @@ -1314,12 +1591,8 @@ class AbuseFilterParser { * @return bool */ protected static function equalsToAny( $string, $values ) { - $string = $string->toString(); - foreach ( $values as $needle ) { - $needle = $needle->toString(); - - if ( $string === $needle ) { + if ( $string->equals( $needle, true ) ) { return true; } } @@ -1367,76 +1640,38 @@ class AbuseFilterParser { /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcRMSpecials( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'rmspecials', 1 ] - ); - } $s = $args[0]->toString(); - $s = $this->rmspecials( $s ); - - return new AFPData( AFPData::DSTRING, $s ); + return new AFPData( AFPData::DSTRING, $this->rmspecials( $s ) ); } /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcRMWhitespace( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'rmwhitespace', 1 ] - ); - } $s = $args[0]->toString(); - $s = $this->rmwhitespace( $s ); - - return new AFPData( AFPData::DSTRING, $s ); + return new AFPData( AFPData::DSTRING, $this->rmwhitespace( $s ) ); } /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcRMDoubles( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'rmdoubles', 1 ] - ); - } $s = $args[0]->toString(); - $s = $this->rmdoubles( $s ); - - return new AFPData( AFPData::DSTRING, $s ); + return new AFPData( AFPData::DSTRING, $this->rmdoubles( $s ) ); } /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcNorm( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'norm', 1 ] - ); - } $s = $args[0]->toString(); $s = $this->ccnorm( $s ); @@ -1450,27 +1685,13 @@ class AbuseFilterParser { /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcSubstr( $args ) { - if ( count( $args ) < 2 ) { - throw new AFPUserVisibleException( - 'notenoughargs', - $this->mCur->pos, - [ 'substr', 2, count( $args ) ] - ); - } - $s = $args[0]->toString(); $offset = $args[1]->toInt(); + $length = isset( $args[2] ) ? $args[2]->toInt() : null; - if ( isset( $args[2] ) ) { - $length = $args[2]->toInt(); - - $result = mb_substr( $s, $offset, $length ); - } else { - $result = mb_substr( $s, $offset ); - } + $result = mb_substr( $s, $offset, $length ); return new AFPData( AFPData::DSTRING, $result ); } @@ -1478,32 +1699,18 @@ class AbuseFilterParser { /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcStrPos( $args ) { - if ( count( $args ) < 2 ) { - throw new AFPUserVisibleException( - 'notenoughargs', - $this->mCur->pos, - [ 'strpos', 2, count( $args ) ] - ); - } - $haystack = $args[0]->toString(); $needle = $args[1]->toString(); + $offset = isset( $args[2] ) ? $args[2]->toInt() : 0; // T62203: Keep empty parameters from causing PHP warnings if ( $needle === '' ) { return new AFPData( AFPData::DINT, -1 ); } - if ( isset( $args[2] ) ) { - $offset = $args[2]->toInt(); - - $result = mb_strpos( $haystack, $needle, $offset ); - } else { - $result = mb_strpos( $haystack, $needle ); - } + $result = mb_strpos( $haystack, $needle, $offset ); if ( $result === false ) { $result = -1; @@ -1515,17 +1722,8 @@ class AbuseFilterParser { /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcStrReplace( $args ) { - if ( count( $args ) < 3 ) { - throw new AFPUserVisibleException( - 'notenoughargs', - $this->mCur->pos, - [ 'str_replace', 3, count( $args ) ] - ); - } - $subject = $args[0]->toString(); $search = $args[1]->toString(); $replace = $args[2]->toString(); @@ -1536,17 +1734,8 @@ class AbuseFilterParser { /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function funcStrRegexEscape( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'rescape', 1 ] - ); - } - $string = $args[0]->toString(); // preg_quote does not need the second parameter, since rlike takes @@ -1557,17 +1746,8 @@ class AbuseFilterParser { /** * @param array $args * @return mixed - * @throws AFPUserVisibleException */ protected function funcSetVar( $args ) { - if ( count( $args ) < 2 ) { - throw new AFPUserVisibleException( - 'notenoughargs', - $this->mCur->pos, - [ 'set_var', 2, count( $args ) ] - ); - } - $varName = $args[0]->toString(); $value = $args[1]; @@ -1577,74 +1757,151 @@ class AbuseFilterParser { } /** - * @param array $args + * Checks if $a contains $b + * + * @param AFPData $a + * @param AFPData $b * @return AFPData - * @throws AFPUserVisibleException */ - protected function castString( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'string', 1 ] - ); + protected function containmentKeyword( AFPData $a, AFPData $b ) { + $a = $a->toString(); + $b = $b->toString(); + + if ( $a === '' || $b === '' ) { + return new AFPData( AFPData::DBOOL, false ); } - $val = $args[0]; - return AFPData::castTypes( $val, AFPData::DSTRING ); + return new AFPData( AFPData::DBOOL, strpos( $a, $b ) !== false ); } /** - * @param array $args + * @param AFPData $a + * @param AFPData $b * @return AFPData - * @throws AFPUserVisibleException */ - protected function castInt( $args ) { - if ( count( $args ) === 0 ) { + protected function keywordIn( AFPData $a, AFPData $b ) { + return $this->containmentKeyword( $b, $a ); + } + + /** + * @param AFPData $a + * @param AFPData $b + * @return AFPData + */ + protected function keywordContains( AFPData $a, AFPData $b ) { + return $this->containmentKeyword( $a, $b ); + } + + /** + * @param AFPData $str + * @param AFPData $pattern + * @return AFPData + */ + protected function keywordLike( AFPData $str, AFPData $pattern ) { + $str = $str->toString(); + $pattern = '#^' . strtr( preg_quote( $pattern->toString(), '#' ), AFPData::WILDCARD_MAP ) . '$#u'; + AtEase::suppressWarnings(); + $result = preg_match( $pattern, $str ); + AtEase::restoreWarnings(); + + return new AFPData( AFPData::DBOOL, (bool)$result ); + } + + /** + * @param AFPData $str + * @param AFPData $regex + * @param int $pos + * @param bool $insensitive + * @return AFPData + * @throws Exception + */ + protected function keywordRegex( AFPData $str, AFPData $regex, $pos, $insensitive = false ) { + $str = $str->toString(); + $pattern = $regex->toString(); + + $pattern = preg_replace( '!(\\\\\\\\)*(\\\\)?/!', '$1\/', $pattern ); + $pattern = "/$pattern/u"; + + if ( $insensitive ) { + $pattern .= 'i'; + } + + AtEase::suppressWarnings(); + $result = preg_match( $pattern, $str ); + AtEase::restoreWarnings(); + if ( $result === false ) { throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'int', 1 ] + 'regexfailure', + // Coverage bug + // @codeCoverageIgnoreStart + $pos, + // @codeCoverageIgnoreEnd + [ $pattern ] ); } - $val = $args[0]; - return AFPData::castTypes( $val, AFPData::DINT ); + return new AFPData( AFPData::DBOOL, (bool)$result ); + } + + /** + * @param AFPData $str + * @param AFPData $regex + * @param int $pos + * @return AFPData + */ + protected function keywordRegexInsensitive( AFPData $str, AFPData $regex, $pos ) { + return $this->keywordRegex( $str, $regex, $pos, true ); + } + + /** + * @param array $args + * @return AFPData + */ + protected function castString( $args ) { + return AFPData::castTypes( $args[0], AFPData::DSTRING ); } /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ - protected function castFloat( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'float', 1 ] - ); - } - $val = $args[0]; + protected function castInt( $args ) { + return AFPData::castTypes( $args[0], AFPData::DINT ); + } - return AFPData::castTypes( $val, AFPData::DFLOAT ); + /** + * @param array $args + * @return AFPData + */ + protected function castFloat( $args ) { + return AFPData::castTypes( $args[0], AFPData::DFLOAT ); } /** * @param array $args * @return AFPData - * @throws AFPUserVisibleException */ protected function castBool( $args ) { - if ( count( $args ) === 0 ) { - throw new AFPUserVisibleException( - 'noparams', - $this->mCur->pos, - [ 'bool', 1 ] + return AFPData::castTypes( $args[0], AFPData::DBOOL ); + } + + /** + * Log empty operands for T156096 + * + * @param string $type Type of the empty operand + */ + protected function logEmptyOperand( $type ) { + if ( $this->mFilter !== null ) { + $this->logger->warning( + 'DEPRECATED! Found empty operand of type `{op_type}` when parsing filter: {filter}. ' . + 'This is deprecated since 1.34 and support will be discontinued soon. Please fix ' . + 'the affected filter!', + [ + 'op_type' => $type, + 'filter' => $this->mFilter + ] ); } - $val = $args[0]; - - return AFPData::castTypes( $val, AFPData::DBOOL ); } + } diff --git a/AbuseFilter/includes/parser/AbuseFilterTokenizer.php b/AbuseFilter/includes/parser/AbuseFilterTokenizer.php index d95a6864..b814b670 100644 --- a/AbuseFilter/includes/parser/AbuseFilterTokenizer.php +++ b/AbuseFilter/includes/parser/AbuseFilterTokenizer.php @@ -1,24 +1,27 @@ <?php -use MediaWiki\MediaWikiServices; +use Psr\Log\LoggerInterface; /** * Tokenizer for AbuseFilter rules. */ class AbuseFilterTokenizer { - /** @var int Tokenizer cache version. Increment this when changing the syntax. **/ - const CACHE_VERSION = 1; - const COMMENT_START_RE = '/\s*\/\*/A'; - const ID_SYMBOL_RE = '/[0-9A-Za-z_]+/A'; - const OPERATOR_RE = + /** @var int Tokenizer cache version. Increment this when changing the syntax. */ + public const CACHE_VERSION = 4; + private const COMMENT_START_RE = '/\s*\/\*/A'; + private const ID_SYMBOL_RE = '/[0-9A-Za-z_]+/A'; + public const OPERATOR_RE = '/(\!\=\=|\!\=|\!|\*\*|\*|\/|\+|\-|%|&|\||\^|\:\=|\?|\:|\<\=|\<|\>\=|\>|\=\=\=|\=\=|\=)/A'; - const RADIX_RE = '/([0-9A-Fa-f]+(?:\.\d*)?|\.\d+)([bxo])?/Au'; - const WHITESPACE = "\011\012\013\014\015\040"; + private const BASE = '0(?<base>[xbo])'; + private const DIGIT = '[0-9A-Fa-f]'; + private const DIGITS = self::DIGIT . '+' . '(?:\.\d*)?|\.\d+'; + private const RADIX_RE = '/(?:' . self::BASE . ')?(?<input>' . self::DIGITS . ')(?!\w)/Au'; + private const WHITESPACE = "\011\012\013\014\015\040"; // Order is important. The punctuation-matching regex requires that // ** comes before *, etc. They are sorted to make it easy to spot // such errors. - public static $operators = [ + public const OPERATORS = [ // Inequality '!==', '!=', '!', // Multiplication/exponentiation @@ -39,7 +42,7 @@ class AbuseFilterTokenizer { '===', '==', '=', ]; - public static $punctuation = [ + public const PUNCTUATION = [ ',' => AFPToken::TCOMMA, '(' => AFPToken::TBRACE, ')' => AFPToken::TBRACE, @@ -48,64 +51,86 @@ class AbuseFilterTokenizer { ';' => AFPToken::TSTATEMENTSEPARATOR, ]; - public static $bases = [ + public const BASES = [ 'b' => 2, 'x' => 16, 'o' => 8 ]; - public static $baseCharsRe = [ + public const BASE_CHARS_RES = [ 2 => '/^[01]+$/', - 8 => '/^[0-8]+$/', + 8 => '/^[0-7]+$/', 16 => '/^[0-9A-Fa-f]+$/', 10 => '/^[0-9.]+$/', ]; - public static $keywords = [ + public const KEYWORDS = [ 'in', 'like', 'true', 'false', 'null', 'contains', 'matches', 'rlike', 'irlike', 'regex', 'if', 'then', 'else', 'end', ]; /** - * @param string $code - * @return array - * @throws AFPException - * @throws AFPUserVisibleException + * @var BagOStuff */ - public static function tokenize( $code ) { - static $tokenizerCache = null; - - if ( !$tokenizerCache ) { - $tokenizerCache = ObjectCache::getLocalServerInstance( 'hash' ); - } + private $cache; - static $stats = null; + /** + * @var LoggerInterface + */ + private $logger; - if ( !$stats ) { - $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); - } + /** + * @param BagOStuff $cache + * @param LoggerInterface $logger + */ + public function __construct( BagOStuff $cache, LoggerInterface $logger ) { + $this->cache = $cache; + $this->logger = $logger; + } - $cacheKey = wfGlobalCacheKey( __CLASS__, self::CACHE_VERSION, crc32( $code ) ); + /** + * Get a cache key used to store the tokenized code + * + * @param string $code Not yet tokenized + * @return string + * @internal + */ + public function getCacheKey( $code ) { + return $this->cache->makeGlobalKey( __CLASS__, self::CACHE_VERSION, crc32( $code ) ); + } - $tokens = $tokenizerCache->get( $cacheKey ); + /** + * Get the tokens for the given code. + * + * @param string $code + * @return array[] + */ + public function getTokens( $code ) { + $tokens = $this->cache->getWithSetCallback( + $this->getCacheKey( $code ), + BagOStuff::TTL_DAY, + function () use ( $code ) { + return $this->tokenize( $code ); + } + ); - if ( $tokens ) { - $stats->increment( 'AbuseFilter.tokenizerCache.hit' ); - return $tokens; - } + return $tokens; + } - $stats->increment( 'AbuseFilter.tokenizerCache.miss' ); + /** + * @param string $code + * @return array[] + */ + private function tokenize( $code ) { $tokens = []; $curPos = 0; do { $prevPos = $curPos; - $token = self::nextToken( $code, $curPos ); + $token = $this->nextToken( $code, $curPos ); $tokens[ $token->pos ] = [ $token, $curPos ]; } while ( $curPos !== $prevPos ); - $tokenizerCache->set( $cacheKey, $tokens, 60 * 60 * 24 ); - return $tokens; } @@ -116,7 +141,7 @@ class AbuseFilterTokenizer { * @throws AFPException * @throws AFPUserVisibleException */ - protected static function nextToken( $code, &$offset ) { + private function nextToken( $code, &$offset ) { $matches = []; $start = $offset; @@ -138,9 +163,9 @@ class AbuseFilterTokenizer { $chr = $code[$offset]; // Punctuation - if ( isset( self::$punctuation[$chr] ) ) { + if ( isset( self::PUNCTUATION[$chr] ) ) { $offset++; - return new AFPToken( self::$punctuation[$chr], $chr, $start ); + return new AFPToken( self::PUNCTUATION[$chr], $chr, $start ); } // String literal @@ -158,23 +183,13 @@ class AbuseFilterTokenizer { } // Numbers - if ( preg_match( self::RADIX_RE, $code, $matches, 0, $offset ) ) { - $token = $matches[0]; - $input = $matches[1]; - $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 - if ( !$baseChar && !empty( self::$bases[ substr( $input, - 1 ) ] ) ) { - $baseChar = substr( $input, - 1, 1 ); - $input = substr( $input, 0, - 1 ); - } - - $base = $baseChar ? self::$bases[$baseChar] : 10; - - // Check against the appropriate character class for input validation - - if ( preg_match( self::$baseCharsRe[$base], $input ) ) { + $matchesv2 = []; + if ( preg_match( self::RADIX_RE, $code, $matchesv2, 0, $offset ) ) { + $token = $matchesv2[0]; + $baseChar = $matchesv2['base']; + $input = $matchesv2['input']; + $base = $baseChar ? self::BASES[$baseChar] : 10; + if ( preg_match( self::BASE_CHARS_RES[$base], $input ) ) { $num = $base !== 10 ? base_convert( $input, $base, 10 ) : $input; $offset += strlen( $token ); return ( strpos( $input, '.' ) !== false ) @@ -188,7 +203,7 @@ class AbuseFilterTokenizer { if ( preg_match( self::ID_SYMBOL_RE, $code, $matches, 0, $offset ) ) { $token = $matches[0]; $offset += strlen( $token ); - $type = in_array( $token, self::$keywords ) + $type = in_array( $token, self::KEYWORDS ) ? AFPToken::TKEYWORD : AFPToken::TID; return new AFPToken( $type, $token, $start ); @@ -206,7 +221,7 @@ class AbuseFilterTokenizer { * @throws AFPException * @throws AFPUserVisibleException */ - protected static function readStringLiteral( $code, &$offset, $start ) { + private static function readStringLiteral( $code, &$offset, $start ) { $type = $code[$offset]; $offset++; $length = strlen( $code ); @@ -223,7 +238,7 @@ class AbuseFilterTokenizer { if ( $addLength ) { $token .= substr( $code, $offset, $addLength ); $offset += $addLength; - } elseif ( $code[$offset] == '\\' ) { + } elseif ( $code[$offset] === '\\' ) { switch ( $code[$offset + 1] ) { case '\\': $token .= '\\'; @@ -244,12 +259,11 @@ class AbuseFilterTokenizer { $chr = substr( $code, $offset + 2, 2 ); if ( preg_match( '/^[0-9A-Fa-f]{2}$/', $chr ) ) { - $chr = base_convert( $chr, 16, 10 ); - $token .= chr( $chr ); + $token .= chr( hexdec( $chr ) ); // \xXX -- 2 done later $offset += 2; } else { - $token .= 'x'; + $token .= '\\x'; } break; default: @@ -260,8 +274,10 @@ class AbuseFilterTokenizer { } else { // Should never happen + // @codeCoverageIgnoreStart $token .= $code[$offset]; $offset++; + // @codeCoverageIgnoreEnd } } throw new AFPUserVisibleException( 'unclosedstring', $offset, [] ); diff --git a/AbuseFilter/includes/special/AbuseFilterSpecialPage.php b/AbuseFilter/includes/special/AbuseFilterSpecialPage.php new file mode 100644 index 00000000..0c1b4171 --- /dev/null +++ b/AbuseFilter/includes/special/AbuseFilterSpecialPage.php @@ -0,0 +1,65 @@ +<?php + +use MediaWiki\MediaWikiServices; + +/** + * Parent class for AbuseFilter special pages. + */ +abstract class AbuseFilterSpecialPage extends SpecialPage { + /** + * Add topbar navigation links + * + * @param string $pageType + */ + protected function addNavigationLinks( $pageType ) { + $user = $this->getUser(); + + $linkDefs = [ + 'home' => 'Special:AbuseFilter', + 'recentchanges' => 'Special:AbuseFilter/history', + 'examine' => 'Special:AbuseFilter/examine', + ]; + + if ( MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $user, 'abusefilter-log' ) + ) { + $linkDefs = array_merge( $linkDefs, [ + 'log' => 'Special:AbuseLog' + ] ); + } + + if ( AbuseFilter::canViewPrivate( $user ) ) { + $linkDefs = array_merge( $linkDefs, [ + 'test' => 'Special:AbuseFilter/test', + 'tools' => 'Special:AbuseFilter/tools' + ] ); + } + + $links = []; + + foreach ( $linkDefs as $name => $page ) { + // Give grep a chance to find the usages: + // abusefilter-topnav-home, abusefilter-topnav-recentchanges, abusefilter-topnav-test, + // abusefilter-topnav-log, abusefilter-topnav-tools, abusefilter-topnav-examine + $msgName = "abusefilter-topnav-$name"; + + $msg = $this->msg( $msgName )->parse(); + $title = Title::newFromText( $page ); + + if ( $name === $pageType ) { + $links[] = Xml::tags( 'strong', null, $msg ); + } else { + $links[] = $this->getLinkRenderer()->makeLink( $title, new HtmlArmor( $msg ) ); + } + } + + $linkStr = $this->msg( 'parentheses' ) + ->rawParams( $this->getLanguage()->pipeList( $links ) ) + ->text(); + $linkStr = $this->msg( 'abusefilter-topnav' )->parse() . " $linkStr"; + + $linkStr = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-navigation' ], $linkStr ); + + $this->getOutput()->setSubtitle( $linkStr ); + } +} diff --git a/AbuseFilter/includes/special/SpecialAbuseFilter.php b/AbuseFilter/includes/special/SpecialAbuseFilter.php index ccd2f8ee..03fc9d6b 100644 --- a/AbuseFilter/includes/special/SpecialAbuseFilter.php +++ b/AbuseFilter/includes/special/SpecialAbuseFilter.php @@ -1,20 +1,38 @@ <?php -class SpecialAbuseFilter extends SpecialPage { - public $mFilter, $mHistoryID; +class SpecialAbuseFilter extends AbuseFilterSpecialPage { + public const PAGE_NAME = 'AbuseFilter'; + /** + * @var int|string|null The current filter + */ + public $mFilter; + /** + * @var int|null The history ID of the current version + */ + public $mHistoryID; + /** + * @inheritDoc + */ public function __construct() { - parent::__construct( 'AbuseFilter', 'abusefilter-view' ); + parent::__construct( self::PAGE_NAME, 'abusefilter-view' ); } /** - * @return bool + * @inheritDoc */ public function doesWrites() { return true; } /** + * @inheritDoc + */ + protected function getGroupName() { + return 'wiki'; + } + + /** * @param string|null $subpage */ public function execute( $subpage ) { @@ -25,13 +43,13 @@ class SpecialAbuseFilter extends SpecialPage { $view = 'AbuseFilterViewList'; $this->setHeaders(); + $this->addHelpLink( 'Extension:AbuseFilter' ); $this->loadParameters( $subpage ); - $out->setPageTitle( $this->msg( 'abusefilter-management' ) ); $this->checkPermissions(); - if ( $request->getVal( 'result' ) == 'success' ) { + if ( $request->getVal( 'result' ) === 'success' ) { $out->setSubtitle( $this->msg( 'abusefilter-edit-done-subtitle' ) ); $changedFilter = intval( $request->getVal( 'changedfilter' ) ); $changeId = intval( $request->getVal( 'changeid' ) ); @@ -58,64 +76,63 @@ class SpecialAbuseFilter extends SpecialPage { } $params = array_values( $params ); - if ( $subpage == 'tools' ) { + if ( $subpage === 'tools' ) { $view = 'AbuseFilterViewTools'; $pageType = 'tools'; $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); } - if ( count( $params ) == 2 && $params[0] == 'revert' && is_numeric( $params[1] ) ) { + if ( count( $params ) === 2 && $params[0] === 'revert' && is_numeric( $params[1] ) ) { $this->mFilter = $params[1]; $view = 'AbuseFilterViewRevert'; $pageType = 'revert'; } - if ( count( $params ) && $params[0] == 'test' ) { + if ( count( $params ) && $params[0] === 'test' ) { $view = 'AbuseFilterViewTestBatch'; $pageType = 'test'; $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); } - if ( count( $params ) && $params[0] == 'examine' ) { + if ( count( $params ) && $params[0] === 'examine' ) { $view = 'AbuseFilterViewExamine'; $pageType = 'examine'; $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); } - if ( !empty( $params[0] ) && ( $params[0] == 'history' || $params[0] == 'log' ) ) { + if ( !empty( $params[0] ) && ( $params[0] === 'history' || $params[0] === 'log' ) ) { $pageType = ''; - if ( count( $params ) == 1 ) { + if ( count( $params ) === 1 ) { $view = 'AbuseFilterViewHistory'; $pageType = 'recentchanges'; - } elseif ( count( $params ) == 2 ) { + } elseif ( count( $params ) === 2 ) { // Second param is a filter ID $view = 'AbuseFilterViewHistory'; $pageType = 'recentchanges'; $this->mFilter = $params[1]; - } elseif ( count( $params ) == 4 && $params[2] == 'item' ) { + } elseif ( count( $params ) === 4 && $params[2] === 'item' ) { $this->mFilter = $params[1]; - $this->mHistoryID = $params[3]; + $this->mHistoryID = (int)$params[3]; $view = 'AbuseFilterViewEdit'; - } elseif ( count( $params ) == 5 && $params[2] == 'diff' ) { + } elseif ( count( $params ) === 5 && $params[2] === 'diff' ) { // Special:AbuseFilter/history/<filter>/diff/<oldid>/<newid> $view = 'AbuseFilterViewDiff'; } } - if ( is_numeric( $subpage ) || $subpage == 'new' ) { + if ( is_numeric( $subpage ) || $subpage === 'new' ) { $this->mFilter = $subpage; $view = 'AbuseFilterViewEdit'; $pageType = 'edit'; } - if ( $subpage == 'import' ) { + if ( $subpage === 'import' ) { $view = 'AbuseFilterViewImport'; $pageType = 'import'; } // Links at the top - AbuseFilter::addNavigationLinks( - $this->getContext(), $pageType, $this->getLinkRenderer() ); + $this->addNavigationLinks( $pageType ); /** @var AbuseFilterView $v */ $v = new $view( $this, $params ); @@ -123,21 +140,12 @@ class SpecialAbuseFilter extends SpecialPage { } /** - * @param string|null $subpage + * @param string|null $filter */ - public function loadParameters( $subpage ) { - $filter = $subpage; - - if ( !is_numeric( $filter ) && $filter != 'new' ) { + public function loadParameters( $filter ) { + if ( !is_numeric( $filter ) && $filter !== 'new' ) { $filter = $this->getRequest()->getIntOrNull( 'wpFilter' ); } $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 ab2e2cbc..31d85699 100644 --- a/AbuseFilter/includes/special/SpecialAbuseLog.php +++ b/AbuseFilter/includes/special/SpecialAbuseLog.php @@ -1,50 +1,97 @@ <?php -class SpecialAbuseLog extends SpecialPage { +use MediaWiki\Cache\LinkBatchFactory; +use MediaWiki\Linker\LinkTarget; +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; +use MediaWiki\Permissions\PermissionManager; +use MediaWiki\User\UserIdentity; + +class SpecialAbuseLog extends AbuseFilterSpecialPage { /** - * @var User + * @var User The user whose AbuseLog entries are being searched */ protected $mSearchUser; + /** + * @var string The start time of the search period + */ protected $mSearchPeriodStart; + /** + * @var string The end time of the search period + */ protected $mSearchPeriodEnd; /** - * @var Title + * @var Title The page of which AbuseLog entries are being searched */ protected $mSearchTitle; /** - * @var string + * @var string The action performed by the user */ protected $mSearchAction; /** - * @var string + * @var string The action taken by AbuseFilter */ protected $mSearchActionTaken; + /** + * @var string The wiki name where we're performing the search + */ protected $mSearchWiki; + /** + * @var string|null The filter IDs we're looking for. Either a single one, or a pipe-separated list + */ protected $mSearchFilter; + /** + * @var string The visibility of entries we're interested in + */ protected $mSearchEntries; + /** + * @var string The impact of the user action, i.e. if the change has been saved + */ protected $mSearchImpact; - public function __construct() { + /** @var string The filter group to search, as defined in $wgAbuseFilterValidGroups */ + protected $mSearchGroup; + + /** @var LinkBatchFactory */ + private $linkBatchFactory; + + /** @var PermissionManager */ + private $permissionManager; + + /** + * @param LinkBatchFactory $linkBatchFactory + * @param PermissionManager $permissionManager + */ + public function __construct( LinkBatchFactory $linkBatchFactory, PermissionManager $permissionManager ) { parent::__construct( 'AbuseLog', 'abusefilter-log' ); + $this->linkBatchFactory = $linkBatchFactory; + $this->permissionManager = $permissionManager; } /** - * @return bool + * @inheritDoc */ public function doesWrites() { return true; } /** + * @inheritDoc + */ + protected function getGroupName() { + return 'changes'; + } + + /** * Main routine * * $parameter string is converted into the $args array, which can come in @@ -66,41 +113,29 @@ class SpecialAbuseLog extends SpecialPage { * 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 + * @param string|null $parameter URL parameters */ public function execute( $parameter ) { $out = $this->getOutput(); $request = $this->getRequest(); - AbuseFilter::addNavigationLinks( - $this->getContext(), 'log', $this->getLinkRenderer() ); + $this->addNavigationLinks( 'log' ); $this->setHeaders(); - $this->outputHeader( 'abusefilter-log-summary' ); + $this->addHelpLink( 'Extension:AbuseFilter' ); $this->loadParameters(); - $out->setPageTitle( $this->msg( 'abusefilter-log' ) ); - $out->setRobotPolicy( "noindex,nofollow" ); - $out->setArticleRelated( false ); $out->enableClientCache( false ); $out->addModuleStyles( 'ext.abuseFilter' ); - // Are we allowed? - $errors = $this->getPageTitle()->getUserPermissionsErrors( - 'abusefilter-log', $this->getUser(), true, [ 'ns-specialprotected' ] ); - if ( count( $errors ) ) { - $out->showPermissionsErrorPage( $errors, 'abusefilter-log' ); - - return; - } + $this->checkPermissions(); - $detailsid = $request->getIntOrNull( 'details' ); $hideid = $request->getIntOrNull( 'hide' ); $args = explode( '/', $parameter ); if ( count( $args ) === 2 && $args[0] === 'private' ) { - $this->showPrivateDetails( $args[1] ); + $this->showPrivateDetails( (int)$args[1] ); } elseif ( count( $args ) === 1 && $args[0] !== '' ) { if ( $args[0] === 'private' ) { $out->addWikiMsg( 'abusefilter-invalid-request-noid' ); @@ -133,10 +168,13 @@ class SpecialAbuseLog extends SpecialPage { $this->mSearchPeriodStart = $request->getText( 'wpSearchPeriodStart' ); $this->mSearchPeriodEnd = $request->getText( 'wpSearchPeriodEnd' ); $this->mSearchTitle = $request->getText( 'wpSearchTitle' ); + if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ) { + $this->mSearchGroup = $request->getText( 'wpSearchGroup' ); + } $this->mSearchFilter = null; $this->mSearchAction = $request->getText( 'wpSearchAction' ); $this->mSearchActionTaken = $request->getText( 'wpSearchActionTaken' ); - if ( self::canSeeDetails() ) { + if ( self::canSeeDetails( $this->getUser() ) ) { $this->mSearchFilter = $request->getText( 'wpSearchFilter' ); } @@ -176,6 +214,7 @@ class SpecialAbuseLog extends SpecialPage { * Builds the search form */ public function searchForm() { + $user = $this->getUser(); $formDescriptor = [ 'SearchUser' => [ 'label-message' => 'abusefilter-log-search-user', @@ -211,29 +250,36 @@ class SpecialAbuseLog extends SpecialPage { ]; $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'; + ksort( $actions ); + $actions = array_merge( + [ $this->msg( 'abusefilter-log-search-action-any' )->text() => 'any' ], + $actions, + [ $this->msg( 'abusefilter-log-search-action-other' )->text() => 'other' ] + ); $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() => '', - ]; + $options = []; + $context = $this->getContext(); foreach ( $this->getAllActions() as $action ) { - $key = AbuseFilter::getActionDisplay( $action ); + $key = AbuseFilter::getActionDisplay( $action, $context ); $options[$key] = $action; } ksort( $options ); + $options = array_merge( + [ $this->msg( 'abusefilter-log-search-action-taken-any' )->text() => '' ], + $options, + [ $this->msg( 'abusefilter-log-noactions-filter' )->text() => 'noactions' ] + ); $formDescriptor['SearchActionTaken'] = [ 'label-message' => 'abusefilter-log-search-action-taken-label', 'type' => 'select', 'options' => $options, ]; - if ( self::canSeeHidden() ) { + if ( self::canSeeHidden( $user ) ) { $formDescriptor['SearchEntries'] = [ 'type' => 'select', 'label-message' => 'abusefilter-log-search-entries-label', @@ -244,15 +290,34 @@ class SpecialAbuseLog extends SpecialPage { ], ]; } - if ( self::canSeeDetails() ) { + + $groups = $this->getConfig()->get( 'AbuseFilterValidGroups' ); + if ( count( $groups ) > 1 ) { + $options = array_merge( + [ $this->msg( 'abusefilter-log-search-group-any' )->text() => 0 ], + array_combine( $groups, $groups ) + ); + $formDescriptor['SearchGroup'] = [ + 'label-message' => 'abusefilter-log-search-group', + 'type' => 'select', + 'options' => $options + ]; + } + + if ( self::canSeeDetails( $user ) ) { + $helpmsg = $this->getConfig()->get( 'AbuseFilterIsCentral' ) + ? $this->msg( 'abusefilter-log-search-filter-help-central' )->escaped() + : $this->msg( 'abusefilter-log-search-filter-help' ) + ->params( AbuseFilter::GLOBAL_FILTER_PREFIX )->escaped(); $formDescriptor['SearchFilter'] = [ 'label-message' => 'abusefilter-log-search-filter', 'type' => 'text', 'default' => $this->mSearchFilter, + 'help' => $helpmsg ]; } if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) { - // Add free form input for wiki name. Would be nice to generate + // @todo 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'] = [ 'label-message' => 'abusefilter-log-search-wiki', @@ -265,16 +330,17 @@ class SpecialAbuseLog extends SpecialPage { ->setWrapperLegendMsg( 'abusefilter-log-search' ) ->setSubmitTextMsg( 'abusefilter-log-search-submit' ) ->setMethod( 'get' ) + ->setCollapsibleOptions( true ) ->prepareForm() ->displayForm( false ); } /** - * @param string $id + * @param int $id */ public function showHideForm( $id ) { $output = $this->getOutput(); - if ( !$this->getUser()->isAllowed( 'abusefilter-hide-log' ) ) { + if ( !$this->permissionManager->userHasRight( $this->getUser(), 'abusefilter-hide-log' ) ) { $output->addWikiMsg( 'abusefilter-log-hide-forbidden' ); return; @@ -282,21 +348,20 @@ class SpecialAbuseLog extends SpecialPage { $dbr = wfGetDB( DB_REPLICA ); - $row = $dbr->selectRow( - [ 'abuse_filter_log', 'abuse_filter' ], + $deleted = $dbr->selectField( + 'abuse_filter_log', 'afl_deleted', [ 'afl_id' => $id ], - __METHOD__, - [], - [ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ] + __METHOD__ ); - if ( !$row ) { + if ( $deleted === false ) { + $output->addWikiMsg( 'abusefilter-log-nonexistent' ); return; } $hideReasonsOther = $this->msg( 'revdelete-reasonotherlist' )->text(); - $hideReasons = $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->text(); + $hideReasons = $this->msg( 'revdelete-reason-dropdown-suppress' )->inContentLanguage()->text(); $hideReasons = Xml::listDropDownOptions( $hideReasons, [ 'other' => $hideReasonsOther ] ); $formInfo = [ @@ -316,7 +381,7 @@ class SpecialAbuseLog extends SpecialPage { ], 'hidden' => [ 'type' => 'toggle', - 'default' => $row->afl_deleted, + 'default' => $deleted, 'label-message' => 'abusefilter-log-hide-hidden', ], ]; @@ -331,7 +396,7 @@ class SpecialAbuseLog extends SpecialPage { // 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 ) ); + LogEventsList::showLogExtract( $output, 'suppress', $this->getPageTitle( (string)$id ) ); } /** @@ -375,19 +440,21 @@ class SpecialAbuseLog extends SpecialPage { */ public function showList() { $out = $this->getOutput(); + $user = $this->getUser(); + $this->outputHeader( 'abusefilter-log-summary' ); // Generate conditions list. $conds = []; if ( $this->mSearchUser ) { - $user = User::newFromName( $this->mSearchUser ); + $searchedUser = User::newFromName( $this->mSearchUser ); - if ( !$user ) { + if ( !$searchedUser ) { $conds['afl_user'] = 0; $conds['afl_user_text'] = $this->mSearchUser; } else { - $conds['afl_user'] = $user->getId(); - $conds['afl_user_text'] = $user->getName(); + $conds['afl_user'] = $searchedUser->getId(); + $conds['afl_user_text'] = $searchedUser->getName(); } } @@ -403,24 +470,58 @@ class SpecialAbuseLog extends SpecialPage { } if ( $this->mSearchWiki ) { - if ( $this->mSearchWiki == wfWikiID() ) { + if ( $this->mSearchWiki === WikiMap::getCurrentWikiDbDomain()->getId() ) { $conds['afl_wiki'] = null; } else { $conds['afl_wiki'] = $this->mSearchWiki; } } + $groupFilters = []; + if ( $this->mSearchGroup ) { + $groupFilters = $dbr->selectFieldValues( + 'abuse_filter', + 'af_id', + [ 'af_group' => $this->mSearchGroup ], + __METHOD__ + ); + } + + $searchFilters = []; if ( $this->mSearchFilter ) { - $searchFilters = array_map( 'trim', explode( '|', $this->mSearchFilter ) ); + $rawFilters = array_map( 'trim', explode( '|', $this->mSearchFilter ) ); + // Map of [ [ id, global ], ... ] + $filtersList = []; + $foundInvalid = false; + foreach ( $rawFilters as $filter ) { + try { + $filtersList[] = AbuseFilter::splitGlobalName( $filter ); + } catch ( InvalidArgumentException $e ) { + $foundInvalid = true; + continue; + } + } + + // @phan-suppress-next-line PhanImpossibleCondition + if ( $foundInvalid ) { + $out->addHTML( + Html::rawElement( + 'p', + [], + Html::warningBox( $this->msg( 'abusefilter-log-invalid-filter' )->escaped() ) + ) + ); + } + // if a filter is hidden, users who can't view private filters should // not be able to find log entries generated by it. - if ( !AbuseFilterView::canViewPrivate() - && !$this->getUser()->isAllowed( 'abusefilter-log-private' ) + if ( !AbuseFilter::canViewPrivate( $user ) + && !$this->permissionManager->userHasRight( $user, 'abusefilter-log-private' ) ) { $searchedForPrivate = false; - foreach ( $searchFilters as $index => $filter ) { - if ( AbuseFilter::filterHidden( $filter ) ) { - unset( $searchFilters[$index] ); + foreach ( $filtersList as $index => $filterData ) { + if ( AbuseFilter::filterHidden( ...$filterData ) ) { + unset( $filtersList[$index] ); $searchedForPrivate = true; } } @@ -428,12 +529,28 @@ class SpecialAbuseLog extends SpecialPage { $out->addWikiMsg( 'abusefilter-log-private-not-included' ); } } - if ( empty( $searchFilters ) ) { - $out->addWikiMsg( 'abusefilter-log-noresults' ); + foreach ( $filtersList as $filterData ) { + $searchFilters[] = AbuseFilter::buildGlobalName( ...$filterData ); + } + } + + $searchIDs = null; + if ( $this->mSearchGroup && !$this->mSearchFilter ) { + $searchIDs = $groupFilters; + } elseif ( !$this->mSearchGroup && $this->mSearchFilter ) { + $searchIDs = $searchFilters; + } elseif ( $this->mSearchGroup && $this->mSearchFilter ) { + $searchIDs = array_intersect( $groupFilters, $searchFilters ); + } + + if ( $searchIDs !== null ) { + if ( !count( $searchIDs ) ) { + $out->addWikiMsg( 'abusefilter-log-noresults' ); return; } - $conds['afl_filter'] = $searchFilters; + + $conds['afl_filter'] = $searchIDs; } $searchTitle = Title::newFromText( $this->mSearchTitle ); @@ -442,20 +559,17 @@ class SpecialAbuseLog extends SpecialPage { $conds['afl_title'] = $searchTitle->getDBkey(); } - if ( self::canSeeHidden() ) { - if ( $this->mSearchEntries == '1' ) { + if ( self::canSeeHidden( $user ) ) { + if ( $this->mSearchEntries === '1' ) { $conds['afl_deleted'] = 1; - } elseif ( $this->mSearchEntries == '2' ) { - $conds[] = self::getNotDeletedCond( $dbr ); + } elseif ( $this->mSearchEntries === '2' ) { + $conds['afl_deleted'] = 0; } } if ( in_array( $this->mSearchImpact, [ '1', '2' ] ) ) { - $unsuccessfulActionConds = $dbr->makeList( [ - 'afl_rev_id' => null, - 'afl_log_id' => null, - ], LIST_AND ); - if ( $this->mSearchImpact == '1' ) { + $unsuccessfulActionConds = 'afl_rev_id IS NULL'; + if ( $this->mSearchImpact === '1' ) { $conds[] = "NOT ( $unsuccessfulActionConds )"; } else { $conds[] = $unsuccessfulActionConds; @@ -490,7 +604,12 @@ class SpecialAbuseLog extends SpecialPage { } } - $pager = new AbuseLogPager( $this, $conds ); + $pager = new AbuseLogPager( + $this, + $conds, + $this->linkBatchFactory, + $this->canSeeUndeleteDiffs() + ); $pager->doQuery(); $result = $pager->getResult(); if ( $result && $result->numRows() !== 0 ) { @@ -503,51 +622,65 @@ class SpecialAbuseLog extends SpecialPage { } /** - * @param string $id + * @param string|int $id + * @suppress SecurityCheck-SQLInjection */ public function showDetails( $id ) { $out = $this->getOutput(); + $user = $this->getUser(); - $dbr = wfGetDB( DB_REPLICA ); + $pager = new AbuseLogPager( + $this, + [], + $this->linkBatchFactory, + $this->canSeeUndeleteDiffs() + ); + [ + 'tables' => $tables, + 'fields' => $fields, + 'join_conds' => $join_conds, + ] = $pager->getQueryInfo(); + + $dbr = wfGetDB( DB_REPLICA ); $row = $dbr->selectRow( - [ 'abuse_filter_log', 'abuse_filter' ], - '*', + $tables, + $fields, [ 'afl_id' => $id ], __METHOD__, [], - [ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ] + $join_conds ); + $error = null; if ( !$row ) { - $out->addWikiMsg( 'abusefilter-log-nonexistent' ); - - return; - } - - if ( AbuseFilter::decodeGlobalName( $row->afl_filter ) ) { - $filter_hidden = null; + $error = 'abusefilter-log-nonexistent'; } else { - $filter_hidden = $row->af_hidden; - } - - if ( !self::canSeeDetails( $row->afl_filter, $filter_hidden ) ) { - $out->addWikiMsg( 'abusefilter-log-cannot-see-details' ); + list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter ); + if ( $global ) { + $filter_hidden = null; + } else { + $filter_hidden = $row->af_hidden; + } - return; + if ( !self::canSeeDetails( $user, $filterID, $global, $filter_hidden ) ) { + $error = 'abusefilter-log-cannot-see-details'; + } elseif ( self::isHidden( $row ) === true && !self::canSeeHidden( $user ) ) { + $error = 'abusefilter-log-details-hidden'; + } elseif ( self::isHidden( $row ) === 'implicit' ) { + $revRec = MediaWikiServices::getInstance() + ->getRevisionLookup() + ->getRevisionById( (int)$row->afl_rev_id ); + if ( !AbuseFilter::userCanViewRev( $revRec, $user ) ) { + // The log is visible, but refers to a deleted revision + $error = 'abusefilter-log-details-hidden-implicit'; + } + } } - if ( self::isHidden( $row ) === true && !self::canSeeHidden() ) { - $out->addWikiMsg( 'abusefilter-log-details-hidden' ); - + if ( $error ) { + $out->addWikiMsg( $error ); return; - } elseif ( self::isHidden( $row ) === 'implicit' ) { - $rev = Revision::newFromId( $row->afl_rev_id ); - // The log is visible, but refers to a deleted revision - if ( !$rev->userCan( Revision::SUPPRESSED_ALL, $this->getUser() ) ) { - $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' ); - return; - } } $output = Xml::element( @@ -564,7 +697,8 @@ class SpecialAbuseLog extends SpecialPage { $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) ); // Diff, if available - if ( $vars && $vars->getVar( 'action' )->toString() == 'edit' ) { + if ( $row->afl_action === 'edit' ) { + $vars->setLogger( LoggerFactory::getInstance( 'AbuseFilter' ) ); $old_wikitext = $vars->getVar( 'old_wikitext' )->toString(); $new_wikitext = $vars->getVar( 'new_wikitext' )->toString(); @@ -592,19 +726,19 @@ class SpecialAbuseLog extends SpecialPage { // Build a table. $output .= AbuseFilter::buildVarDumpTable( $vars, $this->getContext() ); - if ( self::canSeePrivate() ) { + if ( self::canSeePrivateDetails( $user ) ) { $formDescriptor = [ 'Reason' => [ - 'label-message' => 'abusefilter-view-private-reason', + 'label-message' => 'abusefilter-view-privatedetails-reason', 'type' => 'text', 'size' => 45, ], ]; $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); - $htmlForm->setWrapperLegendMsg( 'abusefilter-view-private' ) + $htmlForm->setWrapperLegendMsg( 'abusefilter-view-privatedetails-legend' ) ->setAction( $this->getPageTitle( 'private/' . $id )->getLocalURL() ) - ->setSubmitTextMsg( 'abusefilter-view-private-submit' ) + ->setSubmitTextMsg( 'abusefilter-view-privatedetails-submit' ) ->setMethod( 'post' ) ->prepareForm(); @@ -615,38 +749,54 @@ class SpecialAbuseLog extends SpecialPage { } /** - * @param string $id - * @return void + * Can this user see diffs generated by Special:Undelete? + * @see \SpecialUndelete + * + * @return bool */ - public function showPrivateDetails( $id ) { - $lang = $this->getLanguage(); - $out = $this->getOutput(); - $request = $this->getRequest(); - - $dbr = wfGetDB( DB_REPLICA ); - - $reason = $request->getText( 'wpReason' ); + private function canSeeUndeleteDiffs() : bool { + if ( !$this->permissionManager->userHasRight( $this->getUser(), 'deletedhistory' ) ) { + return false; + } - // 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 $this->permissionManager->userHasAnyRight( + $this->getUser(), 'deletedtext', 'undelete' ); + } - return; + /** + * Can this user see diffs generated by Special:Undelete for the page? + * @see \SpecialUndelete + * @param LinkTarget $page + * + * @return bool + */ + private function canSeeUndeleteDiffForPage( LinkTarget $page ) : bool { + if ( !$this->canSeeUndeleteDiffs() ) { + return false; } - if ( !$this->checkReason( $reason ) ) { - $out->addWikiMsg( 'abusefilter-noreason' ); - $this->showDetails( $id ); - return; + foreach ( [ 'deletedtext', 'undelete' ] as $action ) { + if ( $this->permissionManager->userCan( + $action, $this->getUser(), $page, PermissionManager::RIGOR_QUICK + ) ) { + return true; + } } + return false; + } + + /** + * Helper function to select a row with private details and some more context + * for an AbuseLog entry. + * + * @param User $user The user who's trying to view the row + * @param int $id The ID of the log entry + * @return Status A status object with the requested row stored in the value property, + * or an error and no row. + */ + public static function getPrivateDetailsRow( User $user, $id ) { + $dbr = wfGetDB( DB_REPLICA ); $row = $dbr->selectRow( [ 'abuse_filter_log', 'abuse_filter' ], [ 'afl_id', 'afl_filter', 'afl_user_text', 'afl_timestamp', 'afl_ip', 'af_id', @@ -657,41 +807,38 @@ class SpecialAbuseLog extends SpecialPage { [ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ] ); + $status = Status::newGood(); if ( !$row ) { - $out->addWikiMsg( 'abusefilter-log-nonexistent' ); - - return; + $status->fatal( 'abusefilter-log-nonexistent' ); + return $status; } - if ( AbuseFilter::decodeGlobalName( $row->afl_filter ) ) { - $filter_hidden = null; + list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter ); + if ( $global ) { + $filterHidden = 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; + $filterHidden = $row->af_hidden; } - // Log accessing private details - if ( $this->getConfig()->get( 'AbuseFilterPrivateLog' ) ) { - $user = $this->getUser(); - self::addLogEntry( $id, $reason, $user ); + if ( !self::canSeeDetails( $user, $filterID, $global, $filterHidden ) ) { + $status->fatal( 'abusefilter-log-cannot-see-details' ); + return $status; } + $status->setResult( true, $row ); + return $status; + } - // Show private details (IP). + /** + * Builds an HTML table with the private details for a given abuseLog entry. + * + * @param stdClass $row The row, as returned by self::getPrivateDetailsRow() + * @return string The HTML output + */ + protected function buildPrivateDetailsTable( $row ) { $output = Xml::element( 'legend', null, - $this->msg( 'abusefilter-log-details-private' )->text() + $this->msg( 'abusefilter-log-details-privatedetails' )->text() ); $header = @@ -719,7 +866,7 @@ class SpecialAbuseLog extends SpecialPage { Xml::openElement( 'td' ) . $linkRenderer->makeKnownLink( $this->getPageTitle( $row->afl_id ), - $lang->formatNum( $row->afl_id ) + $this->getLanguage()->formatNum( $row->afl_id ) ) . Xml::closeElement( 'td' ) ); @@ -733,7 +880,7 @@ class SpecialAbuseLog extends SpecialPage { ) . Xml::element( 'td', null, - $lang->timeanddate( $row->afl_timestamp, true ) + $this->getLanguage()->timeanddate( $row->afl_timestamp, true ) ) ); @@ -760,7 +907,7 @@ class SpecialAbuseLog extends SpecialPage { Xml::openElement( 'td' ) . $linkRenderer->makeKnownLink( SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ), - $lang->formatNum( $row->af_id ) + $this->getLanguage()->formatNum( $row->af_id ) ) . Xml::closeElement( 'td' ) ); @@ -781,14 +928,15 @@ class SpecialAbuseLog extends SpecialPage { // 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() - ); + $this->permissionManager->userHasRight( $this->getUser(), 'checkuser' ) + ) { + $CULink = ' · ' . $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( + 'CheckUser', + $row->afl_ip + ), + $this->msg( 'abusefilter-log-details-checkuser' )->text() + ); } else { $CULink = ''; } @@ -822,8 +970,60 @@ class SpecialAbuseLog extends SpecialPage { $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); $output = Xml::tags( 'fieldset', null, $output ); + return $output; + } - $out->addHTML( $output ); + /** + * @param int $id + * @return void + */ + public function showPrivateDetails( $id ) { + $out = $this->getOutput(); + $user = $this->getUser(); + + if ( !self::canSeePrivateDetails( $user ) ) { + $out->addWikiMsg( 'abusefilter-log-cannot-see-privatedetails' ); + + return; + } + $request = $this->getRequest(); + + // Make sure it is a valid request + $token = $request->getVal( 'wpEditToken' ); + if ( !$request->wasPosted() || !$user->matchEditToken( $token ) ) { + $out->addHTML( + Xml::tags( + 'p', + null, + Html::errorBox( $this->msg( 'abusefilter-invalid-request' )->params( $id )->parse() ) + ) + ); + + return; + } + + $reason = $request->getText( 'wpReason' ); + if ( !self::checkPrivateDetailsAccessReason( $reason ) ) { + $out->addWikiMsg( 'abusefilter-noreason' ); + $this->showDetails( $id ); + return; + } + + $status = self::getPrivateDetailsRow( $user, $id ); + if ( !$status->isGood() ) { + $out->addWikiMsg( $status->getErrors()[0] ); + return; + } + $row = $status->getValue(); + + // Log accessing private details + if ( $this->getConfig()->get( 'AbuseFilterLogPrivateDetailsAccess' ) ) { + self::addPrivateDetailsAccessLogEntry( $id, $reason, $user ); + } + + // Show private details (IP). + $table = $this->buildPrivateDetailsTable( $row ); + $out->addHTML( $table ); } /** @@ -833,8 +1033,9 @@ class SpecialAbuseLog extends SpecialPage { * @param string $reason * @return bool */ - protected function checkReason( $reason ) { - return ( !$this->getConfig()->get( 'AbuseFilterForceSummary' ) || strlen( $reason ) > 0 ); + public static function checkPrivateDetailsAccessReason( $reason ) { + global $wgAbuseFilterPrivateDetailsForceReason; + return ( !$wgAbuseFilterPrivateDetailsForceReason || strlen( $reason ) > 0 ); } /** @@ -843,8 +1044,8 @@ class SpecialAbuseLog extends SpecialPage { * @param User $user The user who accessed the private details * @return void */ - public static function addLogEntry( $logID, $reason, $user ) { - $target = self::getTitleFor( 'AbuseLog', $logID ); + public static function addPrivateDetailsAccessLogEntry( $logID, $reason, User $user ) { + $target = self::getTitleFor( 'AbuseLog', (string)$logID ); $logEntry = new ManualLogEntry( 'abusefilterprivatedetails', 'access' ); $logEntry->setPerformer( $user ); @@ -858,49 +1059,50 @@ class SpecialAbuseLog extends SpecialPage { } /** - * @param string|null $filter_id - * @param bool|int|null $filter_hidden + * @param User $user + * @param int|null $id The ID of the filter + * @param bool|int|null $global Whether the filter is global + * @param bool|int|null $hidden Whether the filter is hidden * @return bool */ - public static function canSeeDetails( $filter_id = null, $filter_hidden = null ) { - global $wgUser; - - if ( $filter_id !== null ) { - if ( $filter_hidden === null ) { - $filter_hidden = AbuseFilter::filterHidden( $filter_id ); + public static function canSeeDetails( User $user, $id = null, $global = false, $hidden = null ) { + $pm = MediaWikiServices::getInstance()->getPermissionManager(); + if ( $id !== null ) { + if ( $hidden === null ) { + $hidden = AbuseFilter::filterHidden( $id, $global ); } - if ( $filter_hidden ) { - return $wgUser->isAllowed( 'abusefilter-log-detail' ) && ( - AbuseFilterView::canViewPrivate() || $wgUser->isAllowed( 'abusefilter-log-private' ) - ); + if ( $hidden ) { + return $pm->userHasRight( $user, 'abusefilter-log-detail' ) + && ( AbuseFilter::canViewPrivate( $user ) || + $pm->userHasRight( $user, 'abusefilter-log-private' ) ); } } - return $wgUser->isAllowed( 'abusefilter-log-detail' ); + return $pm->userHasRight( $user, 'abusefilter-log-detail' ); } /** + * @param UserIdentity $user * @return bool */ - public static function canSeePrivate() { - global $wgUser; - - return $wgUser->isAllowed( 'abusefilter-private' ); + public static function canSeePrivateDetails( UserIdentity $user ) { + return MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $user, 'abusefilter-privatedetails' ); } /** + * @param User $user * @return bool */ - public static function canSeeHidden() { - global $wgUser; - - return $wgUser->isAllowed( 'abusefilter-hidden-log' ); + public static function canSeeHidden( User $user ) { + return MediaWikiServices::getInstance()->getPermissionManager() + ->userHasRight( $user, 'abusefilter-hidden-log' ); } /** * @param stdClass $row * @param bool $isListItem - * @return String + * @return string */ public function formatRow( $row, $isListItem = true ) { $user = $this->getUser(); @@ -913,20 +1115,42 @@ class SpecialAbuseLog extends SpecialPage { $diffLink = false; $isHidden = self::isHidden( $row ); - if ( !self::canSeeHidden() && $isHidden === true ) { + // @todo T224203 Try to show the details if the revision is deleted but the AbuseLog entry + // is not. However, watch out to avoid showing too much stuff. + if ( !self::canSeeHidden( $user ) && $isHidden ) { return ''; } $linkRenderer = $this->getLinkRenderer(); if ( !$row->afl_wiki ) { - $pageLink = $linkRenderer->makeLink( $title ); - if ( $row->afl_rev_id && $title->exists() ) { + $pageLink = $linkRenderer->makeLink( + $title, + null, + [], + [ 'redirect' => 'no' ] + ); + if ( $row->rev_id ) { $diffLink = $linkRenderer->makeKnownLink( $title, new HtmlArmor( $this->msg( 'abusefilter-log-diff' )->parse() ), [], - [ 'diff' => 'prev', 'oldid' => $row->afl_rev_id ] ); + [ 'diff' => 'prev', 'oldid' => $row->rev_id ] + ); + } elseif ( + isset( $row->ar_timestamp ) && $row->ar_timestamp + && $this->canSeeUndeleteDiffForPage( $title ) + ) { + $diffLink = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Undelete' ), + new HtmlArmor( $this->msg( 'abusefilter-log-diff' )->parse() ), + [], + [ + 'diff' => 'prev', + 'target' => $title->getPrefixedText(), + 'timestamp' => $row->ar_timestamp, + ] + ); } } else { $pageLink = WikiMap::makeForeignLink( $row->afl_wiki, $row->afl_title ); @@ -937,7 +1161,7 @@ class SpecialAbuseLog extends SpecialPage { [ 'diff' => 'prev', 'oldid' => $row->afl_rev_id ] ); $diffLink = Linker::makeExternalLink( $diffUrl, - $this->msg( 'abusefilter-log-diff' )->parse() ); + $this->msg( 'abusefilter-log-diff' )->text() ); } } @@ -949,27 +1173,28 @@ class SpecialAbuseLog extends SpecialPage { $userLink .= ' (' . WikiMap::getWikiName( $row->afl_wiki ) . ')'; } - $timestamp = $lang->timeanddate( $row->afl_timestamp, true ); + $timestamp = htmlspecialchars( $lang->timeanddate( $row->afl_timestamp, true ) ); - $actions_taken = $row->afl_actions; - if ( !strlen( trim( $actions_taken ) ) ) { + $actions_takenRaw = $row->afl_actions; + if ( !strlen( trim( $actions_takenRaw ) ) ) { $actions_taken = $this->msg( 'abusefilter-log-noactions' )->escaped(); } else { - $actions = explode( ',', $actions_taken ); + $actions = explode( ',', $actions_takenRaw ); $displayActions = []; + $context = $this->getContext(); foreach ( $actions as $action ) { - $displayActions[] = AbuseFilter::getActionDisplay( $action ); + $displayActions[] = AbuseFilter::getActionDisplay( $action, $context ); } $actions_taken = $lang->commaList( $displayActions ); } - $globalIndex = AbuseFilter::decodeGlobalName( $row->afl_filter ); + list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter ); - if ( $globalIndex ) { + if ( $global ) { // Pull global filter description $escaped_comments = Sanitizer::escapeHtmlAllowEntities( - AbuseFilter::getGlobalFilterDescription( $globalIndex ) ); + AbuseFilter::getGlobalFilterDescription( $filterID ) ); $filter_hidden = null; } else { $escaped_comments = Sanitizer::escapeHtmlAllowEntities( @@ -977,7 +1202,7 @@ class SpecialAbuseLog extends SpecialPage { $filter_hidden = $row->af_hidden; } - if ( self::canSeeDetails( $row->afl_filter, $filter_hidden ) ) { + if ( self::canSeeDetails( $user, $filterID, $global, $filter_hidden ) ) { if ( $isListItem ) { $detailsLink = $linkRenderer->makeKnownLink( $this->getPageTitle( $row->afl_id ), @@ -997,7 +1222,7 @@ class SpecialAbuseLog extends SpecialPage { $actionLinks[] = $diffLink; } - if ( $user->isAllowed( 'abusefilter-hide-log' ) ) { + if ( $this->permissionManager->userHasRight( $user, 'abusefilter-hide-log' ) ) { $hideLink = $linkRenderer->makeKnownLink( $this->getPageTitle(), $this->msg( 'abusefilter-log-hidelink' )->text(), @@ -1008,25 +1233,25 @@ class SpecialAbuseLog extends SpecialPage { $actionLinks[] = $hideLink; } - if ( $globalIndex ) { + if ( $global ) { $globalURL = WikiMap::getForeignURL( $this->getConfig()->get( 'AbuseFilterCentralDB' ), - 'Special:AbuseFilter/' . $globalIndex + 'Special:AbuseFilter/' . $filterID ); $linkText = $this->msg( 'abusefilter-log-detailedentry-global' ) - ->numParams( $globalIndex )->escaped(); + ->numParams( $filterID )->text(); $filterLink = Linker::makeExternalLink( $globalURL, $linkText ); } else { - $title = SpecialPage::getTitleFor( 'AbuseFilter', $row->afl_filter ); + $title = SpecialPage::getTitleFor( 'AbuseFilter', $filterID ); $linkText = $this->msg( 'abusefilter-log-detailedentry-local' ) - ->numParams( $row->afl_filter )->text(); + ->numParams( $filterID )->text(); $filterLink = $linkRenderer->makeKnownLink( $title, $linkText ); } $description = $this->msg( 'abusefilter-log-detailedentry-meta' )->rawParams( $timestamp, $userLink, $filterLink, - $row->afl_action, + htmlspecialchars( $row->afl_action ), $pageLink, $actions_taken, $escaped_comments, @@ -1041,7 +1266,7 @@ class SpecialAbuseLog extends SpecialPage { $description = $this->msg( $msg )->rawParams( $timestamp, $userLink, - $row->afl_action, + htmlspecialchars( $row->afl_action ), $pageLink, $actions_taken, $escaped_comments, @@ -1050,19 +1275,18 @@ class SpecialAbuseLog extends SpecialPage { )->params( $row->afl_user_text )->parse(); } + $attribs = null; if ( $isHidden === true ) { - $description .= ' ' . - $this->msg( 'abusefilter-log-hidden' )->parse(); - $class = 'afl-hidden'; + $attribs = [ 'class' => 'mw-abusefilter-log-hidden-entry' ]; } elseif ( $isHidden === 'implicit' ) { $description .= ' ' . $this->msg( 'abusefilter-log-hidden-implicit' )->parse(); } if ( $isListItem ) { - return Xml::tags( 'li', isset( $class ) ? [ 'class' => $class ] : null, $description ); + return Xml::tags( 'li', $attribs, $description ); } else { - return Xml::tags( 'span', isset( $class ) ? [ 'class' => $class ] : null, $description ); + return Xml::tags( 'span', $attribs, $description ); } } @@ -1083,21 +1307,6 @@ class SpecialAbuseLog extends SpecialPage { } /** - * @param \Wikimedia\Rdbms\IDatabase $db - * @return string - */ - public static function getNotDeletedCond( $db ) { - $deletedZeroCond = $db->makeList( - [ 'afl_deleted' => 0 ], LIST_AND ); - $deletedNullCond = $db->makeList( - [ 'afl_deleted' => null ], LIST_AND ); - $notDeletedCond = $db->makeList( - [ $deletedZeroCond, $deletedNullCond ], LIST_OR ); - - return $notDeletedCond; - } - - /** * Given a log entry row, decides whether or not it can be viewed by the public. * * @param stdClass $row The abuse_filter_log row object. @@ -1112,19 +1321,14 @@ class SpecialAbuseLog extends SpecialPage { return true; } if ( $row->afl_rev_id ) { - $revision = Revision::newFromId( $row->afl_rev_id ); - if ( $revision && $revision->getVisibility() != 0 ) { + $revision = MediaWikiServices::getInstance() + ->getRevisionLookup() + ->getRevisionById( $row->afl_rev_id ); + if ( $revision && $revision->getVisibility() !== 0 ) { return 'implicit'; } } return false; } - - /** - * @return string - */ - protected function getGroupName() { - return 'changes'; - } } |