diff options
Diffstat (limited to 'www/wiki/extensions/AbuseFilter/includes')
42 files changed, 13619 insertions, 0 deletions
diff --git a/www/wiki/extensions/AbuseFilter/includes/AFComputedVariable.php b/www/wiki/extensions/AbuseFilter/includes/AFComputedVariable.php new file mode 100644 index 00000000..43dac6a1 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/AFComputedVariable.php @@ -0,0 +1,445 @@ +<?php + +use Wikimedia\Rdbms\Database; +use MediaWiki\MediaWikiServices; + +class AFComputedVariable { + public $mMethod, $mParameters; + public static $userCache = []; + public static $articleCache = []; + + /** + * @param string $method + * @param array $parameters + */ + function __construct( $method, $parameters ) { + $this->mMethod = $method; + $this->mParameters = $parameters; + } + + /** + * It's like Article::prepareContentForEdit, but not for editing (old wikitext usually) + * + * + * @param string $wikitext + * @param WikiPage $article + * + * @return object + */ + function parseNonEditWikitext( $wikitext, $article ) { + static $cache = []; + + $cacheKey = md5( $wikitext ) . ':' . $article->getTitle()->getPrefixedText(); + + if ( isset( $cache[$cacheKey] ) ) { + return $cache[$cacheKey]; + } + + global $wgParser; + $edit = (object)[]; + $options = new ParserOptions; + $options->setTidy( true ); + $edit->output = $wgParser->parse( $wikitext, $article->getTitle(), $options ); + $cache[$cacheKey] = $edit; + + return $edit; + } + + /** + * For backwards compatibility: Get the user object belonging to a certain name + * in case a user name is given as argument. Nowadays user objects are passed + * directly but many old log entries rely on this. + * + * @param string|User $user + * @return User + */ + static function getUserObject( $user ) { + if ( $user instanceof User ) { + $username = $user->getName(); + } else { + $username = $user; + if ( isset( self::$userCache[$username] ) ) { + return self::$userCache[$username]; + } + + wfDebug( "Couldn't find user $username in cache\n" ); + } + + if ( count( self::$userCache ) > 1000 ) { + self::$userCache = []; + } + + 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; + } + + $user = User::newFromName( $username ); + $user->load(); + self::$userCache[$username] = $user; + + return $user; + } + + /** + * @param int $namespace + * @param Title $title + * @return Article + */ + static function articleFromTitle( $namespace, $title ) { + if ( isset( self::$articleCache["$namespace:$title"] ) ) { + return self::$articleCache["$namespace:$title"]; + } + + if ( count( self::$articleCache ) > 1000 ) { + self::$articleCache = []; + } + + wfDebug( "Creating article object for $namespace:$title in cache\n" ); + + // TODO: use WikiPage instead! + $t = Title::makeTitle( $namespace, $title ); + self::$articleCache["$namespace:$title"] = new Article( $t ); + + return self::$articleCache["$namespace:$title"]; + } + + /** + * @param WikiPage $article + * @return array + */ + static function getLinksFromDB( $article ) { + // Stolen from ConfirmEdit + $id = $article->getId(); + if ( !$id ) { + return []; + } + + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( + 'externallinks', + [ '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 + * @throws MWException + * @throws AFPException + */ + function compute( $vars ) { + $parameters = $this->mParameters; + $result = null; + + if ( !Hooks::run( 'AbuseFilter-interceptVariable', + [ $this->mMethod, $vars, $parameters, &$result ] ) ) { + return $result instanceof AFPData + ? $result : AFPData::newFromPHPVar( $result ); + } + + switch ( $this->mMethod ) { + case 'diff': + $text1Var = $parameters['oldtext-var']; + $text2Var = $parameters['newtext-var']; + $text1 = $vars->getVar( $text1Var )->toString(); + $text2 = $vars->getVar( $text2Var )->toString(); + $diffs = new Diff( explode( "\n", $text1 ), explode( "\n", $text2 ) ); + $format = new UnifiedDiffFormatter(); + $result = $format->format( $diffs ); + break; + case 'diff-split': + $diff = $vars->getVar( $parameters['diff-var'] )->toString(); + $line_prefix = $parameters['line-prefix']; + $diff_lines = explode( "\n", $diff ); + $interest_lines = []; + foreach ( $diff_lines as $line ) { + if ( substr( $line, 0, 1 ) === $line_prefix ) { + $interest_lines[] = 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. + + /* @var WikiPage $article */ + if ( isset( $parameters['article'] ) ) { + $article = $parameters['article']; + } else { + $article = self::articleFromTitle( + $parameters['namespace'], + $parameters['title'] + ); + } + if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { + $textVar = $parameters['text-var']; + + // XXX: Use prepareContentForEdit. But we need a Content object for that. + $new_text = $vars->getVar( $textVar )->toString(); + $content = ContentHandler::makeContent( $new_text, $article->getTitle() ); + $editInfo = $article->prepareContentForEdit( $content ); + $links = array_keys( $editInfo->output->getExternalLinks() ); + $result = $links; + 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( + $parameters['namespace'], + $parameters['title'] + ); + + if ( $vars->getVar( 'context' )->toString() == 'filter' ) { + $links = $this->getLinksFromDB( $article ); + wfDebug( "AbuseFilter: loading old links from DB\n" ); + } elseif ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { + wfDebug( "AbuseFilter: loading old links from Parser\n" ); + $textVar = $parameters['text-var']; + + $wikitext = $vars->getVar( $textVar )->toString(); + $editInfo = $this->parseNonEditWikitext( $wikitext, $article ); + $links = array_keys( $editInfo->output->getExternalLinks() ); + } else { + // TODO: Get links from Content object. But we don't have the content object. + // And for non-text content, $wikitext is usually not going to be a valid + // serialization, but rather some dummy text for filtering. + $links = []; + } + + $result = $links; + break; + case 'link-diff-added': + case 'link-diff-removed': + $oldLinkVar = $parameters['oldlink-var']; + $newLinkVar = $parameters['newlink-var']; + + $oldLinks = $vars->getVar( $oldLinkVar )->toString(); + $newLinks = $vars->getVar( $newLinkVar )->toString(); + + $oldLinks = explode( "\n", $oldLinks ); + $newLinks = explode( "\n", $newLinks ); + + if ( $this->mMethod == 'link-diff-added' ) { + $result = array_diff( $newLinks, $oldLinks ); + } + if ( $this->mMethod == 'link-diff-removed' ) { + $result = array_diff( $oldLinks, $newLinks ); + } + break; + case 'parse-wikitext': + // Should ONLY be used when sharing a parse operation with the edit. + if ( isset( $parameters['article'] ) ) { + $article = $parameters['article']; + } else { + $article = self::articleFromTitle( + $parameters['namespace'], + $parameters['title'] + ); + } + if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { + $textVar = $parameters['wikitext-var']; + + $new_text = $vars->getVar( $textVar )->toString(); + $content = ContentHandler::makeContent( $new_text, $article->getTitle() ); + $editInfo = $article->prepareContentForEdit( $content ); + 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 ); + } + 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'] ); + $textVar = $parameters['wikitext-var']; + + if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) { + if ( isset( $parameters['pst'] ) && $parameters['pst'] ) { + // $textVar is already PSTed when it's not loaded from an ongoing edit. + $result = $vars->getVar( $textVar )->toString(); + } else { + $text = $vars->getVar( $textVar )->toString(); + $editInfo = $this->parseNonEditWikitext( $text, $article ); + $result = $editInfo->output->getText(); + } + } else { + // TODO: Parser Output from Content object. But we don't have the content object. + // And for non-text content, $wikitext is usually not going to be a valid + // serialization, but rather some dummy text for filtering. + $result = ''; + } + + break; + case 'strip-html': + $htmlVar = $parameters['html-var']; + $html = $vars->getVar( $htmlVar )->toString(); + $result = StringUtils::delimiterReplace( '<', '>', '', $html ); + break; + case 'load-recent-authors': + $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] ); + if ( !$title->exists() ) { + $result = ''; + break; + } + + $result = self::getLastPageAuthors( $title ); + break; + case 'load-first-author': + $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] ); + + $revision = $title->getFirstRevision(); + if ( $revision ) { + $result = $revision->getUserText(); + } else { + $result = ''; + } + + break; + case 'get-page-restrictions': + $action = $parameters['action']; + $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] ); + + $rights = $title->getRestrictions( $action ); + $rights = count( $rights ) ? $rights : []; + $result = $rights; + break; + case 'simple-user-accessor': + $user = $parameters['user']; + $method = $parameters['method']; + + if ( !$user ) { + throw new MWException( 'No user parameter given.' ); + } + + $obj = self::getUserObject( $user ); + + if ( !$obj ) { + throw new MWException( "Invalid username $user" ); + } + + $result = call_user_func( [ $obj, $method ] ); + break; + case 'user-age': + $user = $parameters['user']; + $asOf = $parameters['asof']; + $obj = self::getUserObject( $user ); + + if ( $obj->getId() == 0 ) { + $result = 0; + break; + } + + $registration = $obj->getRegistration(); + $result = wfTimestamp( TS_UNIX, $asOf ) - wfTimestampOrNull( TS_UNIX, $registration ); + break; + case 'user-groups': + // Deprecated but needed by old log entries + $user = $parameters['user']; + $obj = self::getUserObject( $user ); + $result = $obj->getEffectiveGroups(); + break; + case 'length': + $s = $vars->getVar( $parameters['length-var'] )->toString(); + $result = strlen( $s ); + break; + case 'subtract': + // Currently unused, kept for backwards compatibility for old filters. + $v1 = $vars->getVar( $parameters['val1-var'] )->toFloat(); + $v2 = $vars->getVar( $parameters['val2-var'] )->toFloat(); + $result = $v1 - $v2; + break; + case 'subtract-int': + $v1 = $vars->getVar( $parameters['val1-var'] )->toInt(); + $v2 = $vars->getVar( $parameters['val2-var'] )->toInt(); + $result = $v1 - $v2; + break; + case 'revision-text-by-id': + $rev = Revision::newFromId( $parameters['revid'] ); + $result = AbuseFilter::revisionToString( $rev ); + 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 ); + break; + default: + if ( Hooks::run( 'AbuseFilter-computeVariable', + [ $this->mMethod, $vars, $parameters, &$result ] ) ) { + throw new AFPException( 'Unknown variable compute type ' . $this->mMethod ); + } + } + + return $result instanceof AFPData + ? $result : AFPData::newFromPHPVar( $result ); + } + + /** + * @param Title $title + * @return string[] List of the last 10 (unique) authors from $title + */ + public static function getLastPageAuthors( Title $title ) { + if ( !$title->exists() ) { + return []; + } + + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + + return $cache->getWithSetCallback( + $cache->makeKey( 'last-10-authors', 'revision', $title->getLatestRevID() ), + $cache::TTL_MINUTE, + function ( $oldValue, &$ttl, array &$setOpts ) use ( $title ) { + $dbr = wfGetDB( DB_REPLICA ); + $setOpts += Database::getCacheSetOptions( $dbr ); + // Get the last 100 edit authors with a trivial query (avoid T116557) + $revQuery = Revision::getQueryInfo(); + $revAuthors = $dbr->selectFieldValues( + $revQuery['tables'], + $revQuery['fields']['rev_user_text'], + [ 'rev_page' => $title->getArticleID() ], + __METHOD__, + // Some pages have < 10 authors but many revisions (e.g. bot pages) + [ 'ORDER BY' => 'rev_timestamp DESC', + 'LIMIT' => 100, + // Force index per T116557 + 'USE INDEX' => [ 'revision' => 'page_timestamp' ], + ], + $revQuery['joins'] + ); + // Get the last 10 distinct authors within this set of edits + $users = []; + foreach ( $revAuthors as $author ) { + $users[$author] = 1; + if ( count( $users ) >= 10 ) { + break; + } + } + + return array_keys( $users ); + } + ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/AbuseFilter.php b/www/wiki/extensions/AbuseFilter/includes/AbuseFilter.php new file mode 100644 index 00000000..c9afd66f --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/AbuseFilter.php @@ -0,0 +1,2796 @@ +<?php + +use MediaWiki\Linker\LinkRenderer; +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\Session\SessionManager; +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IDatabase; + +/** + * This class contains most of the business logic of AbuseFilter. It consists of mostly + * static functions that handle activities such as parsing edits, applying filters, + * logging actions, etc. + */ +class AbuseFilter { + public static $statsStoragePeriod = 86400; + public static $condLimitEnabled = true; + + /** @var array Map of (filter ID => stdClass) */ + private static $filterCache = []; + + public static $condCount = 0; + + /** @var array Map of (action ID => string[]) */ + public static $tagsToSet = []; // FIXME: avoid global state here + + public static $history_mappings = [ + 'af_pattern' => 'afh_pattern', + 'af_user' => 'afh_user', + 'af_user_text' => 'afh_user_text', + 'af_timestamp' => 'afh_timestamp', + 'af_comments' => 'afh_comments', + 'af_public_comments' => 'afh_public_comments', + 'af_deleted' => 'afh_deleted', + 'af_id' => 'afh_filter', + 'af_group' => 'afh_group', + ]; + public static $builderValues = [ + 'op-arithmetic' => [ + '+' => 'addition', + '-' => 'subtraction', + '*' => 'multiplication', + '/' => 'divide', + '%' => 'modulo', + '**' => 'pow' + ], + 'op-comparison' => [ + '==' => 'equal', + '===' => 'equal-strict', + '!=' => 'notequal', + '!==' => 'notequal-strict', + '<' => 'lt', + '>' => 'gt', + '<=' => 'lte', + '>=' => 'gte' + ], + 'op-bool' => [ + '!' => 'not', + '&' => 'and', + '|' => 'or', + '^' => 'xor' + ], + 'misc' => [ + 'in' => 'in', + 'contains' => 'contains', + 'like' => 'like', + '""' => 'stringlit', + 'rlike' => 'rlike', + 'irlike' => 'irlike', + 'cond ? iftrue : iffalse' => 'tern', + 'if cond then iftrue elseiffalse end' => 'cond', + ], + 'funcs' => [ + 'length(string)' => 'length', + 'lcase(string)' => 'lcase', + 'ucase(string)' => 'ucase', + 'ccnorm(string)' => 'ccnorm', + 'ccnorm_contains_any(haystack,needle1,needle2,..)' => 'ccnorm-contains-any', + 'ccnorm_contains_all(haystack,needle1,needle2,..)' => 'ccnorm-contains-all', + 'rmdoubles(string)' => 'rmdoubles', + 'specialratio(string)' => 'specialratio', + 'norm(string)' => 'norm', + 'count(needle,haystack)' => 'count', + 'rcount(needle,haystack)' => 'rcount', + 'get_matches(needle,haystack)' => 'get_matches', + 'rmwhitespace(text)' => 'rmwhitespace', + 'rmspecials(text)' => 'rmspecials', + 'ip_in_range(ip, range)' => 'ip_in_range', + 'contains_any(haystack,needle1,needle2,...)' => 'contains-any', + 'contains_all(haystack,needle1,needle2,...)' => 'contains-all', + 'substr(subject, offset, length)' => 'substr', + 'strpos(haystack, needle)' => 'strpos', + 'str_replace(subject, search, replace)' => 'str_replace', + 'rescape(string)' => 'rescape', + 'set_var(var,value)' => 'set_var', + ], + 'vars' => [ + 'timestamp' => 'timestamp', + 'accountname' => 'accountname', + 'action' => 'action', + 'added_lines' => 'addedlines', + 'edit_delta' => 'delta', + 'edit_diff' => 'diff', + 'new_size' => 'newsize', + 'old_size' => 'oldsize', + 'new_content_model' => 'new-content-model', + 'old_content_model' => 'old-content-model', + 'removed_lines' => 'removedlines', + 'summary' => 'summary', + 'article_articleid' => 'article-id', + 'article_namespace' => 'article-ns', + 'article_text' => 'article-text', + 'article_prefixedtext' => 'article-prefixedtext', + // 'article_views' => 'article-views', # May not be enabled, defined in getBuilderValues() + 'moved_from_articleid' => 'movedfrom-id', + 'moved_from_namespace' => 'movedfrom-ns', + 'moved_from_text' => 'movedfrom-text', + 'moved_from_prefixedtext' => 'movedfrom-prefixedtext', + 'moved_to_articleid' => 'movedto-id', + 'moved_to_namespace' => 'movedto-ns', + 'moved_to_text' => 'movedto-text', + 'moved_to_prefixedtext' => 'movedto-prefixedtext', + 'user_editcount' => 'user-editcount', + 'user_age' => 'user-age', + 'user_name' => 'user-name', + 'user_groups' => 'user-groups', + 'user_rights' => 'user-rights', + 'user_blocked' => 'user-blocked', + 'user_emailconfirm' => 'user-emailconfirm', + 'old_wikitext' => 'old-text', + 'new_wikitext' => 'new-text', + 'added_links' => 'added-links', + 'removed_links' => 'removed-links', + 'all_links' => 'all-links', + 'new_pst' => 'new-pst', + 'edit_diff_pst' => 'diff-pst', + 'added_lines_pst' => 'addedlines-pst', + 'new_text' => 'new-text-stripped', + 'new_html' => 'new-html', + 'article_restrictions_edit' => 'restrictions-edit', + 'article_restrictions_move' => 'restrictions-move', + 'article_restrictions_create' => 'restrictions-create', + 'article_restrictions_upload' => 'restrictions-upload', + 'article_recent_contributors' => 'recent-contributors', + 'article_first_contributor' => 'first-contributor', + 'moved_from_restrictions_edit' => 'movedfrom-restrictions-edit', + 'moved_from_restrictions_move' => 'movedfrom-restrictions-move', + 'moved_from_restrictions_create' => 'movedfrom-restrictions-create', + 'moved_from_restrictions_upload' => 'movedfrom-restrictions-upload', + 'moved_from_recent_contributors' => 'movedfrom-recent-contributors', + 'moved_from_first_contributor' => 'movedfrom-first-contributor', + 'moved_to_restrictions_edit' => 'movedto-restrictions-edit', + 'moved_to_restrictions_move' => 'movedto-restrictions-move', + 'moved_to_restrictions_create' => 'movedto-restrictions-create', + 'moved_to_restrictions_upload' => 'movedto-restrictions-upload', + 'moved_to_recent_contributors' => 'movedto-recent-contributors', + 'moved_to_first_contributor' => 'movedto-first-contributor', + // 'old_text' => 'old-text-stripped', # Disabled, performance + // 'old_html' => 'old-html', # Disabled, performance + 'old_links' => 'old-links', + 'minor_edit' => 'minor-edit', + 'file_sha1' => 'file-sha1', + 'file_size' => 'file-size', + 'file_mime' => 'file-mime', + 'file_mediatype' => 'file-mediatype', + 'file_width' => 'file-width', + 'file_height' => 'file-height', + 'file_bits_per_channel' => 'file-bits-per-channel', + ], + ]; + + public static $editboxName = null; + + /** + * @param IContextSource $context + * @param string $pageType + * @param LinkRenderer $linkRenderer + */ + public static function addNavigationLinks( + IContextSource $context, + $pageType, + LinkRenderer $linkRenderer + ) { + $linkDefs = [ + 'home' => 'Special:AbuseFilter', + 'recentchanges' => 'Special:AbuseFilter/history', + 'examine' => 'Special:AbuseFilter/examine', + 'log' => 'Special:AbuseLog', + ]; + + if ( $context->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $linkDefs = array_merge( $linkDefs, [ + 'test' => 'Special:AbuseFilter/test', + 'tools' => 'Special:AbuseFilter/tools', + 'import' => 'Special:AbuseFilter/import', + ] ); + } + + // Save some translator work + $msgOverrides = [ + 'recentchanges' => 'abusefilter-filter-log', + ]; + + $links = []; + + foreach ( $linkDefs as $name => $page ) { + // Give grep a chance to find the usages: + // abusefilter-topnav-home, abusefilter-topnav-test, abusefilter-topnav-examine + // abusefilter-topnav-log, abusefilter-topnav-tools, abusefilter-topnav-import + $msgName = "abusefilter-topnav-$name"; + + if ( isset( $msgOverrides[$name] ) ) { + $msgName = $msgOverrides[$name]; + } + + $msg = $context->msg( $msgName )->parse(); + $title = Title::newFromText( $page ); + + if ( $name == $pageType ) { + $links[] = Xml::tags( 'strong', null, $msg ); + } else { + $links[] = $linkRenderer->makeLink( $title, new HtmlArmor( $msg ) ); + } + } + + $linkStr = $context->msg( 'parentheses', $context->getLanguage()->pipeList( $links ) )->text(); + $linkStr = $context->msg( 'abusefilter-topnav' )->parse() . " $linkStr"; + + $linkStr = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-navigation' ], $linkStr ); + + $context->getOutput()->setSubtitle( $linkStr ); + } + + /** + * @static + * @param User $user + * @return AbuseFilterVariableHolder + */ + public static function generateUserVars( $user ) { + $vars = new AbuseFilterVariableHolder; + + $vars->setLazyLoadVar( + 'user_editcount', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getEditCount' ] + ); + + $vars->setVar( 'user_name', $user->getName() ); + + $vars->setLazyLoadVar( + 'user_emailconfirm', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getEmailAuthenticationTimestamp' ] + ); + + $vars->setLazyLoadVar( + 'user_age', + 'user-age', + [ 'user' => $user, 'asof' => wfTimestampNow() ] + ); + + $vars->setLazyLoadVar( + 'user_groups', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getEffectiveGroups' ] + ); + + $vars->setLazyLoadVar( + 'user_rights', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'getRights' ] + ); + + $vars->setLazyLoadVar( + 'user_blocked', + 'simple-user-accessor', + [ 'user' => $user, 'method' => 'isBlocked' ] + ); + + Hooks::run( 'AbuseFilter-generateUserVars', [ $vars, $user ] ); + + return $vars; + } + + /** + * @return array + */ + public static function getBuilderValues() { + static $realValues = null; + + if ( $realValues ) { + return $realValues; + } + + $realValues = self::$builderValues; + global $wgDisableCounters; + if ( !$wgDisableCounters ) { + $realValues['vars']['article_views'] = 'article-views'; + } + Hooks::run( 'AbuseFilter-builder', [ &$realValues ] ); + + return $realValues; + } + + /** + * @param string $filter + * @return bool + */ + public static function filterHidden( $filter ) { + $globalIndex = self::decodeGlobalName( $filter ); + if ( $globalIndex ) { + global $wgAbuseFilterCentralDB; + if ( !$wgAbuseFilterCentralDB ) { + return false; + } + $dbr = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + $filter = $globalIndex; + } else { + $dbr = wfGetDB( DB_REPLICA ); + } + if ( $filter === 'new' ) { + return false; + } + $hidden = $dbr->selectField( + 'abuse_filter', + 'af_hidden', + [ 'af_id' => $filter ], + __METHOD__ + ); + + return (bool)$hidden; + } + + /** + * @param int $val + * @throws MWException + */ + public static function triggerLimiter( $val = 1 ) { + self::$condCount += $val; + + global $wgAbuseFilterConditionLimit; + + if ( self::$condLimitEnabled && self::$condCount > $wgAbuseFilterConditionLimit ) { + throw new MWException( 'Condition limit reached.' ); + } + } + + public static function disableConditionLimit() { + // For use in batch scripts and the like + self::$condLimitEnabled = false; + } + + /** + * @param Title|null $title + * @param string $prefix + * @return AbuseFilterVariableHolder + */ + public static function generateTitleVars( $title, $prefix ) { + $vars = new AbuseFilterVariableHolder; + + if ( !$title ) { + return $vars; + } + + $vars->setVar( $prefix . '_ARTICLEID', $title->getArticleID() ); + $vars->setVar( $prefix . '_NAMESPACE', $title->getNamespace() ); + $vars->setVar( $prefix . '_TEXT', $title->getText() ); + $vars->setVar( $prefix . '_PREFIXEDTEXT', $title->getPrefixedText() ); + + global $wgDisableCounters; + if ( !$wgDisableCounters && !$title->isSpecialPage() ) { + // Support: HitCounters extension + // XXX: This should be part of the extension (T159069) + if ( method_exists( 'HitCounters\HitCounters', 'getCount' ) ) { + $vars->setVar( $prefix . '_VIEWS', HitCounters\HitCounters::getCount( $title ) ); + } + } + + // Use restrictions. + global $wgRestrictionTypes; + foreach ( $wgRestrictionTypes as $action ) { + $vars->setLazyLoadVar( "{$prefix}_restrictions_$action", 'get-page-restrictions', + [ 'title' => $title->getText(), + 'namespace' => $title->getNamespace(), + 'action' => $action + ] + ); + } + + $vars->setLazyLoadVar( "{$prefix}_recent_contributors", 'load-recent-authors', + [ + 'title' => $title->getText(), + 'namespace' => $title->getNamespace() + ] ); + + $vars->setLazyLoadVar( "{$prefix}_first_contributor", 'load-first-author', + [ + 'title' => $title->getText(), + 'namespace' => $title->getNamespace() + ] ); + + Hooks::run( 'AbuseFilter-generateTitleVars', [ $vars, $title, $prefix ] ); + + return $vars; + } + + /** + * @param string $filter + * @return mixed + */ + public static function checkSyntax( $filter ) { + global $wgAbuseFilterParserClass; + + /** @var $parser AbuseFilterParser */ + $parser = new $wgAbuseFilterParserClass; + + return $parser->checkSyntax( $filter ); + } + + /** + * @param string $expr + * @param array $vars + * @return string + */ + public static function evaluateExpression( $expr, $vars = [] ) { + global $wgAbuseFilterParserClass; + + if ( self::checkSyntax( $expr ) !== true ) { + return 'BADSYNTAX'; + } + + /** @var $parser AbuseFilterParser */ + $parser = new $wgAbuseFilterParserClass( $vars ); + + return $parser->evaluateExpression( $expr ); + } + + /** + * @param string $conds + * @param AbuseFilterVariableHolder $vars + * @param bool $ignoreError + * @return bool + * @throws Exception + */ + public static function checkConditions( + $conds, $vars, $ignoreError = true + ) { + global $wgAbuseFilterParserClass; + + static $parser, $lastVars; + + if ( is_null( $parser ) || $vars !== $lastVars ) { + /** @var $parser AbuseFilterParser */ + $parser = new $wgAbuseFilterParserClass( $vars ); + $lastVars = $vars; + } + + try { + $result = $parser->parse( $conds, self::$condCount ); + } catch ( Exception $excep ) { + // Sigh. + $result = false; + + wfDebugLog( 'AbuseFilter', 'AbuseFilter parser error: ' . $excep->getMessage() . "\n" ); + + if ( !$ignoreError ) { + throw $excep; + } + } + + return $result; + } + + /** + * Returns an associative array of filters which were tripped + * + * @param AbuseFilterVariableHolder $vars + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @param Title|null $title + * @param string $mode 'execute' for edits and logs, 'stash' for cached matches + * + * @return bool[] Map of (integer filter ID => bool) + */ + public static function checkAllFilters( + $vars, + $group = 'default', + Title $title = null, + $mode = 'execute' + ) { + global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; + global $wgAbuseFilterConditionLimit; + + // Fetch from the database. + $filter_matched = []; + + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( + 'abuse_filter', + '*', + [ + 'af_enabled' => 1, + 'af_deleted' => 0, + 'af_group' => $group, + ], + __METHOD__ + ); + + foreach ( $res as $row ) { + $filter_matched[$row->af_id] = self::checkFilter( $row, $vars, $title, '', $mode ); + } + + if ( $wgAbuseFilterCentralDB && !$wgAbuseFilterIsCentral ) { + // Global filters + $globalRulesKey = self::getGlobalRulesKey( $group ); + + $fname = __METHOD__; + $res = ObjectCache::getMainWANInstance()->getWithSetCallback( + $globalRulesKey, + WANObjectCache::TTL_INDEFINITE, + function () use ( $group, $fname ) { + global $wgAbuseFilterCentralDB; + + $fdb = wfGetLB( $wgAbuseFilterCentralDB )->getConnectionRef( + DB_REPLICA, [], $wgAbuseFilterCentralDB + ); + + return iterator_to_array( $fdb->select( + 'abuse_filter', + '*', + [ + 'af_enabled' => 1, + 'af_deleted' => 0, + 'af_global' => 1, + 'af_group' => $group, + ], + $fname + ) ); + }, + [ + 'checkKeys' => [ $globalRulesKey ], + 'lockTSE' => 300 + ] + ); + + foreach ( $res as $row ) { + $filter_matched['global-' . $row->af_id] = + self::checkFilter( $row, $vars, $title, 'global-', $mode ); + } + } + + if ( $title instanceof Title && self::$condCount > $wgAbuseFilterConditionLimit ) { + $actionID = implode( '-', [ + $title->getPrefixedText(), + $vars->getVar( 'user_name' )->toString(), + $vars->getVar( 'action' )->toString() + ] ); + self::bufferTagsToSetByAction( [ $actionID => [ 'abusefilter-condition-limit' ] ] ); + } + + if ( $mode === 'execute' ) { + // Update statistics, and disable filters which are over-blocking. + self::recordStats( $filter_matched, $group ); + } + + return $filter_matched; + } + + /** + * @static + * @param stdClass $row + * @param AbuseFilterVariableHolder $vars + * @param Title|null $title + * @param string $prefix + * @param string $mode 'execute' for edits and logs, 'stash' for cached matches + * @return bool + */ + public static function checkFilter( $row, $vars, Title $title = null, $prefix = '', $mode ) { + global $wgAbuseFilterProfile, $wgAbuseFilterRuntimeProfile, $wgAbuseFilterSlowFilterRuntimeLimit; + + $filterID = $prefix . $row->af_id; + + // Record data to be used if profiling is enabled and mode is 'execute' + $startConds = self::$condCount; + $startTime = microtime( true ); + + // Store the row somewhere convenient + self::$filterCache[$filterID] = $row; + + // Check conditions... + $pattern = trim( $row->af_pattern ); + if ( + self::checkConditions( + $pattern, + $vars, + true /* ignore errors */ + ) + ) { + // Record match. + $result = true; + } else { + // Record non-match. + $result = false; + } + + $timeTaken = microtime( true ) - $startTime; + $condsUsed = self::$condCount - $startConds; + + if ( $wgAbuseFilterProfile && $mode === 'execute' ) { + self::recordProfilingResult( $row->af_id, $timeTaken, $condsUsed ); + } + + $runtime = $timeTaken * 1000; + if ( $mode === 'execute' && $wgAbuseFilterRuntimeProfile && + $runtime > $wgAbuseFilterSlowFilterRuntimeLimit ) { + self::recordSlowFilter( $filterID, $runtime, $condsUsed, $result, $title ); + } + + return $result; + } + + /** + * Logs slow filter's runtime data for later analysis + * + * @param string $filterId + * @param float $runtime + * @param int $totalConditions + * @param Title|null $title + */ + private static function recordSlowFilter( + $filterId, $runtime, $totalConditions, $matched, Title $title = null + ) { + $title = $title ? $title->getPrefixedText() : ''; + + $logger = LoggerFactory::getInstance( 'AbuseFilterSlow' ); + $logger->info( + 'Edit filter {filter_id} on {wiki} is taking longer than expected', + [ + 'wiki' => wfWikiID(), + 'filter_id' => $filterId, + 'title' => $title, + 'runtime' => $runtime, + 'matched' => $matched, + 'total_conditions' => $totalConditions + ] + ); + } + + /** + * @param int $filter + */ + public static function resetFilterProfile( $filter ) { + $stash = ObjectCache::getMainStashInstance(); + $countKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'count' ); + $totalKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'total' ); + $condsKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'conds' ); + + $stash->delete( $countKey ); + $stash->delete( $totalKey ); + $stash->delete( $condsKey ); + } + + /** + * @param int $filter + * @param float $time + * @param int $conds + */ + public static function recordProfilingResult( $filter, $time, $conds ) { + // Defer updates to avoid massive (~1 second) edit time increases + DeferredUpdates::addCallableUpdate( function () use ( $filter, $time, $conds ) { + $stash = ObjectCache::getMainStashInstance(); + $countKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'count' ); + $totalKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'total' ); + $condsKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'conds' ); + + $curCount = $stash->get( $countKey ); + $curTotal = $stash->get( $totalKey ); + $curConds = $stash->get( $condsKey ); + + if ( $curCount ) { + $stash->set( $condsKey, $curConds + $conds, 3600 ); + $stash->set( $totalKey, $curTotal + $time, 3600 ); + $stash->incr( $countKey ); + } else { + $stash->set( $countKey, 1, 3600 ); + $stash->set( $totalKey, $time, 3600 ); + $stash->set( $condsKey, $conds, 3600 ); + } + } ); + } + + /** + * @param string $filter + * @return array + */ + public static function getFilterProfile( $filter ) { + $stash = ObjectCache::getMainStashInstance(); + $countKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'count' ); + $totalKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'total' ); + $condsKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'conds' ); + + $curCount = $stash->get( $countKey ); + $curTotal = $stash->get( $totalKey ); + $curConds = $stash->get( $condsKey ); + + if ( !$curCount ) { + return [ 0, 0 ]; + } + + $timeProfile = ( $curTotal / $curCount ) * 1000; // 1000 ms in a sec + $timeProfile = round( $timeProfile, 2 ); // Return in ms, rounded to 2dp + + $condProfile = ( $curConds / $curCount ); + $condProfile = round( $condProfile, 0 ); + + return [ $timeProfile, $condProfile ]; + } + + /** + * Utility function to decode global-$index to $index. Returns false if not global + * + * @param string $filter + * + * @return string|bool + */ + public static function decodeGlobalName( $filter ) { + if ( strpos( $filter, 'global-' ) == 0 ) { + return substr( $filter, strlen( 'global-' ) ); + } + + return false; + } + + /** + * @param string[] $filters + * @return array[] + */ + public static function getConsequencesForFilters( $filters ) { + $globalFilters = []; + $localFilters = []; + + foreach ( $filters as $filter ) { + $globalIndex = self::decodeGlobalName( $filter ); + + if ( $globalIndex ) { + $globalFilters[] = $globalIndex; + } else { + $localFilters[] = $filter; + } + } + + global $wgAbuseFilterCentralDB; + // Load local filter info + $dbr = wfGetDB( DB_REPLICA ); + // Retrieve the consequences. + $consequences = []; + + if ( count( $localFilters ) ) { + $consequences = self::loadConsequencesFromDB( $dbr, $localFilters ); + } + + if ( count( $globalFilters ) ) { + $fdb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + $consequences = $consequences + self::loadConsequencesFromDB( $fdb, $globalFilters, 'global-' ); + } + + return $consequences; + } + + /** + * @param IDatabase $dbr + * @param string[] $filters + * @param string $prefix + * @return array[] + */ + public static function loadConsequencesFromDB( $dbr, $filters, $prefix = '' ) { + $actionsByFilter = []; + foreach ( $filters as $filter ) { + $actionsByFilter[$prefix . $filter] = []; + } + + $res = $dbr->select( + [ 'abuse_filter_action', 'abuse_filter' ], + '*', + [ 'af_id' => $filters ], + __METHOD__, + [], + [ 'abuse_filter_action' => [ 'LEFT JOIN', 'afa_filter=af_id' ] ] + ); + + // Categorise consequences by filter. + global $wgAbuseFilterRestrictions; + foreach ( $res as $row ) { + if ( $row->af_throttled + && !empty( $wgAbuseFilterRestrictions[$row->afa_consequence] ) + ) { + # Don't do the action + } elseif ( $row->afa_filter != $row->af_id ) { + // We probably got a NULL, as it's a LEFT JOIN. + // Don't add it. + } else { + $actionsByFilter[$prefix . $row->afa_filter][$row->afa_consequence] = [ + 'action' => $row->afa_consequence, + 'parameters' => array_filter( explode( "\n", $row->afa_parameters ) ) + ]; + } + } + + return $actionsByFilter; + } + + /** + * Executes a list of actions. + * + * @param string[] $filters + * @param Title $title + * @param AbuseFilterVariableHolder $vars + * @return Status returns the operation's status. $status->isOK() will return true if + * there were no actions taken, false otherwise. $status->getValue() will return + * an array listing the actions taken. $status->getErrors() etc. will provide + * the errors and warnings to be shown to the user to explain the actions. + */ + public static function executeFilterActions( $filters, $title, $vars ) { + global $wgMainCacheType; + + $actionsByFilter = self::getConsequencesForFilters( $filters ); + $actionsTaken = array_fill_keys( $filters, [] ); + + $messages = []; + // Accumulator to track max block to issue + $maxExpiry = -1; + + global $wgAbuseFilterDisallowGlobalLocalBlocks, $wgAbuseFilterRestrictions, + $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration; + foreach ( $actionsByFilter as $filter => $actions ) { + // Special-case handling for warnings. + $filter_public_comments = self::getFilter( $filter )->af_public_comments; + + $global_filter = self::decodeGlobalName( $filter ) !== false; + + // If the filter is throttled and throttling is available via object + // caching, check to see if the user has hit the throttle. + if ( !empty( $actions['throttle'] ) && $wgMainCacheType !== CACHE_NONE ) { + $parameters = $actions['throttle']['parameters']; + $throttleId = array_shift( $parameters ); + list( $rateCount, $ratePeriod ) = explode( ',', array_shift( $parameters ) ); + + $hitThrottle = false; + + // The rest are throttle-types. + foreach ( $parameters as $throttleType ) { + $hitThrottle = $hitThrottle || self::isThrottled( + $throttleId, $throttleType, $title, $rateCount, $ratePeriod, $global_filter ); + } + + unset( $actions['throttle'] ); + if ( !$hitThrottle ) { + $actionsTaken[$filter][] = 'throttle'; + continue; + } + } + + if ( $wgAbuseFilterDisallowGlobalLocalBlocks && $global_filter ) { + $actions = array_diff_key( $actions, array_filter( $wgAbuseFilterRestrictions ) ); + } + + if ( !empty( $actions['warn'] ) ) { + $parameters = $actions['warn']['parameters']; + $warnKey = 'abusefilter-warned-' . md5( $title->getPrefixedText() ) . '-' . $filter; + + // Make sure the session is started prior to using it + $session = SessionManager::getGlobalSession(); + $session->persist(); + + if ( !isset( $session[$warnKey] ) || !$session[$warnKey] ) { + $session[$warnKey] = true; + + // Threaten them a little bit + if ( !empty( $parameters[0] ) && strlen( $parameters[0] ) ) { + $msg = $parameters[0]; + } else { + $msg = 'abusefilter-warning'; + } + $messages[] = [ $msg, $filter_public_comments, $filter ]; + + $actionsTaken[$filter][] = 'warn'; + + continue; // Don't do anything else. + } else { + // We already warned them + $session[$warnKey] = false; + } + + unset( $actions['warn'] ); + } + + // prevent double warnings + if ( count( array_intersect_key( $actions, array_filter( $wgAbuseFilterRestrictions ) ) ) > 0 && + !empty( $actions['disallow'] ) + ) { + unset( $actions['disallow'] ); + } + + // Find out the max expiry to issue the longest triggered block. + // Need to check here since methods like user->getBlock() aren't available + if ( !empty( $actions['block'] ) ) { + global $wgUser; + $parameters = $actions['block']['parameters']; + + if ( count( $parameters ) === 3 ) { + // New type of filters with custom block + if ( $wgUser->isAnon() ) { + $expiry = $parameters[1]; + } else { + $expiry = $parameters[2]; + } + } else { + // Old type with fixed expiry + if ( $wgUser->isAnon() && $wgAbuseFilterAnonBlockDuration !== null ) { + // The user isn't logged in and the anon block duration + // doesn't default to $wgAbuseFilterBlockDuration. + $expiry = $wgAbuseFilterAnonBlockDuration; + } else { + $expiry = $wgAbuseFilterBlockDuration; + } + } + + $currentExpiry = SpecialBlock::parseExpiryInput( $expiry ); + if ( $currentExpiry > SpecialBlock::parseExpiryInput( $maxExpiry ) ) { + // Save the parameters to issue the block with + $maxExpiry = $expiry; + $blockValues = [ + self::getFilter( $filter )->af_public_comments, + $filter, + is_array( $parameters ) && in_array( 'blocktalk', $parameters ) + ]; + } + unset( $actions['block'] ); + } + + // Do the rest of the actions + foreach ( $actions as $action => $info ) { + $newMsg = self::takeConsequenceAction( + $action, + $info['parameters'], + $title, + $vars, + self::getFilter( $filter )->af_public_comments, + $filter + ); + + if ( $newMsg !== null ) { + $messages[] = $newMsg; + } + $actionsTaken[$filter][] = $action; + } + } + + // Since every filter has been analysed, we now know what the + // longest block duration is, so we can issue the block if + // maxExpiry has been changed. + if ( $maxExpiry !== -1 ) { + self::doAbuseFilterBlock( + [ + 'desc' => $blockValues[0], + 'number' => $blockValues[1] + ], + $wgUser->getName(), + $maxExpiry, + true, + $blockValues[2] + ); + $message = [ + 'abusefilter-blocked-display', + $blockValues[0], + $blockValues[1] + ]; + // Manually add the message. If we're here, there is one. + $messages[] = $message; + $actionsTaken[ $blockValues[1] ][] = 'block'; + } + + return self::buildStatus( $actionsTaken, $messages ); + } + + /** + * Constructs a Status object as returned by executeFilterActions() from the list of + * actions taken and the corresponding list of messages. + * + * @param array[] $actionsTaken associative array mapping each filter to the list if + * actions taken because of that filter. + * @param array[] $messages a list if arrays, where each array contains a message key + * followed by any message parameters. + * + * @return Status + */ + protected static function buildStatus( array $actionsTaken, array $messages ) { + $status = Status::newGood( $actionsTaken ); + + foreach ( $messages as $msg ) { + call_user_func_array( [ $status, 'fatal' ], $msg ); + } + + return $status; + } + + /** + * @param AbuseFilterVariableHolder $vars + * @param Title $title + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @param User $user The user performing the action; defaults to $wgUser + * @param string $mode Use 'execute' to run filters and log or 'stash' to only cache matches + * @return Status + */ + public static function filterAction( + $vars, $title, $group = 'default', $user = null, $mode = 'execute' + ) { + global $wgUser, $wgTitle, $wgRequest, $wgAbuseFilterRuntimeProfile, $wgAbuseFilterLogIP; + + $context = RequestContext::getMain(); + $oldContextTitle = $context->getTitle(); + + $oldWgTitle = $wgTitle; + + if ( !$wgTitle ) { + $wgTitle = SpecialPage::getTitleFor( 'AbuseFilter' ); + } + + if ( !$user ) { + $user = $wgUser; + } + + $logger = LoggerFactory::getInstance( 'StashEdit' ); + $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); + + // Add vars from extensions + Hooks::run( 'AbuseFilter-filterAction', [ &$vars, $title ] ); + $vars->setVar( 'context', 'filter' ); + $vars->setVar( 'timestamp', time() ); + + // Get the stash key based on the relevant "input" variables + $cache = ObjectCache::getLocalClusterInstance(); + $stashKey = self::getStashKey( $cache, $vars, $group ); + $isForEdit = ( $vars->getVar( 'action' )->toString() === 'edit' ); + + if ( $wgAbuseFilterRuntimeProfile ) { + $startTime = microtime( true ); + } + + $filter_matched = false; + if ( $mode === 'execute' && $isForEdit ) { + // Check the filter edit stash results first + $cacheData = $cache->get( $stashKey ); + if ( $cacheData ) { + $filter_matched = $cacheData['matches']; + // Merge in any tags to apply to recent changes entries + self::bufferTagsToSetByAction( $cacheData['tags'] ); + } + } + + if ( is_array( $filter_matched ) ) { + if ( $isForEdit && $mode !== 'stash' ) { + $logger->info( __METHOD__ . ": cache hit for '$title' (key $stashKey)." ); + $statsd->increment( 'abusefilter.check-stash.hit' ); + } + } else { + $filter_matched = self::checkAllFilters( $vars, $group, $title, $mode ); + if ( $isForEdit && $mode !== 'stash' ) { + $logger->info( __METHOD__ . ": cache miss for '$title' (key $stashKey)." ); + $statsd->increment( 'abusefilter.check-stash.miss' ); + } + } + + if ( $mode === 'stash' ) { + // Save the filter stash result and do nothing further + $cacheData = [ 'matches' => $filter_matched, 'tags' => self::$tagsToSet ]; + + // Add runtime metrics in cache for later use + if ( $wgAbuseFilterRuntimeProfile ) { + $cacheData['condCount'] = self::$condCount; + $cacheData['runtime'] = ( microtime( true ) - $startTime ) * 1000; + } + + $cache->set( $stashKey, $cacheData, $cache::TTL_MINUTE ); + $logger->debug( __METHOD__ . ": cache store for '$title' (key $stashKey)." ); + $statsd->increment( 'abusefilter.check-stash.store' ); + + return Status::newGood(); + } + + $matched_filters = array_keys( array_filter( $filter_matched ) ); + + // Save runtime metrics only on edits + if ( $wgAbuseFilterRuntimeProfile && $mode === 'execute' && $isForEdit ) { + if ( $cacheData ) { + $runtime = $cacheData['runtime']; + $condCount = $cacheData['condCount']; + } else { + $runtime = ( microtime( true ) - $startTime ) * 1000; + $condCount = self::$condCount; + } + + self::recordRuntimeProfilingResult( count( $matched_filters ), $condCount, $runtime ); + } + + if ( count( $matched_filters ) == 0 ) { + $status = Status::newGood(); + } else { + $status = self::executeFilterActions( $matched_filters, $title, $vars ); + $actions_taken = $status->getValue(); + $action = $vars->getVar( 'ACTION' )->toString(); + + // If $wgUser isn't safe to load (e.g. a failure during + // AbortAutoAccount), create a dummy anonymous user instead. + $user = $user->isSafeToLoad() ? $user : new User; + + // Create a template + $log_template = [ + 'afl_user' => $user->getId(), + 'afl_user_text' => $user->getName(), + 'afl_timestamp' => wfGetDB( DB_REPLICA )->timestamp( wfTimestampNow() ), + 'afl_namespace' => $title->getNamespace(), + 'afl_title' => $title->getDBkey(), + // DB field is not null, so nothing + 'afl_ip' => ( $wgAbuseFilterLogIP ) ? $wgRequest->getIP() : "" + ]; + + // Hack to avoid revealing IPs of people creating accounts + if ( !$user->getId() && ( $action == 'createaccount' || $action == 'autocreateaccount' ) ) { + $log_template['afl_user_text'] = $vars->getVar( 'accountname' )->toString(); + } + + self::addLogEntries( $actions_taken, $log_template, $action, $vars, $group ); + } + + // Bug 53498: If we screwed around with $wgTitle, reset it so the title + // is correctly picked up from the request later. Do the same for the + // main RequestContext, because that might have picked up the bogus + // title from $wgTitle. + if ( $wgTitle !== $oldWgTitle ) { + $wgTitle = $oldWgTitle; + } + + if ( $context->getTitle() !== $oldContextTitle && $oldContextTitle instanceof Title ) { + $context->setTitle( $oldContextTitle ); + } + + return $status; + } + + /** + * @param string $id Filter ID (integer or "global-<integer>") + * @return stdClass|null DB row + */ + public static function getFilter( $id ) { + global $wgAbuseFilterCentralDB; + + if ( !isset( self::$filterCache[$id] ) ) { + $globalIndex = self::decodeGlobalName( $id ); + if ( $globalIndex ) { + // Global wiki filter + if ( !$wgAbuseFilterCentralDB ) { + return null; // not enabled + } + + $id = $globalIndex; + $lb = wfGetLB( $wgAbuseFilterCentralDB ); + $dbr = $lb->getConnectionRef( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + } else { + // Local wiki filter + $dbr = wfGetDB( DB_REPLICA ); + } + + $row = $dbr->selectRow( 'abuse_filter', '*', [ 'af_id' => $id ], __METHOD__ ); + self::$filterCache[$id] = $row ?: null; + } + + return self::$filterCache[$id]; + } + + /** + * @param BagOStuff $cache + * @param AbuseFilterVariableHolder $vars + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * + * @return string + */ + private static function getStashKey( + BagOStuff $cache, AbuseFilterVariableHolder $vars, $group + ) { + $inputVars = $vars->exportNonLazyVars(); + // Exclude noisy fields that have superficial changes + unset( $inputVars['old_html'] ); + unset( $inputVars['new_html'] ); + unset( $inputVars['user_age'] ); + unset( $inputVars['timestamp'] ); + unset( $inputVars['_VIEWS'] ); + ksort( $inputVars ); + $hash = md5( serialize( $inputVars ) ); + + return $cache->makeKey( + 'abusefilter', + 'check-stash', + $group, + $hash, + 'v1' + ); + } + + /** + * @param array[] $actions_taken + * @param array $log_template + * @param string $action + * @param AbuseFilterVariableHolder $vars + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @return mixed + */ + public static function addLogEntries( $actions_taken, $log_template, $action, + $vars, $group = 'default' + ) { + $dbw = wfGetDB( DB_MASTER ); + + $central_log_template = [ + 'afl_wiki' => wfWikiID(), + ]; + + $log_rows = []; + $central_log_rows = []; + $logged_local_filters = []; + $logged_global_filters = []; + + foreach ( $actions_taken as $filter => $actions ) { + $globalIndex = self::decodeGlobalName( $filter ); + $thisLog = $log_template; + $thisLog['afl_filter'] = $filter; + $thisLog['afl_action'] = $action; + $thisLog['afl_actions'] = implode( ',', $actions ); + + // Don't log if we were only throttling. + if ( $thisLog['afl_actions'] != 'throttle' ) { + $log_rows[] = $thisLog; + + if ( !$globalIndex ) { + $logged_local_filters[] = $filter; + } + + // Global logging + if ( $globalIndex ) { + $title = Title::makeTitle( $thisLog['afl_namespace'], $thisLog['afl_title'] ); + $centralLog = $thisLog + $central_log_template; + $centralLog['afl_filter'] = $globalIndex; + $centralLog['afl_title'] = $title->getPrefixedText(); + $centralLog['afl_namespace'] = 0; + + $central_log_rows[] = $centralLog; + $logged_global_filters[] = $globalIndex; + } + } + } + + if ( !count( $log_rows ) ) { + return; + } + + // Only store the var dump if we're actually going to add log rows. + $var_dump = self::storeVarDump( $vars ); + $var_dump = "stored-text:$var_dump"; // To distinguish from stuff stored directly + + $stash = ObjectCache::getMainStashInstance(); + + // Increment trigger counter + $stash->incr( self::filterMatchesKey() ); + + $local_log_ids = []; + global $wgAbuseFilterNotifications, $wgAbuseFilterNotificationsPrivate; + foreach ( $log_rows as $data ) { + $data['afl_var_dump'] = $var_dump; + $data['afl_id'] = $dbw->nextSequenceValue( 'abuse_filter_log_afl_id_seq' ); + $dbw->insert( 'abuse_filter_log', $data, __METHOD__ ); + $local_log_ids[] = $data['afl_id'] = $dbw->insertId(); + // Give grep a chance to find the usages: + // logentry-abusefilter-hit + $entry = new ManualLogEntry( 'abusefilter', 'hit' ); + // Construct a user object + $user = User::newFromId( $data['afl_user'] ); + $user->setName( $data['afl_user_text'] ); + $entry->setPerformer( $user ); + // Set action target + $entry->setTarget( Title::makeTitle( $data['afl_namespace'], $data['afl_title'] ) ); + // Additional info + $entry->setParameters( [ + 'action' => $data['afl_action'], + 'filter' => $data['afl_filter'], + 'actions' => $data['afl_actions'], + 'log' => $data['afl_id'], + ] ); + + // Send data to CheckUser if installed and we + // aren't already sending a notification to recentchanges + if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) + && strpos( $wgAbuseFilterNotifications, 'rc' ) === false + ) { + $rc = $entry->getRecentChange(); + CheckUserHooks::updateCheckUserData( $rc ); + } + + if ( $wgAbuseFilterNotifications !== false ) { + if ( self::filterHidden( $data['afl_filter'] ) && !$wgAbuseFilterNotificationsPrivate ) { + continue; + } + $entry->publish( 0, $wgAbuseFilterNotifications ); + } + } + + $method = __METHOD__; + + if ( count( $logged_local_filters ) ) { + // Update hit-counter. + $dbw->onTransactionPreCommitOrIdle( + function () use ( $dbw, $logged_local_filters, $method ) { + $dbw->update( 'abuse_filter', + [ 'af_hit_count=af_hit_count+1' ], + [ 'af_id' => $logged_local_filters ], + $method + ); + } + ); + } + + $global_log_ids = []; + + // Global stuff + if ( count( $logged_global_filters ) ) { + $vars->computeDBVars(); + $global_var_dump = self::storeVarDump( $vars, true ); + $global_var_dump = "stored-text:$global_var_dump"; + foreach ( $central_log_rows as $index => $data ) { + $central_log_rows[$index]['afl_var_dump'] = $global_var_dump; + } + + global $wgAbuseFilterCentralDB; + $fdb = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB ); + + foreach ( $central_log_rows as $row ) { + $fdb->insert( 'abuse_filter_log', $row, __METHOD__ ); + $global_log_ids[] = $dbw->insertId(); + } + + $fdb->onTransactionPreCommitOrIdle( + function () use ( $fdb, $logged_global_filters, $method ) { + $fdb->update( 'abuse_filter', + [ 'af_hit_count=af_hit_count+1' ], + [ 'af_id' => $logged_global_filters ], + $method + ); + } + ); + } + + $vars->setVar( 'global_log_ids', $global_log_ids ); + $vars->setVar( 'local_log_ids', $local_log_ids ); + + // Check for emergency disabling. + $total = $stash->get( self::filterUsedKey( $group ) ); + self::checkEmergencyDisable( $group, $logged_local_filters, $total ); + } + + /** + * Store a var dump to External Storage or the text table + * Some of this code is stolen from Revision::insertOn and friends + * + * @param AbuseFilterVariableHolder $vars + * @param bool $global + * + * @return int|null + */ + public static function storeVarDump( $vars, $global = false ) { + global $wgCompressRevisions; + + // Get all variables yet set and compute old and new wikitext if not yet done + // as those are needed for the diff view on top of the abuse log pages + $vars = $vars->dumpAllVars( [ 'old_wikitext', 'new_wikitext' ] ); + + // Vars is an array with native PHP data types (non-objects) now + $text = serialize( $vars ); + $flags = [ 'nativeDataArray' ]; + + if ( $wgCompressRevisions ) { + if ( function_exists( 'gzdeflate' ) ) { + $text = gzdeflate( $text ); + $flags[] = 'gzip'; + } + } + + // Store to ES if applicable + global $wgDefaultExternalStore, $wgAbuseFilterCentralDB; + if ( $wgDefaultExternalStore ) { + if ( $global ) { + $text = ExternalStore::insertToForeignDefault( $text, $wgAbuseFilterCentralDB ); + } else { + $text = ExternalStore::insertToDefault( $text ); + } + $flags[] = 'external'; + + if ( !$text ) { + // Not mission-critical, just return nothing + return null; + } + } + + // Store to text table + if ( $global ) { + $dbw = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB ); + } else { + $dbw = wfGetDB( DB_MASTER ); + } + $old_id = $dbw->nextSequenceValue( 'text_old_id_seq' ); + $dbw->insert( 'text', + [ + 'old_id' => $old_id, + 'old_text' => $text, + 'old_flags' => implode( ',', $flags ), + ], __METHOD__ + ); + + return $dbw->insertId(); + } + + /** + * Retrieve a var dump from External Storage or the text table + * Some of this code is stolen from Revision::loadText et al + * + * @param string $stored_dump + * + * @return object|AbuseFilterVariableHolder|bool + */ + public static function loadVarDump( $stored_dump ) { + // Back-compat + if ( substr( $stored_dump, 0, strlen( 'stored-text:' ) ) !== 'stored-text:' ) { + $data = unserialize( $stored_dump ); + if ( is_array( $data ) ) { + $vh = new AbuseFilterVariableHolder; + foreach ( $data as $name => $value ) { + $vh->setVar( $name, $value ); + } + + return $vh; + } else { + return $data; + } + } + + $text_id = substr( $stored_dump, strlen( 'stored-text:' ) ); + + $dbr = wfGetDB( DB_REPLICA ); + + $text_row = $dbr->selectRow( + 'text', + [ 'old_text', 'old_flags' ], + [ 'old_id' => $text_id ], + __METHOD__ + ); + + if ( !$text_row ) { + return new AbuseFilterVariableHolder; + } + + $flags = explode( ',', $text_row->old_flags ); + $text = $text_row->old_text; + + if ( in_array( 'external', $flags ) ) { + $text = ExternalStore::fetchFromURL( $text ); + } + + if ( in_array( 'gzip', $flags ) ) { + $text = gzinflate( $text ); + } + + $obj = unserialize( $text ); + + if ( in_array( 'nativeDataArray', $flags ) ) { + $vars = $obj; + $obj = new AbuseFilterVariableHolder(); + foreach ( $vars as $key => $value ) { + $obj->setVar( $key, $value ); + } + } + + return $obj; + } + + /** + * @param string $action + * @param array $parameters + * @param Title $title + * @param AbuseFilterVariableHolder $vars + * @param string $rule_desc + * @param int|string $rule_number + * + * @return array|null a message describing the action that was taken, + * or null if no action was taken. The message is given as an array + * containing the message key followed by any message parameters. + */ + public static function takeConsequenceAction( $action, $parameters, $title, + $vars, $rule_desc, $rule_number ) { + global $wgAbuseFilterCustomActionsHandlers, $wgRequest; + + $message = null; + + switch ( $action ) { + case 'disallow': + if ( !empty( $parameters[0] ) && strlen( $parameters[0] ) ) { + $message = [ $parameters[0], $rule_desc, $rule_number ]; + } else { + // Generic message. + $message = [ + 'abusefilter-disallowed', + $rule_desc, + $rule_number + ]; + } + break; + case 'rangeblock': + global $wgAbuseFilterRangeBlockSize, $wgBlockCIDRLimit; + + $ip = $wgRequest->getIP(); + if ( IP::isIPv6( $ip ) ) { + $CIDRsize = max( $wgAbuseFilterRangeBlockSize['IPv6'], $wgBlockCIDRLimit['IPv6'] ); + } else { + $CIDRsize = max( $wgAbuseFilterRangeBlockSize['IPv4'], $wgBlockCIDRLimit['IPv4'] ); + } + $blockCIDR = $ip . '/' . $CIDRsize; + self::doAbuseFilterBlock( + [ + 'desc' => $rule_desc, + 'number' => $rule_number + ], + IP::sanitizeRange( $blockCIDR ), + '1 week', + false + ); + + $message = [ + 'abusefilter-blocked-display', + $rule_desc, + $rule_number + ]; + break; + case 'degroup': + global $wgUser; + if ( !$wgUser->isAnon() ) { + // Remove all groups from the user. Ouch. + $groups = $wgUser->getGroups(); + + foreach ( $groups as $group ) { + $wgUser->removeGroup( $group ); + } + + $message = [ + 'abusefilter-degrouped', + $rule_desc, + $rule_number + ]; + + // Don't log it if there aren't any groups being removed! + if ( !count( $groups ) ) { + break; + } + + // Log it. + $log = new LogPage( 'rights' ); + + $log->addEntry( 'rights', + $wgUser->getUserPage(), + wfMessage( + 'abusefilter-degroupreason', + $rule_desc, + $rule_number + )->inContentLanguage()->text(), + [ + implode( ', ', $groups ), + '' + ], + self::getFilterUser() + ); + } + + break; + case 'blockautopromote': + global $wgUser; + if ( !$wgUser->isAnon() ) { + $blockPeriod = (int)mt_rand( 3 * 86400, 7 * 86400 ); // Block for 3-7 days. + ObjectCache::getMainStashInstance()->set( + self::autoPromoteBlockKey( $wgUser ), true, $blockPeriod + ); + + $message = [ + 'abusefilter-autopromote-blocked', + $rule_desc, + $rule_number + ]; + } + break; + + case 'block': + // Do nothing, handled at the end of executeFilterActions. Here for completeness. + break; + case 'flag': + // Do nothing. Here for completeness. + break; + + case 'tag': + // Mark with a tag on recentchanges. + global $wgUser; + + $actionID = implode( '-', [ + $title->getPrefixedText(), $wgUser->getName(), + $vars->getVar( 'ACTION' )->toString() + ] ); + + self::bufferTagsToSetByAction( [ $actionID => $parameters ] ); + break; + default: + if ( isset( $wgAbuseFilterCustomActionsHandlers[$action] ) ) { + $custom_function = $wgAbuseFilterCustomActionsHandlers[$action]; + if ( is_callable( $custom_function ) ) { + $msg = call_user_func( + $custom_function, + $action, + $parameters, + $title, + $vars, + $rule_desc, + $rule_number + ); + } + if ( isset( $msg ) ) { + $message = [ $msg ]; + } + } else { + wfDebugLog( 'AbuseFilter', "Unrecognised action $action" ); + } + } + + return $message; + } + + /** + * @param array[] $tagsByAction Map of (integer => string[]) + */ + private static function bufferTagsToSetByAction( array $tagsByAction ) { + global $wgAbuseFilterActions; + if ( isset( $wgAbuseFilterActions['tag'] ) && $wgAbuseFilterActions['tag'] ) { + foreach ( $tagsByAction as $actionID => $tags ) { + if ( !isset( self::$tagsToSet[$actionID] ) ) { + self::$tagsToSet[$actionID] = $tags; + } else { + self::$tagsToSet[$actionID] = array_merge( self::$tagsToSet[$actionID], $tags ); + } + } + } + } + + /** + * Perform a block by the AbuseFilter system user + * @param array $rule should have 'desc' and 'number' + * @param string $target + * @param string $expiry + * @param bool $isAutoBlock + * @param bool $preventEditOwnUserTalk + */ + protected static function doAbuseFilterBlock( + array $rule, + $target, + $expiry, + $isAutoBlock, + $preventEditOwnUserTalk = false + ) { + $filterUser = self::getFilterUser(); + $reason = wfMessage( + 'abusefilter-blockreason', + $rule['desc'], $rule['number'] + )->inContentLanguage()->text(); + + $block = new Block(); + $block->setTarget( $target ); + $block->setBlocker( $filterUser ); + $block->mReason = $reason; + $block->isHardblock( false ); + $block->isAutoblocking( $isAutoBlock ); + $block->prevents( 'createaccount', true ); + $block->prevents( 'editownusertalk', $preventEditOwnUserTalk ); + $block->mExpiry = SpecialBlock::parseExpiryInput( $expiry ); + + $success = $block->insert(); + + if ( $success ) { + // Log it only if the block was successful + $logParams = []; + $logParams['5::duration'] = ( $block->mExpiry === 'infinity' ) + ? 'indefinite' + : $expiry; + $flags = [ 'nocreate' ]; + if ( !$block->isAutoblocking() && !IP::isIPAddress( $target ) ) { + // Conditionally added same as SpecialBlock + $flags[] = 'noautoblock'; + } + if ( $preventEditOwnUserTalk === true ) { + $flags[] = 'nousertalk'; + } + $logParams['6::flags'] = implode( ',', $flags ); + + $logEntry = new ManualLogEntry( 'block', 'block' ); + $logEntry->setTarget( Title::makeTitle( NS_USER, $target ) ); + $logEntry->setComment( $reason ); + $logEntry->setPerformer( $filterUser ); + $logEntry->setParameters( $logParams ); + $blockIds = array_merge( [ $success['id'] ], $success['autoIds'] ); + $logEntry->setRelations( [ 'ipb_id' => $blockIds ] ); + $logEntry->publish( $logEntry->insert() ); + } + } + + /** + * @param string $throttleId + * @param array $types + * @param Title $title + * @param string $rateCount + * @param string $ratePeriod + * @param bool $global + * @return bool + */ + public static function isThrottled( $throttleId, $types, $title, $rateCount, + $ratePeriod, $global = false + ) { + $stash = ObjectCache::getMainStashInstance(); + $key = self::throttleKey( $throttleId, $types, $title, $global ); + $count = intval( $stash->get( $key ) ); + + wfDebugLog( 'AbuseFilter', "Got value $count for throttle key $key\n" ); + + if ( $count > 0 ) { + $stash->incr( $key ); + $count++; + wfDebugLog( 'AbuseFilter', "Incremented throttle key $key" ); + } else { + wfDebugLog( 'AbuseFilter', "Added throttle key $key with value 1" ); + $stash->add( $key, 1, $ratePeriod ); + $count = 1; + } + + if ( $count > $rateCount ) { + wfDebugLog( 'AbuseFilter', "Throttle $key hit value $count -- maximum is $rateCount." ); + + return true; // THROTTLED + } + + wfDebugLog( 'AbuseFilter', "Throttle $key not hit!" ); + + return false; // NOT THROTTLED + } + + /** + * @param string $type + * @param Title $title + * @return int|string + */ + public static function throttleIdentifier( $type, $title ) { + global $wgUser, $wgRequest; + + switch ( $type ) { + case 'ip': + $identifier = $wgRequest->getIP(); + break; + case 'user': + $identifier = $wgUser->getId(); + break; + case 'range': + $identifier = substr( IP::toHex( $wgRequest->getIP() ), 0, 4 ); + break; + case 'creationdate': + $reg = $wgUser->getRegistration(); + $identifier = $reg - ( $reg % 86400 ); + break; + case 'editcount': + // Hack for detecting different single-purpose accounts. + $identifier = $wgUser->getEditCount(); + break; + case 'site': + $identifier = 1; + break; + case 'page': + $identifier = $title->getPrefixedText(); + break; + default: + $identifier = 0; + } + + return $identifier; + } + + /** + * @param string $throttleId + * @param string $type + * @param Title $title + * @param bool $global + * @return string + */ + public static function throttleKey( $throttleId, $type, $title, $global = false ) { + $types = explode( ',', $type ); + + $identifiers = []; + + foreach ( $types as $subtype ) { + $identifiers[] = self::throttleIdentifier( $subtype, $title ); + } + + $identifier = sha1( implode( ':', $identifiers ) ); + + global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB; + + if ( $global && !$wgAbuseFilterIsCentral ) { + list( $globalSite, $globalPrefix ) = wfSplitWikiID( $wgAbuseFilterCentralDB ); + + return wfForeignMemcKey( + $globalSite, $globalPrefix, + 'abusefilter', 'throttle', $throttleId, $type, $identifier ); + } + + return wfMemcKey( 'abusefilter', 'throttle', $throttleId, $type, $identifier ); + } + + /** + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @return string + */ + public static function getGlobalRulesKey( $group ) { + global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB; + + if ( !$wgAbuseFilterIsCentral ) { + list( $globalSite, $globalPrefix ) = wfSplitWikiID( $wgAbuseFilterCentralDB ); + + return wfForeignMemcKey( + $globalSite, $globalPrefix, + 'abusefilter', 'rules', $group + ); + } + + return wfMemcKey( 'abusefilter', 'rules', $group ); + } + + /** + * @param User $user + * @return string + */ + public static function autoPromoteBlockKey( $user ) { + return wfMemcKey( 'abusefilter', 'block-autopromote', $user->getId() ); + } + + /** + * Update statistics, and disable filters which are over-blocking. + * @param bool[] $filters + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + */ + public static function recordStats( $filters, $group = 'default' ) { + global $wgAbuseFilterConditionLimit, $wgAbuseFilterProfileActionsCap; + + $stash = ObjectCache::getMainStashInstance(); + + // Figure out if we've triggered overflows and blocks. + $overflow_triggered = ( self::$condCount > $wgAbuseFilterConditionLimit ); + + // Store some keys... + $overflow_key = self::filterLimitReachedKey(); + $total_key = self::filterUsedKey( $group ); + + $total = $stash->get( $total_key ); + + $storage_period = self::$statsStoragePeriod; + + if ( !$total || $total > $wgAbuseFilterProfileActionsCap ) { + // This is for if the total doesn't exist, or has gone past 10,000. + // Recreate all the keys at the same time, so they expire together. + $stash->set( $total_key, 0, $storage_period ); + $stash->set( $overflow_key, 0, $storage_period ); + + foreach ( $filters as $filter => $matched ) { + $stash->set( self::filterMatchesKey( $filter ), 0, $storage_period ); + } + $stash->set( self::filterMatchesKey(), 0, $storage_period ); + } + + // Increment total + $stash->incr( $total_key ); + + // Increment overflow counter, if our condition limit overflowed + if ( $overflow_triggered ) { + $stash->incr( $overflow_key ); + } + } + + /** + * Record runtime profiling data + * + * @param int $totalFilters + * @param int $totalConditions + * @param float $runtime + */ + private static function recordRuntimeProfilingResult( $totalFilters, $totalConditions, $runtime ) { + $keyPrefix = 'abusefilter.runtime-profile.' . wfWikiID() . '.'; + + $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); + $statsd->timing( $keyPrefix . 'runtime', $runtime ); + $statsd->timing( $keyPrefix . 'total_filters', $totalFilters ); + $statsd->timing( $keyPrefix . 'total_conditions', $totalConditions ); + } + + /** + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @param string[] $filters + * @param int $total + */ + public static function checkEmergencyDisable( $group, $filters, $total ) { + global $wgAbuseFilterEmergencyDisableThreshold, $wgAbuseFilterEmergencyDisableCount, + $wgAbuseFilterEmergencyDisableAge; + + $stash = ObjectCache::getMainStashInstance(); + foreach ( $filters as $filter ) { + // determine emergency disable values for this action + $emergencyDisableThreshold = + self::getEmergencyValue( $wgAbuseFilterEmergencyDisableThreshold, $group ); + $filterEmergencyDisableCount = + self::getEmergencyValue( $wgAbuseFilterEmergencyDisableCount, $group ); + $emergencyDisableAge = + self::getEmergencyValue( $wgAbuseFilterEmergencyDisableAge, $group ); + + // Increment counter + $matchCount = $stash->get( self::filterMatchesKey( $filter ) ); + + // Handle missing keys... + if ( !$matchCount ) { + $stash->set( self::filterMatchesKey( $filter ), 1, self::$statsStoragePeriod ); + } else { + $stash->incr( self::filterMatchesKey( $filter ) ); + } + $matchCount++; + + // Figure out if the filter is subject to being deleted. + $filter_age = wfTimestamp( TS_UNIX, self::getFilter( $filter )->af_timestamp ); + $throttle_exempt_time = $filter_age + $emergencyDisableAge; + + if ( $total && $throttle_exempt_time > time() + && $matchCount > $filterEmergencyDisableCount + && ( $matchCount / $total ) > $emergencyDisableThreshold + ) { + // More than $wgAbuseFilterEmergencyDisableCount matches, + // constituting more than $emergencyDisableThreshold + // (a fraction) of last few edits. Disable it. + DeferredUpdates::addUpdate( + new AutoCommitUpdate( + wfGetDB( DB_MASTER ), + __METHOD__, + function ( IDatabase $dbw, $fname ) use ( $filter ) { + $dbw->update( 'abuse_filter', + [ 'af_throttled' => 1 ], + [ 'af_id' => $filter ], + $fname + ); + } + ) + ); + } + } + } + + /** + * @param array $emergencyValue + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @return mixed + */ + public static function getEmergencyValue( array $emergencyValue, $group ) { + return isset( $emergencyValue[$group] ) ? $emergencyValue[$group] : $emergencyValue['default']; + } + + /** + * @return string + */ + public static function filterLimitReachedKey() { + return wfMemcKey( 'abusefilter', 'stats', 'overflow' ); + } + + /** + * @param string|null $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @return string + */ + public static function filterUsedKey( $group = null ) { + return wfMemcKey( 'abusefilter', 'stats', 'total', $group ); + } + + /** + * @param string|null $filter + * @return string + */ + public static function filterMatchesKey( $filter = null ) { + return wfMemcKey( 'abusefilter', 'stats', 'matches', $filter ); + } + + /** + * @return User + */ + public static function getFilterUser() { + $username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text(); + $user = User::newSystemUser( $username, [ 'steal' => true ] ); + + // Promote user to 'sysop' so it doesn't look + // like an unprivileged account is blocking users + if ( !in_array( 'sysop', $user->getGroups() ) ) { + $user->addGroup( 'sysop' ); + } + + return $user; + } + + /** + * Extract values for syntax highlight + * + * @param bool $canEdit + * @return array + */ + public static function getAceConfig( $canEdit ) { + $values = self::getBuilderValues(); + $builderVariables = implode( '|', array_keys( $values['vars'] ) ); + $builderFunctions = implode( '|', array_keys( AbuseFilterParser::$mFunctions ) ); + // AbuseFilterTokenizer::$keywords also includes constants (true, false and null), + // but Ace redefines these constants afterwards so this will not be an issue + $builderKeywords = implode( '|', AbuseFilterTokenizer::$keywords ); + + return [ + 'variables' => $builderVariables, + 'functions' => $builderFunctions, + 'keywords' => $builderKeywords, + 'aceReadOnly' => !$canEdit + ]; + } + + /** + * @param string $rules + * @param string $textName + * @param bool $addResultDiv + * @param bool $canEdit + * @param bool $externalForm + * @return string + */ + static function buildEditBox( $rules, $textName = 'wpFilterRules', $addResultDiv = true, + $canEdit = true, $externalForm = false ) { + global $wgOut; + + $wgOut->enableOOUI(); + $editorAttrib = [ 'dir' => 'ltr' ]; # Rules are in English + + global $wgUser; + $noTestAttrib = []; + if ( !$wgUser->isAllowed( 'abusefilter-modify' ) ) { + $noTestAttrib['disabled'] = 'disabled'; + $addResultDiv = false; + } + + $rules = rtrim( $rules ) . "\n"; + + if ( ExtensionRegistry::getInstance()->isLoaded( 'CodeEditor' ) ) { + $editorAttrib['name'] = 'wpAceFilterEditor'; + $editorAttrib['id'] = 'wpAceFilterEditor'; + $editorAttrib['class'] = 'mw-abusefilter-editor'; + + $switchEditor = + new OOUI\ButtonWidget( + [ + 'label' => wfMessage( 'abusefilter-edit-switch-editor' )->text(), + 'id' => 'mw-abusefilter-switcheditor' + ] + $noTestAttrib + ); + + $rulesContainer = Xml::element( 'div', $editorAttrib, $rules ); + + // Dummy textarea for submitting form and to use in case JS is disabled + $textareaAttribs = []; + if ( $externalForm ) { + $textareaAttribs['form'] = 'wpFilterForm'; + } + $rulesContainer .= Xml::textarea( $textName, $rules, 40, 15, $textareaAttribs ); + + $editorConfig = self::getAceConfig( $canEdit ); + + // Add Ace configuration variable + $wgOut->addJsConfigVars( 'aceConfig', $editorConfig ); + } else { + if ( !$canEdit ) { + $editorAttrib['readonly'] = 'readonly'; + } + if ( $externalForm ) { + $editorAttrib['form'] = 'wpFilterForm'; + } + $rulesContainer = Xml::textarea( $textName, $rules, 40, 15, $editorAttrib ); + } + + if ( $canEdit ) { + // Generate builder drop-down + $dropDown = self::getBuilderValues(); + + // The array needs to be rearranged to be understood by OOUI + foreach ( $dropDown as $group => $values ) { + // Give grep a chance to find the usages: + // abusefilter-edit-builder-group-op-arithmetic, abusefilter-edit-builder-group-op-comparison, + // abusefilter-edit-builder-group-op-bool, abusefilter-edit-builder-group-misc, + // abusefilter-edit-builder-group-funcs, abusefilter-edit-builder-group-vars + $localisedLabel = wfMessage( "abusefilter-edit-builder-group-$group" )->text(); + $dropDown[ $localisedLabel ] = $dropDown[ $group ]; + unset( $dropDown[ $group ] ); + $dropDown[ $localisedLabel ] = array_flip( $dropDown[ $localisedLabel ] ); + foreach ( $values as $content => $name ) { + $localisedInnerLabel = wfMessage( "abusefilter-edit-builder-$group-$name" )->text(); + $dropDown[ $localisedLabel ][ $localisedInnerLabel ] = $dropDown[ $localisedLabel ][ $name ]; + unset( $dropDown[ $localisedLabel ][ $name ] ); + } + } + + $dropDown = [ wfMessage( 'abusefilter-edit-builder-select' )->text() => 'other' ] + $dropDown; + $dropDown = Xml::listDropDownOptionsOoui( $dropDown ); + $dropDown = new OOUI\DropdownInputWidget( [ + 'name' => 'wpFilterBuilder', + 'inputId' => 'wpFilterBuilder', + 'options' => $dropDown + ] ); + + $dropDown = new OOUI\FieldLayout( $dropDown ); + $formElements = [ $dropDown ]; + + // Button for syntax check + $syntaxCheck = + new OOUI\ButtonWidget( + [ + 'label' => wfMessage( 'abusefilter-edit-check' )->text(), + 'id' => 'mw-abusefilter-syntaxcheck' + ] + $noTestAttrib + ); + $group = $syntaxCheck; + + // Button for switching editor (if Ace is used) + if ( isset( $switchEditor ) ) { + $group = + new OOUI\Widget( [ + 'content' => new OOUI\HorizontalLayout( [ + 'items' => [ $switchEditor, $syntaxCheck ] + ] ) + ] ); + } + $group = new OOUI\FieldLayout( $group ); + $formElements[] = $group; + + $fieldSet = new OOUI\FieldsetLayout( [ + 'items' => $formElements, + 'classes' => [ 'mw-abusefilter-edit-buttons' ] + ] ); + + $rulesContainer .= $fieldSet; + } + + if ( $addResultDiv ) { + $rulesContainer .= Xml::element( 'div', + [ 'id' => 'mw-abusefilter-syntaxresult', 'style' => 'display: none;' ], + ' ' ); + } + + // Add script + $wgOut->addModules( 'ext.abuseFilter.edit' ); + self::$editboxName = $textName; + + return $rulesContainer; + } + + /** + * Build input and button for loading a filter + * + * @return string + */ + static function buildFilterLoader() { + $loadText = + new OOUI\TextInputWidget( + [ + 'type' => 'number', + 'name' => 'wpInsertFilter', + 'id' => 'mw-abusefilter-load-filter' + ] + ); + $loadButton = + new OOUI\ButtonInputWidget( + [ + 'label' => wfMessage( 'abusefilter-test-load' )->text(), + 'id' => 'mw-abusefilter-load' + ] + ); + $loadGroup = + new OOUI\ActionFieldLayout( + $loadText, + $loadButton, + [ + 'label' => wfMessage( 'abusefilter-test-load-filter' )->text() + ] + ); + // CSS class for reducing default input field width + $loadDiv = + Xml::tags( + 'div', + [ 'class' => 'mw-abusefilter-load-filter-id' ], + $loadGroup + ); + return $loadDiv; + } + + /** + * Each version is expected to be an array( $row, $actions ) + * Returns an array of fields that are different. + * + * @param array $version_1 + * @param array $version_2 + * + * @return array + */ + static function compareVersions( $version_1, $version_2 ) { + $compareFields = [ + 'af_public_comments', + 'af_pattern', + 'af_comments', + 'af_deleted', + 'af_enabled', + 'af_hidden', + 'af_global', + 'af_group', + ]; + $differences = []; + + list( $row1, $actions1 ) = $version_1; + list( $row2, $actions2 ) = $version_2; + + foreach ( $compareFields as $field ) { + if ( !isset( $row2->$field ) || $row1->$field != $row2->$field ) { + $differences[] = $field; + } + } + + global $wgAbuseFilterActions; + foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { + if ( !isset( $actions1[$action] ) && !isset( $actions2[$action] ) ) { + // They're both unset + } elseif ( isset( $actions1[$action] ) && isset( $actions2[$action] ) ) { + // They're both set. + // Double check needed, e.g. per T180194 + if ( array_diff( $actions1[$action]['parameters'], + $actions2[$action]['parameters'] ) || + array_diff( $actions2[$action]['parameters'], + $actions1[$action]['parameters'] ) ) { + // Different parameters + $differences[] = 'actions'; + } + } else { + // One's unset, one's set. + $differences[] = 'actions'; + } + } + + return array_unique( $differences ); + } + + /** + * @param stdClass $row + * @return array + */ + static function translateFromHistory( $row ) { + # Translate into an abuse_filter row with some black magic. + # This is ever so slightly evil! + $af_row = new stdClass; + + foreach ( self::$history_mappings as $af_col => $afh_col ) { + $af_row->$af_col = $row->$afh_col; + } + + # Process flags + + $af_row->af_deleted = 0; + $af_row->af_hidden = 0; + $af_row->af_enabled = 0; + + $flags = explode( ',', $row->afh_flags ); + foreach ( $flags as $flag ) { + $col_name = "af_$flag"; + $af_row->$col_name = 1; + } + + # Process actions + $actions_raw = unserialize( $row->afh_actions ); + $actions_output = []; + if ( is_array( $actions_raw ) ) { + foreach ( $actions_raw as $action => $parameters ) { + $actions_output[$action] = [ + 'action' => $action, + 'parameters' => $parameters + ]; + } + } + + return [ $af_row, $actions_output ]; + } + + /** + * @param string $action + * @return string + */ + static function getActionDisplay( $action ) { + // Give grep a chance to find the usages: + // abusefilter-action-tag, abusefilter-action-throttle, abusefilter-action-warn, + // abusefilter-action-blockautopromote, abusefilter-action-block, abusefilter-action-degroup, + // abusefilter-action-rangeblock, abusefilter-action-disallow + $display = wfMessage( "abusefilter-action-$action" )->escaped(); + $display = wfMessage( "abusefilter-action-$action", $display )->isDisabled() ? $action : $display; + + return $display; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder|null + */ + public static function getVarsFromRCRow( $row ) { + if ( $row->rc_log_type == 'move' ) { + $vars = self::getMoveVarsFromRCRow( $row ); + } elseif ( $row->rc_log_type == 'newusers' ) { + $vars = self::getCreateVarsFromRCRow( $row ); + } elseif ( $row->rc_log_type == 'delete' ) { + $vars = self::getDeleteVarsFromRCRow( $row ); + } elseif ( $row->rc_this_oldid ) { + // It's an edit. + $vars = self::getEditVarsFromRCRow( $row ); + } else { + return null; + } + if ( $vars ) { + $vars->setVar( 'context', 'generated' ); + $vars->setVar( 'timestamp', wfTimestamp( TS_UNIX, $row->rc_timestamp ) ); + } + + return $vars; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder + */ + public static function getCreateVarsFromRCRow( $row ) { + $vars = new AbuseFilterVariableHolder; + + $vars->setVar( 'ACTION', ( $row->rc_log_action == 'autocreate' ) ? + 'autocreateaccount' : + 'createaccount' ); + + $name = Title::makeTitle( $row->rc_namespace, $row->rc_title )->getText(); + // Add user data if the account was created by a registered user + if ( $row->rc_user && $name != $row->rc_user_text ) { + $user = User::newFromName( $row->rc_user_text ); + $vars->addHolders( self::generateUserVars( $user ) ); + } + + $vars->setVar( 'accountname', $name ); + + return $vars; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder + */ + public static function getDeleteVarsFromRCRow( $row ) { + $vars = new AbuseFilterVariableHolder; + $title = Title::makeTitle( $row->rc_namespace, $row->rc_title ); + + if ( $row->rc_user ) { + $user = User::newFromName( $row->rc_user_text ); + } else { + $user = new User; + $user->setName( $row->rc_user_text ); + } + + $vars->addHolders( + self::generateUserVars( $user ), + self::generateTitleVars( $title, 'ARTICLE' ) + ); + + $vars->setVar( 'ACTION', 'delete' ); + $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text ); + + return $vars; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder + */ + public static function getEditVarsFromRCRow( $row ) { + $vars = new AbuseFilterVariableHolder; + $title = Title::makeTitle( $row->rc_namespace, $row->rc_title ); + + if ( $row->rc_user ) { + $user = User::newFromName( $row->rc_user_text ); + } else { + $user = new User; + $user->setName( $row->rc_user_text ); + } + + $vars->addHolders( + self::generateUserVars( $user ), + self::generateTitleVars( $title, 'ARTICLE' ) + ); + + $vars->setVar( 'ACTION', 'edit' ); + $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text ); + + $vars->setLazyLoadVar( 'new_wikitext', 'revision-text-by-id', + [ 'revid' => $row->rc_this_oldid ] ); + + if ( $row->rc_last_oldid ) { + $vars->setLazyLoadVar( 'old_wikitext', 'revision-text-by-id', + [ 'revid' => $row->rc_last_oldid ] ); + } else { + $vars->setVar( 'old_wikitext', '' ); + } + + $vars->addHolders( self::getEditVars( $title ) ); + + return $vars; + } + + /** + * @param stdClass $row + * @return AbuseFilterVariableHolder + */ + public static function getMoveVarsFromRCRow( $row ) { + if ( $row->rc_user ) { + $user = User::newFromId( $row->rc_user ); + } else { + $user = new User; + $user->setName( $row->rc_user_text ); + } + + $params = array_values( DatabaseLogEntry::newFromRow( $row )->getParameters() ); + + $oldTitle = Title::makeTitle( $row->rc_namespace, $row->rc_title ); + $newTitle = Title::newFromText( $params[0] ); + + $vars = AbuseFilterVariableHolder::merge( + self::generateUserVars( $user ), + self::generateTitleVars( $oldTitle, 'MOVED_FROM' ), + self::generateTitleVars( $newTitle, 'MOVED_TO' ) + ); + + $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text ); + $vars->setVar( 'ACTION', 'move' ); + + return $vars; + } + + /** + * @param Title $title + * @param Page|null $page + * @return AbuseFilterVariableHolder + */ + public static function getEditVars( $title, Page $page = null ) { + $vars = new AbuseFilterVariableHolder; + + // NOTE: $page may end up remaining null, e.g. if $title points to a special page. + if ( !$page && $title instanceof Title && $title->canExist() ) { + $page = WikiPage::factory( $title ); + } + + $vars->setLazyLoadVar( 'edit_diff', 'diff', + [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_wikitext' ] ); + $vars->setLazyLoadVar( 'edit_diff_pst', 'diff', + [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_pst' ] ); + $vars->setLazyLoadVar( 'new_size', 'length', [ 'length-var' => 'new_wikitext' ] ); + $vars->setLazyLoadVar( 'old_size', 'length', [ 'length-var' => 'old_wikitext' ] ); + $vars->setLazyLoadVar( 'edit_delta', 'subtract-int', + [ 'val1-var' => 'new_size', 'val2-var' => 'old_size' ] ); + + // Some more specific/useful details about the changes. + $vars->setLazyLoadVar( 'added_lines', 'diff-split', + [ 'diff-var' => 'edit_diff', 'line-prefix' => '+' ] ); + $vars->setLazyLoadVar( 'removed_lines', 'diff-split', + [ 'diff-var' => 'edit_diff', 'line-prefix' => '-' ] ); + $vars->setLazyLoadVar( 'added_lines_pst', 'diff-split', + [ 'diff-var' => 'edit_diff_pst', 'line-prefix' => '+' ] ); + + // Links + $vars->setLazyLoadVar( 'added_links', 'link-diff-added', + [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] ); + $vars->setLazyLoadVar( 'removed_links', 'link-diff-removed', + [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] ); + $vars->setLazyLoadVar( 'new_text', 'strip-html', + [ 'html-var' => 'new_html' ] ); + $vars->setLazyLoadVar( 'old_text', 'strip-html', + [ 'html-var' => 'old_html' ] ); + + if ( $title instanceof Title ) { + $vars->setLazyLoadVar( 'all_links', 'links-from-wikitext', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'text-var' => 'new_wikitext', + 'article' => $page + ] ); + $vars->setLazyLoadVar( 'old_links', 'links-from-wikitext-or-database', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'text-var' => 'old_wikitext' + ] ); + $vars->setLazyLoadVar( 'new_pst', 'parse-wikitext', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'wikitext-var' => 'new_wikitext', + 'article' => $page, + 'pst' => true, + ] ); + $vars->setLazyLoadVar( 'new_html', 'parse-wikitext', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'wikitext-var' => 'new_wikitext', + 'article' => $page + ] ); + $vars->setLazyLoadVar( 'old_html', 'parse-wikitext-nonedit', + [ + 'namespace' => $title->getNamespace(), + 'title' => $title->getText(), + 'wikitext-var' => 'old_wikitext' + ] ); + } + + return $vars; + } + + /** + * @param AbuseFilterVariableHolder|array $vars + * @param IContextSource $context + * @return string + */ + public static function buildVarDumpTable( $vars, IContextSource $context ) { + // Export all values + if ( $vars instanceof AbuseFilterVariableHolder ) { + $vars = $vars->exportAllVars(); + } + + $output = ''; + + // I don't want to change the names of the pre-existing messages + // describing the variables, nor do I want to rewrite them, so I'm just + // mapping the variable names to builder messages with a pre-existing array. + $variableMessageMappings = self::getBuilderValues(); + $variableMessageMappings = $variableMessageMappings['vars']; + + $output .= + Xml::openElement( 'table', [ 'class' => 'mw-abuselog-details' ] ) . + Xml::openElement( 'tbody' ) . + "\n"; + + $header = + Xml::element( 'th', null, $context->msg( 'abusefilter-log-details-var' )->text() ) . + Xml::element( 'th', null, $context->msg( 'abusefilter-log-details-val' )->text() ); + $output .= Xml::tags( 'tr', null, $header ) . "\n"; + + if ( !count( $vars ) ) { + $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); + + return $output; + } + + // Now, build the body of the table. + foreach ( $vars as $key => $value ) { + $key = strtolower( $key ); + + if ( !empty( $variableMessageMappings[$key] ) ) { + $mapping = $variableMessageMappings[$key]; + $keyDisplay = $context->msg( "abusefilter-edit-builder-vars-$mapping" )->parse() . + ' ' . Xml::element( 'code', null, $context->msg( 'parentheses', $key )->text() ); + } else { + $keyDisplay = Xml::element( 'code', null, $key ); + } + + if ( is_null( $value ) ) { + $value = ''; + } + $value = Xml::element( 'div', [ 'class' => 'mw-abuselog-var-value' ], $value, false ); + + $trow = + Xml::tags( 'td', [ 'class' => 'mw-abuselog-var' ], $keyDisplay ) . + Xml::tags( 'td', [ 'class' => 'mw-abuselog-var-value' ], $value ); + $output .= + Xml::tags( 'tr', + [ 'class' => "mw-abuselog-details-$key mw-abuselog-value" ], $trow + ) . "\n"; + } + + $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); + + return $output; + } + + /** + * @param string $action + * @param string[] $parameters + * @return string + */ + static function formatAction( $action, $parameters ) { + /** @var $wgLang Language */ + global $wgLang; + if ( count( $parameters ) === 0 || + ( $action === 'block' && count( $parameters ) !== 3 ) ) { + $displayAction = self::getActionDisplay( $action ); + } else { + if ( $action === 'block' ) { + // Needs to be treated separately since the message is more complex + $displayAction = self::getActionDisplay( 'block' ) . + ' ' . + wfMessage( 'abusefilter-block-anon' ) . + wfMessage( 'colon-separator' )->escaped() . + $wgLang->translateBlockExpiry( $parameters[1] ) . + wfMessage( 'comma-separator' )->escaped() . + $wgLang->lcfirst( self::getActionDisplay( 'block' ) ) . + ' ' . + wfMessage( 'abusefilter-block-user' ) . + wfMessage( 'colon-separator' )->escaped() . + $wgLang->translateBlockExpiry( $parameters[2] ); + } else { + $displayAction = self::getActionDisplay( $action ) . + wfMessage( 'colon-separator' )->escaped() . + htmlspecialchars( $wgLang->semicolonList( $parameters ) ); + } + } + + return $displayAction; + } + + /** + * @param string $value + * @return string + */ + static function formatFlags( $value ) { + /** @var $wgLang Language */ + global $wgLang; + $flags = array_filter( explode( ',', $value ) ); + $flags_display = []; + foreach ( $flags as $flag ) { + $flags_display[] = wfMessage( "abusefilter-history-$flag" )->escaped(); + } + + return $wgLang->commaList( $flags_display ); + } + + /** + * @param string $filterID + * @return string + */ + static function getGlobalFilterDescription( $filterID ) { + global $wgAbuseFilterCentralDB; + + if ( !$wgAbuseFilterCentralDB ) { + return ''; + } + + static $cache = []; + if ( isset( $cache[$filterID] ) ) { + return $cache[$filterID]; + } + + $fdb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + + $cache[$filterID] = $fdb->selectField( + 'abuse_filter', + 'af_public_comments', + [ 'af_id' => $filterID ], + __METHOD__ + ); + + return $cache[$filterID]; + } + + /** + * Gives either the user-specified name for a group, + * or spits the input back out + * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups) + * @return string A name for that filter group, or the input. + */ + static function nameGroup( $group ) { + // Give grep a chance to find the usages: abusefilter-group-default + $msg = "abusefilter-group-$group"; + + return wfMessage( $msg )->exists() ? wfMessage( $msg )->escaped() : $group; + } + + /** + * Look up some text of a revision from its revision id + * + * Note that this is really *some* text, we do not make *any* guarantee + * that this text will be even close to what the user actually sees, or + * that the form is fit for any intended purpose. + * + * Note also that if the revision for any reason is not an Revision + * the function returns with an empty string. + * + * @param Revision $revision a valid revision + * @param int $audience one of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @return string|null the content of the revision as some kind of string, + * or an empty string if it can not be found + */ + static function revisionToString( $revision, $audience = Revision::FOR_THIS_USER ) { + if ( !$revision instanceof Revision ) { + return ''; + } + + $content = $revision->getContent( $audience ); + if ( $content === null ) { + return ''; + } + $result = self::contentToString( $content ); + + return $result; + } + + /** + * Converts the given Content object to a string. + * + * This uses Content::getNativeData() if $content is an instance of TextContent, + * or Content::getTextForSearchIndex() otherwise. + * + * The hook 'AbuseFilter::contentToString' can be used to override this + * behavior. + * + * @param Content $content + * + * @return string a suitable string representation of the content. + */ + static function contentToString( Content $content ) { + $text = null; + + if ( Hooks::run( 'AbuseFilter-contentToString', [ $content, &$text ] ) ) { + $text = $content instanceof TextContent + ? $content->getNativeData() + : $content->getTextForSearchIndex(); + } + + if ( is_string( $text ) ) { + // T22310 + // XXX: Is this really needed? Should we rather apply PST? + $text = str_replace( "\r\n", "\n", $text ); + } else { + $text = ''; + } + + return $text; + } + + /** + * Get the history ID of the first change to a given filter + * + * @param int $filterID Filter id + * @return int + */ + public static function getFirstFilterChange( $filterID ) { + static $firstChanges = []; + + if ( !isset( $firstChanges[$filterID] ) ) { + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'abuse_filter_history', + 'afh_id', + [ + 'afh_filter' => $filterID, + ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp ASC' ] + ); + $firstChanges[$filterID] = $row->afh_id; + } + + return $firstChanges[$filterID]; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/AbuseFilterChangesList.php b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterChangesList.php new file mode 100644 index 00000000..c5996796 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterChangesList.php @@ -0,0 +1,118 @@ +<?php + +class AbuseFilterChangesList extends OldChangesList { + + /** + * @var string + */ + private $testFilter; + + public function __construct( Skin $skin, $testFilter ) { + parent::__construct( $skin ); + $this->testFilter = $testFilter; + } + + /** + * @param string &$s + * @param RecentChange &$rc + * @param string[] &$classes + */ + public function insertExtra( &$s, &$rc, &$classes ) { + if ( (int)$rc->getAttribute( 'rc_deleted' ) !== 0 ) { + $s .= ' ' . $this->msg( 'abusefilter-log-hidden-implicit' )->parse(); + if ( !$this->userCan( $rc, Revision::SUPPRESSED_ALL ) ) { + return; + } + } + + $examineParams = []; + if ( $this->testFilter ) { + $examineParams['testfilter'] = $this->testFilter; + } + + $title = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/' . $rc->mAttribs['rc_id'] ); + $examineLink = $this->linkRenderer->makeLink( + $title, + new HtmlArmor( $this->msg( 'abusefilter-changeslist-examine' )->parse() ), + [], + $examineParams + ); + + $s .= ' '.$this->msg( 'parentheses' )->rawParams( $examineLink )->escaped(); + + # If we have a match.. + if ( isset( $rc->filterResult ) ) { + $class = $rc->filterResult ? + 'mw-abusefilter-changeslist-match' : + 'mw-abusefilter-changeslist-nomatch'; + + $classes[] = $class; + } + } + + /** + * Insert links to user page, user talk page and eventually a blocking link. + * Like the parent, but don't hide details if user can see them. + * + * @param string &$s HTML to update + * @param RecentChange &$rc + */ + public function insertUserRelatedLinks( &$s, &$rc ) { + $links = $this->getLanguage()->getDirMark() . Linker::userLink( $rc->mAttribs['rc_user'], + $rc->mAttribs['rc_user_text'] ) . + Linker::userToolLinks( $rc->mAttribs['rc_user'], $rc->mAttribs['rc_user_text'] ); + + if ( $this->isDeleted( $rc, Revision::DELETED_USER ) ) { + if ( $this->userCan( $rc, Revision::DELETED_USER ) ) { + $s .= ' <span class="history-deleted">' . $links . '</span>'; + } else { + $s .= ' <span class="history-deleted">' . + $this->msg( 'rev-deleted-user' )->escaped() . '</span>'; + } + } else { + $s .= $links; + } + } + + /** + * Insert a formatted comment. Like the parent, but don't hide details if user can see them. + * @param RecentChange $rc + * @return string + */ + public function insertComment( $rc ) { + if ( $this->isDeleted( $rc, Revision::DELETED_COMMENT ) ) { + if ( $this->userCan( $rc, Revision::DELETED_COMMENT ) ) { + return ' <span class="history-deleted">' . + Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() ) . '</span>'; + } else { + return ' <span class="history-deleted">' . + $this->msg( 'rev-deleted-comment' )->escaped() . '</span>'; + } + } else { + return Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() ); + } + } + + /** + * Insert a formatted action. The same as parent, but with a different audience in LogFormatter + * + * @param RecentChange $rc + * @return string + */ + public function insertLogEntry( $rc ) { + $formatter = LogFormatter::newFromRow( $rc->mAttribs ); + $formatter->setContext( $this->getContext() ); + $formatter->setAudience( LogFormatter::FOR_THIS_USER ); + $formatter->setShowUserToolLinks( true ); + $mark = $this->getLanguage()->getDirMark(); + return $formatter->getActionText() . " $mark" . $formatter->getComment(); + } + + /** + * @param string &$s + * @param RecentChange &$rc + */ + public function insertRollback( &$s, &$rc ) { + // Kill rollback links. + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/AbuseFilterHooks.php b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterHooks.php new file mode 100644 index 00000000..07e289bd --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterHooks.php @@ -0,0 +1,851 @@ +<?php + +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\Database; + +class AbuseFilterHooks { + const FETCH_ALL_TAGS_KEY = 'abusefilter-fetch-all-tags'; + + public static $successful_action_vars = false; + /** @var WikiPage|Article|bool */ + public static $last_edit_page = false; // make sure edit filter & edit save hooks match + // So far, all of the error message out-params for these hooks accept HTML. + // Hooray! + + /** + * Called right after configuration has been loaded. + */ + public static function onRegistration() { + global $wgAbuseFilterAvailableActions, $wgAbuseFilterRestrictedActions, + $wgAuthManagerAutoConfig; + + 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.' ); + } + + $wgAuthManagerAutoConfig['preauth'][AbuseFilterPreAuthenticationProvider::class] = [ + 'class' => AbuseFilterPreAuthenticationProvider::class, + 'sort' => 5, // run after normal preauth providers to keep the log cleaner + ]; + } + + /** + * Entry point for the EditFilterMergedContent hook. + * + * @param IContextSource $context the context of the edit + * @param Content $content the new Content generated by the edit + * @param Status $status Error message to return + * @param string $summary Edit summary for page + * @param User $user the user performing the edit + * @param bool $minoredit whether this is a minor edit according to the user. + * @return bool Always true + */ + public static function onEditFilterMergedContent( IContextSource $context, Content $content, + Status $status, $summary, User $user, $minoredit + ) { + $text = AbuseFilter::contentToString( $content ); + + $filterStatus = self::filterEdit( $context, $content, $text, $status, $summary, $minoredit ); + + if ( !$filterStatus->isOK() ) { + // Produce a useful error message for API edits + $status->apiHookResult = self::getApiResult( $filterStatus ); + } + + return true; + } + + /** + * Implementation for EditFilterMergedContent hook. + * + * @param IContextSource $context the context of the edit + * @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. + * @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; + + $user = $context->getUser(); + + $oldcontent = null; + + if ( ( $title instanceof Title ) && $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, $oldcontent, $text + ); + + $filter_result = AbuseFilter::filterAction( $vars, $title ); + if ( !$filter_result->isOK() ) { + $status->merge( $filter_result ); + + return $filter_result; + } + + self::$successful_action_vars = $vars; + self::$last_edit_page = $page; + + return Status::newGood(); + } + + /** + * @param User $user + * @param Title $title + * @param WikiPage|null $page + * @param string $summary + * @param Content $newcontent + * @param Content|null $oldcontent + * @param string $text + * @return AbuseFilterVariableHolder + * @throws MWException + */ + private static function newVariableHolderForEdit( + User $user, Title $title, $page, $summary, Content $newcontent, + $oldcontent = null, $text + ) { + $vars = new AbuseFilterVariableHolder(); + $vars->addHolders( + AbuseFilter::generateUserVars( $user ), + AbuseFilter::generateTitleVars( $title, 'ARTICLE' ) + ); + $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 + */ + private static function getApiResult( Status $status ) { + global $wgFullyInitialised; + + $params = $status->getErrorsArray()[0]; + $key = array_shift( $params ); + + $warning = wfMessage( $key )->params( $params ); + if ( !$wgFullyInitialised ) { + // This could happen for account autocreation checks + $warning = $warning->inContentLanguage(); + } + + $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( + call_user_func_array( '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(), + ]; + } + + /** + * @param WikiPage &$wikiPage + * @param User &$user + * @param string $content Content + * @param string $summary + * @param bool $minoredit + * @param bool $watchthis + * @param string $sectionanchor + * @param int &$flags + * @param Revision $revision + * @param Status &$status + * @param int $baseRevId + * + * @return bool + */ + public static function onPageContentSaveComplete( + WikiPage &$wikiPage, &$user, $content, $summary, $minoredit, $watchthis, $sectionanchor, + &$flags, $revision, &$status, $baseRevId + ) { + if ( !self::$successful_action_vars || !$revision ) { + self::$successful_action_vars = false; + + return true; + } + + /** @var AbuseFilterVariableHolder $vars */ + $vars = self::$successful_action_vars; + + if ( $vars->getVar( 'article_prefixedtext' )->toString() !== + $wikiPage->getTitle()->getPrefixedText() + ) { + return true; + } + + if ( !self::identicalPageObjects( $wikiPage, self::$last_edit_page ) ) { + return true; // this isn't the edit $successful_action_vars was set for + } + self::$last_edit_page = false; + + if ( $vars->getVar( 'local_log_ids' ) ) { + // 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__ + ); + } + } + + 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__ + ); + } + } + + return true; + } + + /** + * 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 + * @return bool + */ + public static function onGetAutoPromoteGroups( $user, &$promote ) { + if ( $promote ) { + $key = AbuseFilter::autoPromoteBlockKey( $user ); + $blocked = (bool)ObjectCache::getInstance( 'hash' )->getWithSetCallback( + $key, + 30, + function () use ( $key ) { + return (int)ObjectCache::getMainStashInstance()->get( $key ); + } + ); + + if ( $blocked ) { + $promote = []; + } + } + + return true; + } + + /** + * @param Title $oldTitle + * @param Title $newTitle + * @param User $user + * @param string $reason + * @param Status $status + * @return bool + */ + public static function onMovePageCheckPermissions( 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 ); + $status->merge( $result ); + + return $result->isOK(); + } + + /** + * @param WikiPage &$article + * @param User &$user + * @param string &$reason + * @param string &$error + * @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(), 'ARTICLE' ) + ); + + $vars->setVar( 'SUMMARY', $reason ); + $vars->setVar( 'ACTION', 'delete' ); + + $filter_result = AbuseFilter::filterAction( $vars, $article->getTitle() ); + + $status->merge( $filter_result ); + $error = $filter_result->isOK() ? '' : $filter_result->getHTML(); + + return $filter_result->isOK(); + } + + /** + * @param RecentChange $recentChange + * @return bool + */ + public static function onRecentChangeSave( $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 + ] ); + + if ( isset( AbuseFilter::$tagsToSet[$actionID] ) ) { + $recentChange->addTags( AbuseFilter::$tagsToSet[$actionID] ); + } + + return true; + } + + /** + * Purge all cache related to tags, both within AbuseFilter and in core + */ + public static function purgeTagCache() { + ChangeTags::purgeTagCacheAll(); + + $services = MediaWikiServices::getInstance(); + $cache = $services->getMainWANObjectCache(); + + $cache->delete( + $cache->makeKey( self::FETCH_ALL_TAGS_KEY, 0 ) + ); + + $cache->delete( + $cache->makeKey( self::FETCH_ALL_TAGS_KEY, 1 ) + ); + } + + /** + * @param array $tags + * @param bool $enabled + * @return bool + */ + private static function fetchAllTags( array &$tags, $enabled ) { + $services = MediaWikiServices::getInstance(); + $cache = $services->getMainWANObjectCache(); + + $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 ) { + global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; + + $dbr = wfGetDB( DB_REPLICA ); + // Account for any snapshot/replica DB lag + $setOpts += Database::getCacheSetOptions( $dbr ); + + # This is a pretty awful hack. + + $where = [ 'afa_consequence' => 'tag', 'af_deleted' => false ]; + if ( $enabled ) { + $where['af_enabled'] = true; + } + $res = $dbr->select( + [ 'abuse_filter_action', 'abuse_filter' ], + 'afa_parameters', + $where, + __METHOD__, + [], + [ 'abuse_filter' => [ 'INNER JOIN', 'afa_filter=af_id' ] ] + ); + + foreach ( $res as $row ) { + $tags = array_filter( + array_merge( explode( "\n", $row->afa_parameters ), $tags ) + ); + } + + if ( $wgAbuseFilterCentralDB && !$wgAbuseFilterIsCentral ) { + $dbr = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + $where['af_global'] = 1; + $res = $dbr->select( + [ 'abuse_filter_action', 'abuse_filter' ], + 'afa_parameters', + $where, + __METHOD__, + [], + [ 'abuse_filter' => [ 'INNER JOIN', 'afa_filter=af_id' ] ] + ); + + foreach ( $res as $row ) { + $tags = array_filter( + array_merge( explode( "\n", $row->afa_parameters ), $tags ) + ); + } + } + + return $tags; + } + ); + + $tags[] = 'abusefilter-condition-limit'; + + return true; + } + + /** + * @param string[] &$tags + * @return bool + */ + public static function onListDefinedTags( array &$tags ) { + return self::fetchAllTags( $tags, false ); + } + + /** + * @param string[] &$tags + * @return bool + */ + public static function onChangeTagsListActive( array &$tags ) { + return self::fetchAllTags( $tags, true ); + } + + /** + * @param DatabaseUpdater $updater + * @throws MWException + * @return bool + */ + public static function onLoadExtensionSchemaUpdates( DatabaseUpdater $updater ) { + $dir = dirname( __DIR__ ); + + 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->addExtensionUpdate( [ + 'addField', 'abuse_filter_history', 'afh_changed_fields', + "$dir/db_patches/patch-afh_changed_fields.sql", true + ] ); + $updater->addExtensionUpdate( [ 'addField', 'abuse_filter', 'af_deleted', + "$dir/db_patches/patch-af_deleted.sql", true ] ); + $updater->addExtensionUpdate( [ 'addField', 'abuse_filter', 'af_actions', + "$dir/db_patches/patch-af_actions.sql", true ] ); + $updater->addExtensionUpdate( [ 'addField', 'abuse_filter', 'af_global', + "$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' ) { + $updater->addExtensionUpdate( [ 'addIndex', 'abuse_filter_log', + 'filter_timestamp', "$dir/db_patches/patch-fix-indexes.sql", true ] ); + } else { + $updater->addExtensionUpdate( [ + 'addIndex', 'abuse_filter_log', 'afl_filter_timestamp', + "$dir/db_patches/patch-fix-indexes.sqlite.sql", true + ] ); + } + + $updater->addExtensionUpdate( [ 'addField', 'abuse_filter', + 'af_group', "$dir/db_patches/patch-af_group.sql", true ] ); + + if ( $updater->getDB()->getType() == 'mysql' ) { + $updater->addExtensionUpdate( [ + 'addIndex', 'abuse_filter_log', 'wiki_timestamp', + "$dir/db_patches/patch-global_logging_wiki-index.sql", true + ] ); + } else { + $updater->addExtensionUpdate( [ + 'addIndex', 'abuse_filter_log', 'afl_wiki_timestamp', + "$dir/db_patches/patch-global_logging_wiki-index.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 + ] ); + } 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, ... + */ + } + } elseif ( $updater->getDB()->getType() == 'postgres' ) { + $updater->addExtensionUpdate( [ + 'addTable', 'abuse_filter', "$dir/abusefilter.tables.pg.sql", true ] ); + $updater->addExtensionUpdate( [ + 'addTable', 'abuse_filter_history', + "$dir/db_patches/patch-abuse_filter_history.pg.sql", true + ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter', 'af_actions', "TEXT NOT NULL DEFAULT ''" ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter', 'af_deleted', 'SMALLINT NOT NULL DEFAULT 0' ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter', 'af_global', 'SMALLINT NOT NULL DEFAULT 0' ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter_log', 'afl_wiki', 'TEXT' ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter_log', 'afl_deleted', 'SMALLINT' ] ); + $updater->addExtensionUpdate( [ + 'changeField', 'abuse_filter_log', 'afl_filter', 'TEXT', '' ] ); + $updater->addExtensionUpdate( [ + 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_ip', "(afl_ip)" ] ); + $updater->addExtensionUpdate( [ + 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_wiki', "(afl_wiki)" ] ); + $updater->addExtensionUpdate( [ + 'changeField', 'abuse_filter_log', 'afl_namespace', "INTEGER", '' ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter', 'af_group', "TEXT NOT NULL DEFAULT 'default'" ] ); + $updater->addExtensionUpdate( [ + 'addPgExtIndex', 'abuse_filter', 'abuse_filter_group_enabled_id', + "(af_group, af_enabled, af_id)" + ] ); + $updater->addExtensionUpdate( [ + 'addPgField', 'abuse_filter_history', 'afh_group', "TEXT" ] ); + } + + $updater->addExtensionUpdate( [ [ __CLASS__, 'createAbuseFilterUser' ] ] ); + + return true; + } + + /** + * Updater callback to create the AbuseFilter user after the user tables have been updated. + * @param DatabaseUpdater $updater + */ + public static function createAbuseFilterUser( $updater ) { + $username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text(); + $user = User::newFromName( $username ); + + if ( $user && !$updater->updateRowExists( 'create abusefilter-blocker-user' ) ) { + $user = User::newSystemUser( $username, [ 'steal' => true ] ); + $updater->insertUpdateRow( 'create abusefilter-blocker-user' ); + # Promote user so it doesn't look too crazy. + $user->addGroup( 'sysop' ); + } + } + + /** + * @param int $id + * @param Title $nt + * @param array &$tools + * @param SpecialPage $sp for context + */ + public static function onContributionsToolLinks( $id, $nt, array &$tools, SpecialPage $sp ) { + $username = $nt->getText(); + if ( $sp->getUser()->isAllowed( 'abusefilter-log' ) && !IP::isValidRange( $username ) ) { + $linkRenderer = $sp->getLinkRenderer(); + $tools['abuselog'] = $linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseLog' ), + $sp->msg( 'abusefilter-log-linkoncontribs' )->text(), + [ 'title' => $sp->msg( 'abusefilter-log-linkoncontribs-text', + $username )->text() ], + [ 'wpSearchUser' => $username ] + ); + } + } + + /** + * Filter an upload. + * + * @param UploadBase $upload + * @param User $user + * @param array $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 + ) { + return self::filterUpload( 'upload', $upload, $user, $props, $comment, $pageText, $error ); + } + + /** + * Filter an upload to stash. If a filter doesn't need to check the page contents or + * upload comment, it can use `action='stashupload'` to provide better experience to e.g. + * UploadWizard (rejecting files immediately, rather than after the user adds the details). + * + * @param UploadBase $upload + * @param User $user + * @param array $props + * @param array|ApiMessage &$error + * @return bool + */ + public static function onUploadStashFile( UploadBase $upload, User $user, + array $props, &$error + ) { + return self::filterUpload( 'stashupload', $upload, $user, $props, null, null, $error ); + } + + /** + * Implementation for UploadStashFile and UploadVerifyUpload hooks. + * + * @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 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 + ) { + $title = $upload->getTitle(); + + $vars = new AbuseFilterVariableHolder; + $vars->addHolders( + AbuseFilter::generateUserVars( $user ), + AbuseFilter::generateTitleVars( $title, 'ARTICLE' ) + ); + $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 ) ); + } + + $filter_result = AbuseFilter::filterAction( $vars, $title ); + + if ( !$filter_result->isOK() ) { + $messageAndParams = $filter_result->getErrorsArray()[0]; + $apiResult = self::getApiResult( $filter_result ); + $error = ApiMessage::create( + $messageAndParams, + $apiResult['code'], + $apiResult + ); + } + + return $filter_result->isOK(); + } + + /** + * Adds global variables to the Javascript as needed + * + * @param array &$vars + * @return bool + */ + 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, + ]; + } + + return true; + } + + /** + * Tables that Extension:UserMerge needs to update + * + * @param array &$updateFields + * @return bool + */ + public static function onUserMergeAccountFields( array &$updateFields ) { + $updateFields[] = [ 'abuse_filter', 'af_user', 'af_user_text' ]; + $updateFields[] = [ 'abuse_filter_log', 'afl_user', 'afl_user_text' ]; + $updateFields[] = [ 'abuse_filter_history', 'afh_user', 'afh_user_text' ]; + + return true; + } + + /** + * Warms the cache for getLastPageAuthors() - T116557 + * + * @param WikiPage $page + * @param Content $content + * @param ParserOutput $output + * @param string $summary + * @param User $user + */ + public static function onParserOutputStashForEdit( + WikiPage $page, Content $content, ParserOutput $output, $summary = '', $user = null + ) { + $revision = $page->getRevision(); + if ( !$revision ) { + return; + } + + $text = AbuseFilter::contentToString( $content ); + $oldcontent = $revision->getContent( Revision::RAW ); + $user = $user ?: RequestContext::getMain()->getUser(); + + // Cache any resulting filter matches. + // Do this outside the synchronous stash lock to avoid any chance of slowdown. + DeferredUpdates::addCallableUpdate( + function () use ( $user, $page, $summary, $content, $oldcontent, $text ) { + $vars = self::newVariableHolderForEdit( + $user, $page->getTitle(), $page, $summary, $content, $oldcontent, $text + ); + AbuseFilter::filterAction( $vars, $page->getTitle(), 'default', $user, 'stash' ); + }, + DeferredUpdates::PRESEND + ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php new file mode 100644 index 00000000..769c27d3 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php @@ -0,0 +1,53 @@ +<?php + +class AbuseFilterModifyLogFormatter extends LogFormatter { + + /** + * @return string + */ + protected function getMessageKey() { + $subtype = $this->entry->getSubtype(); + // Messages that can be used here: + // * abusefilter-logentry-create + // * abusefilter-logentry-modify + return "abusefilter-logentry-$subtype"; + } + + /** + * @return array + */ + protected function extractParameters() { + $parameters = $this->entry->getParameters(); + if ( $this->entry->isLegacy() ) { + list( $historyId, $filterId ) = $parameters; + } else { + $historyId = $parameters['historyId']; + $filterId = $parameters['newId']; + } + + $detailsTitle = SpecialPage::getTitleFor( + 'AbuseFilter', + "history/$filterId/diff/prev/$historyId" + ); + + $params = []; + $params[3] = Message::rawParam( + $this->makePageLink( + $this->entry->getTarget(), + [], + $this->msg( 'abusefilter-log-detailedentry-local' ) + ->numParams( $filterId )->escaped() + ) + ); + $params[4] = Message::rawParam( + $this->makePageLink( + $detailsTitle, + [], + $this->msg( 'abusefilter-log-detailslink' )->escaped() + ) + ); + + return $params; + } + +} diff --git a/www/wiki/extensions/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php new file mode 100644 index 00000000..046c4c30 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php @@ -0,0 +1,46 @@ +<?php + +use MediaWiki\Auth\AbstractPreAuthenticationProvider; +use MediaWiki\Auth\AuthenticationRequest; + +class AbuseFilterPreAuthenticationProvider extends AbstractPreAuthenticationProvider { + public function testForAccountCreation( $user, $creator, array $reqs ) { + return $this->testUser( $user, $creator, false ); + } + + public function testUserForCreation( $user, $autocreate, array $options = [] ) { + // if this is not an autocreation, testForAccountCreation already handled it + if ( $autocreate ) { + return $this->testUser( $user, $user, true ); + } + return StatusValue::newGood(); + } + + /** + * @param User $user The user being created or autocreated + * @param User $creator The user who caused $user to be created (or $user itself on autocreation) + * @param bool $autocreate Is this an autocreation? + * @return StatusValue + */ + protected function testUser( $user, $creator, $autocreate ) { + 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() ); + + // pass creator in explicitly to prevent recording the current user on autocreation - T135360 + $status = AbuseFilter::filterAction( $vars, SpecialPage::getTitleFor( 'Userlogin' ), + 'default', $creator ); + + return $status->getStatusValue(); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/AbuseFilterVariableHolder.php b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterVariableHolder.php new file mode 100644 index 00000000..dd6f0be6 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/AbuseFilterVariableHolder.php @@ -0,0 +1,226 @@ +<?php + +class AbuseFilterVariableHolder { + public $mVars = []; + + public static $varBlacklist = [ 'context' ]; + + public function __construct() { + // Backwards-compatibility (unused now) + $this->setVar( 'minor_edit', false ); + } + + /** + * @param string $variable + * @param mixed $datum + */ + function setVar( $variable, $datum ) { + $variable = strtolower( $variable ); + if ( !( $datum instanceof AFPData || $datum instanceof AFComputedVariable ) ) { + $datum = AFPData::newFromPHPVar( $datum ); + } + + $this->mVars[$variable] = $datum; + } + + /** + * @param string $variable + * @param string $method + * @param array $parameters + */ + function setLazyLoadVar( $variable, $method, $parameters ) { + $placeholder = new AFComputedVariable( $method, $parameters ); + $this->setVar( $variable, $placeholder ); + } + + /** + * Get a variable from the current object + * + * @param string $variable + * @return AFPData + */ + function getVar( $variable ) { + $variable = strtolower( $variable ); + if ( isset( $this->mVars[$variable] ) ) { + if ( $this->mVars[$variable] instanceof AFComputedVariable ) { + $value = $this->mVars[$variable]->compute( $this ); + $this->setVar( $variable, $value ); + return $value; + } elseif ( $this->mVars[$variable] instanceof AFPData ) { + return $this->mVars[$variable]; + } + } + return new AFPData(); + } + + /** + * @return AbuseFilterVariableHolder + */ + public static function merge() { + $newHolder = new AbuseFilterVariableHolder; + call_user_func_array( [ $newHolder, "addHolders" ], func_get_args() ); + + return $newHolder; + } + + /** + * @param self $addHolder + * @throws MWException + * @deprecated use addHolders() instead + */ + public function addHolder( $addHolder ) { + $this->addHolders( $addHolder ); + } + + /** + * Merge any number of holders given as arguments into this holder. + * + * @throws MWException + */ + public function addHolders() { + $holders = func_get_args(); + + foreach ( $holders as $addHolder ) { + if ( !is_object( $addHolder ) ) { + throw new MWException( 'Invalid argument to AbuseFilterVariableHolder::addHolders' ); + } + $this->mVars = array_merge( $this->mVars, $addHolder->mVars ); + } + } + + function __wakeup() { + // Reset the context. + $this->setVar( 'context', 'stored' ); + } + + /** + * Export all variables stored in this object as string + * + * @return string[] + */ + function exportAllVars() { + $exported = []; + foreach ( array_keys( $this->mVars ) as $varName ) { + if ( !in_array( $varName, self::$varBlacklist ) ) { + $exported[$varName] = $this->getVar( $varName )->toString(); + } + } + + return $exported; + } + + /** + * Export all non-lazy variables stored in this object as string + * + * @return string[] + */ + function exportNonLazyVars() { + $exported = []; + foreach ( $this->mVars as $varName => $data ) { + if ( + !( $data instanceof AFComputedVariable ) + && !in_array( $varName, self::$varBlacklist ) + ) { + $exported[$varName] = $this->getVar( $varName )->toString(); + } + } + + return $exported; + } + + /** + * Dump all variables stored in this object in their native types. + * If you want a not yet set variable to be included in the results you can + * either set $compute to an array with the name of the variable or set + * $compute to true to compute all not yet set variables. + * + * @param array|bool $compute Variables we should copute if not yet set + * @param bool $includeUserVars Include user set variables + * @return array + */ + public function dumpAllVars( $compute = [], $includeUserVars = false ) { + $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'] ); + + // Title vars can have several prefixes + $prefixes = [ 'ARTICLE', 'MOVED_FROM', 'MOVED_TO' ]; + $titleVars = [ + '_ARTICLEID', + '_NAMESPACE', + '_TEXT', + '_PREFIXEDTEXT', + '_recent_contributors' + ]; + foreach ( $wgRestrictionTypes as $action ) { + $titleVars[] = "_restrictions_$action"; + } + + foreach ( $titleVars as $var ) { + foreach ( $prefixes as $prefix ) { + $coreVariables[] = $prefix . $var; + } + } + $coreVariables = array_map( 'strtolower', $coreVariables ); + } + + foreach ( $allVarNames as $varName ) { + 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 + ) + ) { + $exported[$varName] = $this->getVar( $varName )->toNative(); + } + } + + return $exported; + } + + /** + * @param string $var + * @return bool + */ + function varIsSet( $var ) { + return array_key_exists( $var, $this->mVars ); + } + + /** + * Compute all vars which need DB access. Useful for vars which are going to be saved + * cross-wiki or used for offline analysis. + */ + function computeDBVars() { + static $dbTypes = [ + 'links-from-wikitext-or-database', + 'load-recent-authors', + 'get-page-restrictions', + 'simple-user-accessor', + 'user-age', + 'user-groups', + 'user-rights', + 'revision-text-by-id', + 'revision-text-by-timestamp' + ]; + + foreach ( $this->mVars as $name => $value ) { + if ( $value instanceof AFComputedVariable && + in_array( $value->mMethod, $dbTypes ) + ) { + $value = $value->compute( $this ); + $this->setVar( $name, $value ); + } + } + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/AbuseLogHitFormatter.php b/www/wiki/extensions/AbuseFilter/includes/AbuseLogHitFormatter.php new file mode 100644 index 00000000..b6bc83e4 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/AbuseLogHitFormatter.php @@ -0,0 +1,60 @@ +<?php + +use MediaWiki\MediaWikiServices; + +/** + * This class formats abuse log notifications. + * + * Uses logentry-abusefilter-hit + */ +class AbuseLogHitFormatter extends LogFormatter { + + /** + * @return array + */ + protected function getMessageParameters() { + $entry = $this->entry->getParameters(); + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + $params = parent::getMessageParameters(); + + $filter_title = SpecialPage::getTitleFor( 'AbuseFilter', $entry['filter'] ); + $filter_caption = $this->msg( 'abusefilter-log-detailedentry-local' )->params( $entry['filter'] ); + $log_title = SpecialPage::getTitleFor( 'AbuseLog', $entry['log'] ); + $log_caption = $this->msg( 'abusefilter-log-detailslink' ); + + $params[4] = $entry['action']; + + if ( $this->plaintext ) { + $params[3] = '[[' . $filter_title->getPrefixedText() . '|' . $filter_caption . ']]'; + $params[8] = '[[' . $log_title->getPrefixedText() . '|' . $log_caption . ']]'; + } else { + $params[3] = Message::rawParam( $linkRenderer->makeLink( + $filter_title, + $filter_caption + ) ); + $params[8] = Message::rawParam( $linkRenderer->makeLink( + $log_title, + $log_caption + ) ); + } + + $actions_taken = $entry['actions']; + if ( !strlen( trim( $actions_taken ) ) ) { + $actions_taken = $this->msg( 'abusefilter-log-noactions' ); + } else { + $actions = explode( ',', $actions_taken ); + $displayActions = []; + + foreach ( $actions as $action ) { + $displayActions[] = AbuseFilter::getActionDisplay( $action ); + } + $actions_taken = $this->context->getLanguage()->commaList( $displayActions ); + } + $params[5] = $actions_taken; + + // Bad things happen if the numbers are not in correct order + ksort( $params ); + + return $params; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/TableDiffFormatterFullContext.php b/www/wiki/extensions/AbuseFilter/includes/TableDiffFormatterFullContext.php new file mode 100644 index 00000000..f88b953f --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/TableDiffFormatterFullContext.php @@ -0,0 +1,36 @@ +<?php + +/** + * Like TableDiffFormatter, but will always render the full context + * (even for empty diffs). + * + * @private + */ +class TableDiffFormatterFullContext extends TableDiffFormatter { + /** + * Format a diff. + * + * @param Diff $diff + * @return string The formatted output. + */ + function format( $diff ) { + $xlen = $ylen = 0; + + // Calculate the length of the left and the right side + foreach ( $diff->edits as $edit ) { + if ( $edit->orig ) { + $xlen += count( $edit->orig ); + } + if ( $edit->closing ) { + $ylen += count( $edit->closing ); + } + } + + // Just render the diff with no preprocessing + $this->startDiff(); + $this->block( 1, $xlen, 1, $ylen, $diff->edits ); + $end = $this->endDiff(); + + return $end; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterView.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterView.php new file mode 100644 index 00000000..11b06bbf --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterView.php @@ -0,0 +1,112 @@ +<?php + +use Wikimedia\Rdbms\IDatabase; + +abstract class AbuseFilterView extends ContextSource { + public $mFilter, $mHistoryID, $mSubmit; + + /** + * @var \MediaWiki\Linker\LinkRenderer + */ + protected $linkRenderer; + + /** + * @param SpecialAbuseFilter $page + * @param array $params + */ + function __construct( $page, $params ) { + $this->mPage = $page; + $this->mParams = $params; + $this->setContext( $this->mPage->getContext() ); + $this->linkRenderer = $page->getLinkRenderer(); + } + + /** + * @param string $subpage + * @return Title + */ + function getTitle( $subpage = '' ) { + return $this->mPage->getPageTitle( $subpage ); + } + + abstract 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 IDatabase $db + * @return string + */ + public function buildTestConditions( IDatabase $db ) { + // If one of these is true, we're abusefilter compatible. + return $db->makeList( [ + 'rc_source' => [ + RecentChange::SRC_EDIT, + RecentChange::SRC_NEW, + ], + $db->makeList( [ + 'rc_source' => RecentChange::SRC_LOG, + $db->makeList( [ + $db->makeList( [ + 'rc_log_type' => 'move', + 'rc_log_action' => 'move' + ], LIST_AND ), + $db->makeList( [ + 'rc_log_type' => 'newusers', + 'rc_log_action' => 'create' + ], LIST_AND ), + $db->makeList( [ + 'rc_log_type' => 'delete', + 'rc_log_action' => 'delete' + ], LIST_AND ), + // @todo: add upload + ], LIST_OR ), + ], LIST_AND ), + ], LIST_OR ); + } + + /** + * @static + * @return bool + */ + 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/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewDiff.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewDiff.php new file mode 100644 index 00000000..4a3b1881 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewDiff.php @@ -0,0 +1,387 @@ +<?php + +class AbuseFilterViewDiff extends AbuseFilterView { + public $mOldVersion = null; + public $mNewVersion = null; + public $mNextHistoryId = null; + public $mFilter = null; + + function show() { + $show = $this->loadData(); + $out = $this->getOutput(); + + $links = []; + if ( $this->mFilter ) { + $links['abusefilter-history-backedit'] = $this->getTitle( $this->mFilter ); + $links['abusefilter-diff-backhistory'] = $this->getTitle( 'history/' . $this->mFilter ); + } + + foreach ( $links as $msg => $title ) { + $links[$msg] = $this->linkRenderer->makeLink( $title, $this->msg( $msg )->text() ); + } + + $backlinks = $this->getLanguage()->pipeList( $links ); + $out->addHTML( Xml::tags( 'p', null, $backlinks ) ); + + if ( $show ) { + $out->addHTML( $this->formatDiff() ); + + // Next and previous change links + $links = []; + if ( AbuseFilter::getFirstFilterChange( $this->mFilter ) != + $this->mOldVersion['meta']['history_id'] + ) { + // Create a "previous change" link if this isn't the first change of the given filter + $links[] = $this->linkRenderer->makeLink( + $this->getTitle( + 'history/' . $this->mFilter . '/diff/prev/' . $this->mOldVersion['meta']['history_id'] + ), + $this->getLanguage()->getArrow( 'backwards' ) . + ' ' . $this->msg( 'abusefilter-diff-prev' )->text() + ); + } + + if ( !is_null( $this->mNextHistoryId ) ) { + // Create a "next change" link if this isn't the last change of the given filter + $links[] = $this->linkRenderer->makeLink( + $this->getTitle( + 'history/' . $this->mFilter . '/diff/prev/' . $this->mNextHistoryId + ), + $this->msg( 'abusefilter-diff-next' )->text() . + ' ' . $this->getLanguage()->getArrow( 'forwards' ) + ); + } + + if ( count( $links ) > 0 ) { + $backlinks = $this->getLanguage()->pipeList( $links ); + $out->addHTML( Xml::tags( 'p', null, $backlinks ) ); + } + } + } + + function loadData() { + $oldSpec = $this->mParams[3]; + $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' ); + return false; + } + + $this->mOldVersion = $this->loadSpec( $oldSpec, $newSpec ); + $this->mNewVersion = $this->loadSpec( $newSpec, $oldSpec ); + + if ( is_null( $this->mOldVersion ) || is_null( $this->mNewVersion ) ) { + $this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' ); + return false; + } + + $this->mNextHistoryId = $this->getNextHistoryId( + $this->mNewVersion['meta']['history_id'] + ); + + return true; + } + + /** + * Get the history ID of the next change + * + * @param int $historyId History id to find next change of + * @return int|null Id of the next change or null if there isn't one + */ + function getNextHistoryId( $historyId ) { + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'abuse_filter_history', + 'afh_id', + [ + 'afh_filter' => $this->mFilter, + 'afh_id > ' . $dbr->addQuotes( $historyId ), + ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp ASC' ] + ); + if ( $row ) { + return $row->afh_id; + } + return null; + } + + function loadSpec( $spec, $otherSpec ) { + static $dependentSpecs = [ 'prev', 'next' ]; + static $cache = []; + + if ( isset( $cache[$spec] ) ) { + return $cache[$spec]; + } + + $dbr = wfGetDB( DB_REPLICA ); + $row = null; + if ( is_numeric( $spec ) ) { + $row = $dbr->selectRow( + 'abuse_filter_history', + '*', + [ 'afh_id' => $spec, 'afh_filter' => $this->mFilter ], + __METHOD__ + ); + } elseif ( $spec == 'cur' ) { + $row = $dbr->selectRow( + 'abuse_filter_history', + '*', + [ 'afh_filter' => $this->mFilter ], + __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', + '*', + [ + '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 ) ) { + // cached + $other = $this->loadSpec( $otherSpec, $spec ); + + $row = $dbr->selectRow( + 'abuse_filter_history', + '*', + [ + 'afh_filter' => $this->mFilter, + 'afh_id>' . $dbr->addQuotes( $other['meta']['history_id'] ), + ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp ASC' ] + ); + + if ( $other && !$row ) { + $t = $this->getTitle( + 'history/' . $this->mFilter . '/item/' . $other['meta']['history_id'] ); + $this->getOutput()->redirect( $t->getFullURL() ); + return null; + } + } + + if ( !$row ) { + return null; + } + + $data = $this->loadFromHistoryRow( $row ); + $cache[$spec] = $data; + return $data; + } + + function loadFromHistoryRow( $row ) { + return [ + 'meta' => [ + 'history_id' => $row->afh_id, + 'modified_by' => $row->afh_user, + 'modified_by_text' => $row->afh_user_text, + 'modified' => $row->afh_timestamp, + ], + 'info' => [ + 'description' => $row->afh_public_comments, + 'flags' => $row->afh_flags, + 'notes' => $row->afh_comments, + 'group' => $row->afh_group, + ], + 'pattern' => $row->afh_pattern, + 'actions' => unserialize( $row->afh_actions ), + ]; + } + + /** + * @param string $timestamp + * @param int $history_id + * @return string + */ + function formatVersionLink( $timestamp, $history_id ) { + $filter = $this->mFilter; + $text = $this->getLanguage()->timeanddate( $timestamp, true ); + $title = $this->getTitle( "history/$filter/item/$history_id" ); + + $link = $this->linkRenderer->makeLink( $title, $text ); + + return $link; + } + + /** + * @return string + */ + function formatDiff() { + $oldVersion = $this->mOldVersion; + $newVersion = $this->mNewVersion; + + // headings + $oldLink = $this->formatVersionLink( + $oldVersion['meta']['modified'], + $oldVersion['meta']['history_id'] + ); + $newLink = $this->formatVersionLink( + $newVersion['meta']['modified'], + $newVersion['meta']['history_id'] + ); + + $oldUserLink = Linker::userLink( + $oldVersion['meta']['modified_by'], + $oldVersion['meta']['modified_by_text'] + ); + $newUserLink = Linker::userLink( + $newVersion['meta']['modified_by'], + $newVersion['meta']['modified_by_text'] + ); + + $headings = ''; + $headings .= Xml::tags( 'th', null, + $this->msg( 'abusefilter-diff-item' )->parse() ); + $headings .= Xml::tags( 'th', null, + $this->msg( 'abusefilter-diff-version' ) + ->rawParams( $oldLink, $oldUserLink ) + ->params( $newVersion['meta']['modified_by_text'] ) + ->parse() + ); + $headings .= Xml::tags( 'th', null, + $this->msg( 'abusefilter-diff-version' ) + ->rawParams( $newLink, $newUserLink ) + ->params( $newVersion['meta']['modified_by_text'] ) + ->parse() + ); + + $headings = Xml::tags( 'tr', null, $headings ); + + // Basic info + $info = ''; + $info .= $this->getHeaderRow( 'abusefilter-diff-info' ); + $info .= $this->getDiffRow( + 'abusefilter-edit-description', + $oldVersion['info']['description'], + $newVersion['info']['description'] + ); + global $wgAbuseFilterValidGroups; + if ( + count( $wgAbuseFilterValidGroups ) > 1 || + $oldVersion['info']['group'] != $newVersion['info']['group'] + ) { + $info .= $this->getDiffRow( + 'abusefilter-edit-group', + AbuseFilter::nameGroup( $oldVersion['info']['group'] ), + AbuseFilter::nameGroup( $newVersion['info']['group'] ) + ); + } + $info .= $this->getDiffRow( + 'abusefilter-edit-flags', + AbuseFilter::formatFlags( $oldVersion['info']['flags'] ), + AbuseFilter::formatFlags( $newVersion['info']['flags'] ) + ); + + $info .= $this->getDiffRow( + 'abusefilter-edit-notes', + $oldVersion['info']['notes'], + $newVersion['info']['notes'] + ); + + // Pattern + $info .= $this->getHeaderRow( 'abusefilter-diff-pattern' ); + $info .= $this->getDiffRow( + 'abusefilter-edit-rules', + $oldVersion['pattern'], + $newVersion['pattern'], + 'text' + ); + + // Actions + $oldActions = $this->stringifyActions( $oldVersion['actions'] ); + $newActions = $this->stringifyActions( $newVersion['actions'] ); + + $info .= $this->getHeaderRow( 'abusefilter-edit-consequences' ); + $info .= $this->getDiffRow( + 'abusefilter-edit-consequences', + $oldActions, + $newActions + ); + + $html = "<table class='wikitable'> + <thead>$headings</thead> + <tbody>$info</tbody> + </table>"; + + $html = Xml::tags( 'h2', null, $this->msg( 'abusefilter-diff-title' )->parse() ) . $html; + + return $html; + } + + /** + * @param array $actions + * @return array + */ + function stringifyActions( $actions ) { + $lines = []; + + ksort( $actions ); + foreach ( $actions as $action => $parameters ) { + $lines[] = AbuseFilter::formatAction( $action, $parameters ); + } + + if ( !count( $lines ) ) { + $lines[] = ''; + } + + return $lines; + } + + /** + * @param string $msg + * @return string + */ + function getHeaderRow( $msg ) { + $html = $this->msg( $msg )->parse(); + $html = Xml::tags( 'th', [ 'colspan' => 3 ], $html ); + $html = Xml::tags( 'tr', [ 'class' => 'mw-abusefilter-diff-header' ], $html ); + + return $html; + } + + /** + * @param string $msg + * @param array|string $old + * @param array|string $new + * @return string + */ + function getDiffRow( $msg, $old, $new ) { + if ( !is_array( $old ) ) { + $old = explode( "\n", preg_replace( "/\\\r\\\n?/", "\n", $old ) ); + } + if ( !is_array( $new ) ) { + $new = explode( "\n", preg_replace( "/\\\r\\\n?/", "\n", $new ) ); + } + + $diffEngine = new DifferenceEngine( $this->getContext() ); + + $diffEngine->showDiffStyle(); + + $diff = new Diff( $old, $new ); + $formatter = new TableDiffFormatterFullContext(); + $formattedDiff = $diffEngine->addHeader( $formatter->format( $diff ), '', '' ); + + return Xml::tags( 'tr', null, + Xml::tags( 'th', null, $this->msg( $msg )->parse() ) . + Xml::tags( 'td', [ 'colspan' => 2 ], $formattedDiff ) + ) . "\n"; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewEdit.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewEdit.php new file mode 100644 index 00000000..9f4db06e --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewEdit.php @@ -0,0 +1,1252 @@ +<?php + +class AbuseFilterViewEdit extends AbuseFilterView { + /** + * @param SpecialAbuseFilter $page + * @param array $params + */ + function __construct( $page, $params ) { + parent::__construct( $page, $params ); + $this->mFilter = $page->mFilter; + $this->mHistoryID = $page->mHistoryID; + } + + /** + * Check whether a filter is allowed to use a tag + * + * @param string $tag Tag name + * @return Status + */ + protected function isAllowedTag( $tag ) { + $tagNameStatus = ChangeTags::isTagNameValid( $tag ); + + if ( !$tagNameStatus->isGood() ) { + return $tagNameStatus; + } + + $finalStatus = Status::newGood(); + + $canAddStatus = + ChangeTags::canAddTagsAccompanyingChange( + [ $tag ] + ); + + if ( $canAddStatus->isGood() ) { + return $finalStatus; + } + + $alreadyDefinedTags = []; + AbuseFilterHooks::onListDefinedTags( $alreadyDefinedTags ); + + if ( in_array( $tag, $alreadyDefinedTags, true ) ) { + return $finalStatus; + } + + $canCreateTagStatus = ChangeTags::canCreateTag( $tag ); + if ( $canCreateTagStatus->isGood() ) { + return $finalStatus; + } + + $finalStatus->fatal( 'abusefilter-edit-bad-tags' ); + return $finalStatus; + } + + function show() { + $user = $this->getUser(); + $out = $this->getOutput(); + $request = $this->getRequest(); + $out->setPageTitle( $this->msg( 'abusefilter-edit' ) ); + $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); + + $filter = $this->mFilter; + $history_id = $this->mHistoryID; + if ( $this->mHistoryID ) { + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'abuse_filter_history', + 'afh_id', + [ + 'afh_filter' => $filter, + ], + __METHOD__, + [ 'ORDER BY' => 'afh_timestamp DESC' ] + ); + // change $history_id to null if it's current version id + if ( $row->afh_id === $this->mHistoryID ) { + $history_id = null; + } + } + + // Add default warning messages + $this->exposeWarningMessages(); + + if ( $filter == 'new' && !$this->canEdit() ) { + $out->addWikiMsg( 'abusefilter-edit-notallowed' ); + return; + } + + $editToken = $request->getVal( 'wpEditToken' ); + $tokenMatches = $user->matchEditToken( + $editToken, [ 'abusefilter', $filter ], $request ); + + if ( $tokenMatches && $this->canEdit() ) { + // Check syntax + $syntaxerr = AbuseFilter::checkSyntax( $request->getVal( 'wpFilterRules' ) ); + if ( $syntaxerr !== true ) { + $out->addHTML( + $this->buildFilterEditor( + $this->msg( + 'abusefilter-edit-badsyntax', + [ $syntaxerr[0] ] + )->parseAsBlock(), + $filter, $history_id + ) + ); + return; + } + // Check for missing required fields (title and pattern) + $missing = []; + if ( !$request->getVal( 'wpFilterRules' ) || + trim( $request->getVal( 'wpFilterRules' ) ) === '' ) { + $missing[] = $this->msg( 'abusefilter-edit-field-conditions' )->escaped(); + } + if ( !$request->getVal( 'wpFilterDescription' ) ) { + $missing[] = $this->msg( 'abusefilter-edit-field-description' )->escaped(); + } + if ( count( $missing ) !== 0 ) { + $missing = $this->getLanguage()->commaList( $missing ); + $out->addHTML( + $this->buildFilterEditor( + $this->msg( + 'abusefilter-edit-missingfields', + $missing + )->parseAsBlock(), + $filter, $history_id + ) + ); + return; + } + + $dbw = wfGetDB( DB_MASTER ); + + list( $newRow, $actions ) = $this->loadRequest( $filter ); + + $differences = AbuseFilter::compareVersions( + [ $newRow, $actions ], + [ $newRow->mOriginalRow, $newRow->mOriginalActions ] + ); + + // Don't allow adding a new global rule, or updating a + // rule that is currently global, without permissions. + if ( !$this->canEditFilter( $newRow ) || !$this->canEditFilter( $newRow->mOriginalRow ) ) { + $out->addWikiMsg( 'abusefilter-edit-notallowed-global' ); + return; + } + + // Don't allow custom messages on global rules + if ( $newRow->af_global == 1 && + $request->getVal( 'wpFilterWarnMessage' ) !== 'abusefilter-warning' + ) { + $out->addWikiMsg( 'abusefilter-edit-notallowed-global-custom-msg' ); + return; + } + + $origActions = $newRow->mOriginalActions; + $wasGlobal = (bool)$newRow->mOriginalRow->af_global; + + unset( $newRow->mOriginalRow ); + unset( $newRow->mOriginalActions ); + + // Check for non-changes + if ( !count( $differences ) ) { + $out->redirect( $this->getTitle()->getLocalURL() ); + return; + } + + // Check for restricted actions + global $wgAbuseFilterRestrictions; + if ( count( array_intersect_key( + array_filter( $wgAbuseFilterRestrictions ), + array_merge( + array_filter( $actions ), + array_filter( $origActions ) + ) + ) ) + && !$user->isAllowed( 'abusefilter-modify-restricted' ) + ) { + $out->addHTML( + $this->buildFilterEditor( + $this->msg( 'abusefilter-edit-restricted' )->parseAsBlock(), + $this->mFilter, + $history_id + ) + ); + return; + } + + // If we've activated the 'tag' option, check the arguments for validity. + if ( !empty( $actions['tag'] ) ) { + foreach ( $actions['tag']['parameters'] as $tag ) { + $status = $this->isAllowedTag( $tag ); + + if ( !$status->isGood() ) { + $out->addHTML( + $this->buildFilterEditor( + $status->getMessage()->parseAsBlock(), + $this->mFilter, + $history_id + ) + ); + return; + } + } + } + + $newRow = get_object_vars( $newRow ); // Convert from object to array + + // Set last modifier. + $newRow['af_timestamp'] = $dbw->timestamp( wfTimestampNow() ); + $newRow['af_user'] = $user->getId(); + $newRow['af_user_text'] = $user->getName(); + + $dbw->startAtomic( __METHOD__ ); + + // Insert MAIN row. + if ( $filter == 'new' ) { + $new_id = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' ); + $is_new = true; + } else { + $new_id = $this->mFilter; + $is_new = false; + } + + // Reset throttled marker, if we're re-enabling it. + $newRow['af_throttled'] = $newRow['af_throttled'] && !$newRow['af_enabled']; + $newRow['af_id'] = $new_id; // ID. + + // T67807 + // integer 1's & 0's might be better understood than booleans + $newRow['af_enabled'] = (int)$newRow['af_enabled']; + $newRow['af_hidden'] = (int)$newRow['af_hidden']; + $newRow['af_throttled'] = (int)$newRow['af_throttled']; + $newRow['af_deleted'] = (int)$newRow['af_deleted']; + $newRow['af_global'] = (int)$newRow['af_global']; + + $dbw->replace( 'abuse_filter', [ 'af_id' ], $newRow, __METHOD__ ); + + if ( $is_new ) { + $new_id = $dbw->insertId(); + } + + // Actions + global $wgAbuseFilterActions; + $deadActions = []; + $actionsRows = []; + foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { + // Check if it's set + $enabled = isset( $actions[$action] ) && (bool)$actions[$action]; + + if ( $enabled ) { + $parameters = $actions[$action]['parameters']; + + $thisRow = [ + 'afa_filter' => $new_id, + 'afa_consequence' => $action, + 'afa_parameters' => implode( "\n", $parameters ) + ]; + $actionsRows[] = $thisRow; + } else { + $deadActions[] = $action; + } + } + + // Create a history row + $afh_row = []; + + foreach ( AbuseFilter::$history_mappings as $af_col => $afh_col ) { + $afh_row[$afh_col] = $newRow[$af_col]; + } + + // Actions + $displayActions = []; + foreach ( $actions as $action ) { + $displayActions[$action['action']] = $action['parameters']; + } + $afh_row['afh_actions'] = serialize( $displayActions ); + + $afh_row['afh_changed_fields'] = implode( ',', $differences ); + + // Flags + $flags = []; + if ( $newRow['af_hidden'] ) { + $flags[] = 'hidden'; + } + if ( $newRow['af_enabled'] ) { + $flags[] = 'enabled'; + } + if ( $newRow['af_deleted'] ) { + $flags[] = 'deleted'; + } + if ( $newRow['af_global'] ) { + $flags[] = 'global'; + } + + $afh_row['afh_flags'] = implode( ',', $flags ); + + $afh_row['afh_filter'] = $new_id; + $afh_row['afh_id'] = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' ); + + // Do the update + $dbw->insert( 'abuse_filter_history', $afh_row, __METHOD__ ); + $history_id = $dbw->insertId(); + if ( $filter != 'new' ) { + $dbw->delete( + 'abuse_filter_action', + [ 'afa_filter' => $filter ], + __METHOD__ + ); + } + $dbw->insert( 'abuse_filter_action', $actionsRows, __METHOD__ ); + + $dbw->endAtomic( __METHOD__ ); + + // Invalidate cache if this was a global rule + if ( $wasGlobal || $newRow['af_global'] ) { + $group = 'default'; + if ( isset( $newRow['af_group'] ) && $newRow['af_group'] != '' ) { + $group = $newRow['af_group']; + } + + $globalRulesKey = AbuseFilter::getGlobalRulesKey( $group ); + ObjectCache::getMainWANInstance()->touchCheckKey( $globalRulesKey ); + } + + // Logging + $subtype = $filter === 'new' ? 'create' : 'modify'; + $logEntry = new ManualLogEntry( 'abusefilter', $subtype ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $this->getTitle( $new_id ) ); + $logEntry->setParameters( [ + 'historyId' => $history_id, + 'newId' => $new_id + ] ); + $logid = $logEntry->insert(); + $logEntry->publish( $logid ); + + // Purge the tag list cache so the fetchAllTags hook applies tag changes + if ( isset( $actions['tag'] ) ) { + AbuseFilterHooks::purgeTagCache(); + } + + AbuseFilter::resetFilterProfile( $new_id ); + + $out->redirect( + $this->getTitle()->getLocalURL( + [ + 'result' => 'success', + 'changedfilter' => $new_id, + 'changeid' => $history_id, + ] + ) + ); + } else { + if ( $tokenMatches ) { + // lost rights meanwhile + $out->addWikiMsg( 'abusefilter-edit-notallowed' ); + } + + if ( $history_id ) { + $out->addWikiMsg( + 'abusefilter-edit-oldwarning', $history_id, $filter ); + } + + $out->addHTML( $this->buildFilterEditor( null, $filter, $history_id ) ); + + if ( $history_id ) { + $out->addWikiMsg( + 'abusefilter-edit-oldwarning', $history_id, $filter ); + } + } + } + + /** + * Builds the full form for edit filters. + * Loads data either from the database or from the HTTP request. + * The request takes precedence over the database + * @param string $error An error message to show above the filter box. + * @param int $filter The filter ID + * @param int $history_id The history ID of the filter, if applicable. Otherwise null + * @return bool|string False if there is a failure building the editor, + * otherwise the HTML text for the editor. + */ + function buildFilterEditor( $error, $filter, $history_id = null ) { + if ( $filter === null ) { + return false; + } + + // Build the edit form + $out = $this->getOutput(); + $lang = $this->getLanguage(); + $user = $this->getUser(); + + // Load from request OR database. + list( $row, $actions ) = $this->loadRequest( $filter, $history_id ); + + if ( !$row ) { + $out->addWikiMsg( 'abusefilter-edit-badfilter' ); + $out->addHTML( $this->linkRenderer->makeLink( $this->getTitle(), + $this->msg( 'abusefilter-return' )->text() ) ); + return false; + } + + $out->addSubtitle( $this->msg( + $filter === 'new' ? 'abusefilter-edit-subtitle-new' : 'abusefilter-edit-subtitle', + $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(); + } + + $output = ''; + if ( $error ) { + $out->addHTML( "<span class=\"error\">$error</span>" ); + } + + // Read-only attribute + $readOnlyAttrib = []; + $cbReadOnlyAttrib = []; // For checkboxes + + $styleAttrib = [ 'style' => 'width:95%' ]; + + if ( !$this->canEditFilter( $row ) ) { + $readOnlyAttrib['readonly'] = 'readonly'; + $cbReadOnlyAttrib['disabled'] = 'disabled'; + } + + $fields = []; + + $fields['abusefilter-edit-id'] = + $this->mFilter == 'new' ? + $this->msg( 'abusefilter-edit-new' )->escaped() : + $lang->formatNum( $filter ); + $fields['abusefilter-edit-description'] = + Xml::input( + 'wpFilterDescription', + 45, + isset( $row->af_public_comments ) ? $row->af_public_comments : '', + array_merge( $readOnlyAttrib, $styleAttrib ) + ); + + global $wgAbuseFilterValidGroups; + if ( count( $wgAbuseFilterValidGroups ) > 1 ) { + $groupSelector = new XmlSelect( + 'wpFilterGroup', + 'mw-abusefilter-edit-group-input', + 'default' + ); + + if ( isset( $row->af_group ) && $row->af_group ) { + $groupSelector->setDefault( $row->af_group ); + } + + foreach ( $wgAbuseFilterValidGroups as $group ) { + $groupSelector->addOption( AbuseFilter::nameGroup( $group ), $group ); + } + + if ( !empty( $readOnlyAttrib ) ) { + $groupSelector->setAttribute( 'disabled', 'disabled' ); + } + + $fields['abusefilter-edit-group'] = $groupSelector->getHTML(); + } + + // Hit count display + if ( !empty( $row->af_hit_count ) && $user->isAllowed( 'abusefilter-log-detail' ) ) { + $count_display = $this->msg( 'abusefilter-hitcount' ) + ->numParams( (int)$row->af_hit_count )->text(); + $hitCount = $this->linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'AbuseLog' ), + $count_display, + [], + [ 'wpSearchFilter' => $row->af_id ] + ); + + $fields['abusefilter-edit-hitcount'] = $hitCount; + } + + if ( $filter !== 'new' ) { + // Statistics + global $wgAbuseFilterProfile; + $stash = ObjectCache::getMainStashInstance(); + $matches_count = (int)$stash->get( AbuseFilter::filterMatchesKey( $filter ) ); + $total = (int)$stash->get( AbuseFilter::filterUsedKey( $row->af_group ) ); + + if ( $total > 0 ) { + $matches_percent = sprintf( '%.2f', 100 * $matches_count / $total ); + if ( $wgAbuseFilterProfile ) { + 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(); + } + } + } + + $fields['abusefilter-edit-rules'] = AbuseFilter::buildEditBox( + $row->af_pattern, + 'wpFilterRules', + true, + $this->canEditFilter( $row ) + ); + $fields['abusefilter-edit-notes'] = Xml::textarea( + 'wpFilterNotes', + ( isset( $row->af_comments ) ? $row->af_comments . "\n" : "\n" ), + 40, 15, + $readOnlyAttrib + ); + + // Build checkboxes + $checkboxes = [ 'hidden', 'enabled', 'deleted' ]; + $flags = ''; + + global $wgAbuseFilterIsCentral; + if ( $wgAbuseFilterIsCentral ) { + $checkboxes[] = 'global'; + } + + if ( isset( $row->af_throttled ) && $row->af_throttled ) { + global $wgAbuseFilterRestrictions; + + $filterActions = explode( ',', $row->af_actions ); + $throttledActions = array_intersect_key( + array_flip( $filterActions ), + array_filter( $wgAbuseFilterRestrictions ) + ); + + if ( $throttledActions ) { + $throttledActions = array_map( + function ( $filterAction ) { + return $this->msg( 'abusefilter-action-' . $filterAction )->text(); + }, + array_keys( $throttledActions ) + ); + + $flags .= $out->parse( + Html::warningBox( + $this->msg( 'abusefilter-edit-throttled-warning' ) + ->plaintextParams( $lang->commaList( $throttledActions ) ) + ->escaped() + ) + ); + } + } + + foreach ( $checkboxes as $checkboxId ) { + // Messages that can be used here: + // * abusefilter-edit-enabled + // * abusefilter-edit-deleted + // * abusefilter-edit-hidden + // * abusefilter-edit-global + $message = "abusefilter-edit-$checkboxId"; + $dbField = "af_$checkboxId"; + $postVar = 'wpFilter' . ucfirst( $checkboxId ); + + if ( $checkboxId == 'global' && !$this->canEditGlobal() ) { + $cbReadOnlyAttrib['disabled'] = 'disabled'; + } + + $checkbox = Xml::checkLabel( + $this->msg( $message )->text(), + $postVar, + $postVar, + isset( $row->$dbField ) ? $row->$dbField : false, + $cbReadOnlyAttrib + ); + $checkbox = Xml::tags( 'p', null, $checkbox ); + $flags .= $checkbox; + } + + $fields['abusefilter-edit-flags'] = $flags; + $tools = ''; + + if ( $filter != 'new' ) { + if ( $user->isAllowed( 'abusefilter-revert' ) ) { + $tools .= Xml::tags( + 'p', null, + $this->linkRenderer->makeLink( + $this->getTitle( "revert/$filter" ), + new HtmlArmor( $this->msg( 'abusefilter-edit-revert' )->parse() ) + ) + ); + } + + if ( $this->canEdit() ) { + // Test link + $tools .= Xml::tags( + 'p', null, + $this->linkRenderer->makeLink( + $this->getTitle( "test/$filter" ), + new HtmlArmor( $this->msg( 'abusefilter-edit-test-link' )->parse() ) + ) + ); + } + // Last modification details + $userLink = + Linker::userLink( $row->af_user, $row->af_user_text ) . + Linker::userToolLinks( $row->af_user, $row->af_user_text ); + $userName = $row->af_user_text; + $fields['abusefilter-edit-lastmod'] = + $this->msg( 'abusefilter-edit-lastmod-text' ) + ->rawParams( + $lang->timeanddate( $row->af_timestamp, true ), + $userLink, + $lang->date( $row->af_timestamp, true ), + $lang->time( $row->af_timestamp, true ), + $userName + )->parse(); + $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 .= Xml::element( 'textarea', + [ 'readonly' => 'readonly', 'id' => 'mw-abusefilter-export' ], + $exportText + ); + + $fields['abusefilter-edit-tools'] = $tools; + + $form = Xml::buildForm( $fields ); + $form = Xml::fieldset( $this->msg( 'abusefilter-edit-main' )->text(), $form ); + $form .= Xml::fieldset( + $this->msg( 'abusefilter-edit-consequences' )->text(), + $this->buildConsequenceEditor( $row, $actions ) + ); + + if ( $this->canEditFilter( $row ) ) { + $form .= Xml::submitButton( + $this->msg( 'abusefilter-edit-save' )->text(), + [ 'accesskey' => 's' ] + ); + $form .= Html::hidden( + 'wpEditToken', + $user->getEditToken( [ 'abusefilter', $filter ] ) + ); + } + + $form = Xml::tags( 'form', + [ + 'action' => $this->getTitle( $filter )->getFullURL(), + 'method' => 'post' + ], + $form + ); + + $output .= $form; + + return $output; + } + + /** + * 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 + * corresponding to the abuse filter held in $row. + * @return HTML text for an action editor. + */ + function buildConsequenceEditor( $row, $actions ) { + global $wgAbuseFilterActions; + + $enabledActions = array_filter( $wgAbuseFilterActions ); + + $setActions = []; + foreach ( $enabledActions as $action => $_ ) { + $setActions[$action] = array_key_exists( $action, $actions ); + } + + $output = ''; + + foreach ( $enabledActions as $action => $_ ) { + MediaWiki\suppressWarnings(); + $params = $actions[$action]['parameters']; + MediaWiki\restoreWarnings(); + $output .= $this->buildConsequenceSelector( + $action, $setActions[$action], $params, $row ); + } + + return $output; + } + + /** + * @param string $action The action to build an editor for + * @param bool $set Whether or not the action is activated + * @param array $parameters Action parameters + * @param stdClass $row abuse_filter row object + * @return string + */ + function buildConsequenceSelector( $action, $set, $parameters, $row ) { + global $wgAbuseFilterActions, $wgMainCacheType; + + if ( empty( $wgAbuseFilterActions[$action] ) ) { + return ''; + } + + $readOnlyAttrib = []; + $cbReadOnlyAttrib = []; // For checkboxes + + if ( !$this->canEditFilter( $row ) ) { + $readOnlyAttrib['readonly'] = 'readonly'; + $cbReadOnlyAttrib['disabled'] = 'disabled'; + } + + switch ( $action ) { + case 'throttle': + // Throttling is only available via object caching + if ( $wgMainCacheType === CACHE_NONE ) { + return ''; + } + $throttleSettings = Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-throttle' )->text(), + 'wpFilterActionThrottle', + "mw-abusefilter-action-checkbox-$action", + $set, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib ); + $throttleFields = []; + + if ( $set ) { + array_shift( $parameters ); + $throttleRate = explode( ',', $parameters[0] ); + $throttleCount = $throttleRate[0]; + $throttlePeriod = $throttleRate[1]; + + $throttleGroups = implode( "\n", array_slice( $parameters, 1 ) ); + } else { + $throttleCount = 3; + $throttlePeriod = 60; + + $throttleGroups = "user\n"; + } + + $throttleFields['abusefilter-edit-throttle-count'] = + Xml::input( 'wpFilterThrottleCount', 20, $throttleCount, $readOnlyAttrib ); + $throttleFields['abusefilter-edit-throttle-period'] = + $this->msg( 'abusefilter-edit-throttle-seconds' ) + ->rawParams( Xml::input( 'wpFilterThrottlePeriod', 20, $throttlePeriod, + $readOnlyAttrib ) + )->parse(); + $throttleFields['abusefilter-edit-throttle-groups'] = + Xml::textarea( 'wpFilterThrottleGroups', $throttleGroups . "\n", + 40, 5, $readOnlyAttrib ); + $throttleSettings .= + Xml::tags( + 'div', + [ 'id' => 'mw-abusefilter-throttle-parameters' ], + Xml::buildForm( $throttleFields ) + ); + return $throttleSettings; + case 'warn': + global $wgAbuseFilterDefaultWarningMessage; + $output = ''; + $checkbox = Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-warn' )->text(), + 'wpFilterActionWarn', + "mw-abusefilter-action-checkbox-$action", + $set, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib ); + $output .= Xml::tags( 'p', null, $checkbox ); + if ( $set ) { + $warnMsg = $parameters[0]; + } elseif ( + $row && + isset( $row->af_group ) && $row->af_group && + isset( $wgAbuseFilterDefaultWarningMessage[$row->af_group] ) + ) { + $warnMsg = $wgAbuseFilterDefaultWarningMessage[$row->af_group]; + } else { + $warnMsg = 'abusefilter-warning'; + } + + $warnFields['abusefilter-edit-warn-message'] = + $this->getExistingSelector( $warnMsg, !empty( $readOnlyAttrib ) ); + $warnFields['abusefilter-edit-warn-other-label'] = + Xml::input( + 'wpFilterWarnMessageOther', + 45, + $warnMsg, + [ 'id' => 'mw-abusefilter-warn-message-other' ] + $cbReadOnlyAttrib + ); + + $previewButton = Xml::element( + 'input', + [ + 'type' => 'button', + 'id' => 'mw-abusefilter-warn-preview-button', + 'value' => $this->msg( 'abusefilter-edit-warn-preview' )->text() + ] + ); + $editButton = ''; + if ( $this->getUser()->isAllowed( 'editinterface' ) ) { + $editButton .= ' ' . Xml::element( + 'input', + [ + 'type' => 'button', + 'id' => 'mw-abusefilter-warn-edit-button', + 'value' => $this->msg( 'abusefilter-edit-warn-edit' )->text() + ] + ); + } + $previewHolder = Xml::element( + 'div', + [ 'id' => 'mw-abusefilter-warn-preview' ], '' + ); + $warnFields['abusefilter-edit-warn-actions'] = + Xml::tags( 'p', null, $previewButton . $editButton ) . "\n$previewHolder"; + $output .= + Xml::tags( + 'div', + [ 'id' => 'mw-abusefilter-warn-parameters' ], + Xml::buildForm( $warnFields ) + ); + return $output; + case 'tag': + if ( $set ) { + $tags = $parameters; + } else { + $tags = []; + } + $output = ''; + + $checkbox = Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-tag' )->text(), + 'wpFilterActionTag', + "mw-abusefilter-action-checkbox-$action", + $set, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib + ); + $output .= Xml::tags( 'p', null, $checkbox ); + + $tagFields['abusefilter-edit-tag-tag'] = + Xml::textarea( 'wpFilterTags', implode( "\n", $tags ), 40, 5, $readOnlyAttrib ); + $output .= + Xml::tags( 'div', + [ 'id' => 'mw-abusefilter-tag-parameters' ], + Xml::buildForm( $tagFields ) + ); + return $output; + case 'block': + global $wgBlockAllowsUTEdit, $wgAbuseFilterBlockDuration, + $wgAbuseFilterAnonBlockDuration; + + if ( $set && count( $parameters ) === 3 ) { + // Both blocktalk and custom block durations available + $blockTalk = $parameters[0]; + $defaultAnonDuration = $parameters[1]; + $defaultUserDuration = $parameters[2]; + } else { + if ( $set && count( $parameters ) === 1 ) { + // Only blocktalk available + $blockTalk = $parameters[0]; + } + if ( $wgAbuseFilterAnonBlockDuration ) { + $defaultAnonDuration = $wgAbuseFilterAnonBlockDuration; + } else { + $defaultAnonDuration = $wgAbuseFilterBlockDuration; + } + $defaultUserDuration = $wgAbuseFilterBlockDuration; + } + $suggestedBlocks = SpecialBlock::getSuggestedDurations(); + $suggestedBlocks = self::normalizeBlocks( $suggestedBlocks ); + + $output = ''; + $checkbox = Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-block' )->text(), + 'wpFilterActionBlock', + "mw-abusefilter-action-checkbox-block", + $set, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib ); + $output .= Xml::tags( 'p', null, $checkbox ); + if ( $wgBlockAllowsUTEdit === true ) { + $talkCheckbox = + Xml::checkLabel( + $this->msg( 'abusefilter-edit-action-blocktalk' )->text(), + 'wpFilterBlockTalk', + 'mw-abusefilter-action-checkbox-blocktalk', + isset( $blockTalk ) && $blockTalk == 'blocktalk', + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib + ); + } + + $anonDuration = new XmlSelect( + 'wpBlockAnonDuration', + false, + 'default' + ); + $anonDuration->addOptions( $suggestedBlocks ); + + $userDuration = new XmlSelect( + 'wpBlockUserDuration', + false, + 'default' + ); + $userDuration->addOptions( $suggestedBlocks ); + + // Set defaults + $anonDuration->setDefault( $defaultAnonDuration ); + $userDuration->setDefault( $defaultUserDuration ); + + if ( !$this->canEditFilter( $row ) ) { + $anonDuration->setAttribute( 'disabled', 'disabled' ); + $userDuration->setAttribute( 'disabled', 'disabled' ); + } + + if ( $wgBlockAllowsUTEdit === true ) { + $durations['abusefilter-edit-block-options'] = $talkCheckbox; + } + $durations['abusefilter-edit-block-anon-durations'] = $anonDuration->getHTML(); + $durations['abusefilter-edit-block-user-durations'] = $userDuration->getHTML(); + + $rawOutput = Xml::buildForm( $durations ); + + $output .= Xml::tags( + 'div', + [ 'id' => 'mw-abusefilter-block-parameters' ], + $rawOutput + ); + + return $output; + + default: + // Give grep a chance to find the usages: + // abusefilter-edit-action-warn, abusefilter-edit-action-disallow + // abusefilter-edit-action-blockautopromote + // abusefilter-edit-action-degroup, abusefilter-edit-action-throttle + // abusefilter-edit-action-rangeblock, abusefilter-edit-action-tag + $message = 'abusefilter-edit-action-' . $action; + $form_field = 'wpFilterAction' . ucfirst( $action ); + $status = $set; + + $thisAction = Xml::checkLabel( + $this->msg( $message )->text(), + $form_field, + "mw-abusefilter-action-checkbox-$action", + $status, + [ 'class' => 'mw-abusefilter-action-checkbox' ] + $cbReadOnlyAttrib + ); + $thisAction = Xml::tags( 'p', null, $thisAction ); + return $thisAction; + } + } + + /** + * @param string $warnMsg + * @param bool $readOnly + * @return string + */ + function getExistingSelector( $warnMsg, $readOnly = false ) { + $existingSelector = new XmlSelect( + 'wpFilterWarnMessage', + 'mw-abusefilter-warn-message-existing', + $warnMsg == 'abusefilter-warning' ? 'abusefilter-warning' : 'other' + ); + + $existingSelector->addOption( 'abusefilter-warning' ); + + if ( $readOnly ) { + $existingSelector->setAttribute( 'disabled', 'disabled' ); + } else { + // Find other messages. + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( + 'page', + [ 'page_title' ], + [ + 'page_namespace' => 8, + 'page_title LIKE ' . $dbr->addQuotes( 'Abusefilter-warning%' ) + ], + __METHOD__ + ); + + $lang = $this->getLanguage(); + foreach ( $res as $row ) { + if ( $lang->lcfirst( $row->page_title ) == $lang->lcfirst( $warnMsg ) ) { + $existingSelector->setDefault( $lang->lcfirst( $warnMsg ) ); + } + + if ( $row->page_title != 'Abusefilter-warning' ) { + $existingSelector->addOption( $lang->lcfirst( $row->page_title ) ); + } + } + } + + $existingSelector->addOption( $this->msg( 'abusefilter-edit-warn-other' )->text(), 'other' ); + + return $existingSelector->getHTML(); + } + + /** + * @ToDo: Maybe we should also check if global values belong to $durations + * and determine the right point to add them if missing. + * + * @param array $durations + * @return array + */ + protected static function normalizeBlocks( $durations ) { + global $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration; + // We need to have same values since it may happen that ipblocklist + // and one (or both) of the global variables use different wording + // for the same duration. In such case, when setting the default of + // the dropdowns it would fail. + foreach ( $durations as &$duration ) { + $currentDuration = SpecialBlock::parseExpiryInput( $duration ); + $anonDuration = SpecialBlock::parseExpiryInput( $wgAbuseFilterAnonBlockDuration ); + $userDuration = SpecialBlock::parseExpiryInput( $wgAbuseFilterBlockDuration ); + + if ( $duration !== $wgAbuseFilterBlockDuration && + $currentDuration === $userDuration ) { + $duration = $wgAbuseFilterBlockDuration; + + } elseif ( $duration !== $wgAbuseFilterAnonBlockDuration && + $currentDuration === $anonDuration ) { + $duration = $wgAbuseFilterAnonBlockDuration; + } + } + + return $durations; + } + + /** + * 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, + * or NULL if the filter does not exist. + */ + 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, [] ]; + } + + // Load from master to avoid unintended reversions where there's replication lag. + $dbr = $this->getRequest()->wasPosted() + ? wfGetDB( DB_MASTER ) + : wfGetDB( DB_REPLICA ); + + // Load certain fields only. This prevents a condition seen on Wikimedia where + // a schema change adding a new field caused that extra field to be selected. + // Since the selected row may be inserted back into the database, this will cause + // an SQL error if, say, one server has the updated schema but another does not. + $loadFields = [ + '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', + ]; + + // Load the main row + $row = $dbr->selectRow( 'abuse_filter', $loadFields, [ 'af_id' => $id ], __METHOD__ ); + + if ( !isset( $row ) || !isset( $row->af_id ) || !$row->af_id ) { + return null; + } + + // Load the actions + $actions = []; + $res = $dbr->select( 'abuse_filter_action', + '*', + [ 'afa_filter' => $id ], + __METHOD__ + ); + + foreach ( $res as $actionRow ) { + $thisAction = []; + $thisAction['action'] = $actionRow->afa_consequence; + $thisAction['parameters'] = array_filter( explode( "\n", $actionRow->afa_parameters ) ); + + $actions[$actionRow->afa_consequence] = $thisAction; + } + + 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. + * @param int $history_id If any, the history ID being requested. + * @return Array with filter data if available, otherwise null. + * The first element contains the abuse_filter database row, + * the second element is an array of related abuse_filter_action rows. + */ + function loadRequest( $filter, $history_id = null ) { + static $row = null; + static $actions = null; + $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 ) { + return $this->loadHistoryItem( $history_id ); + } else { + return $this->loadFilterData( $filter ); + } + + // We need some details like last editor + list( $row, $origActions ) = $this->loadFilterData( $filter ); + + $row->mOriginalRow = clone $row; + $row->mOriginalActions = $origActions; + + // Check for importing + $import = $request->getVal( 'wpImportText' ); + if ( $import ) { + $data = FormatJson::decode( $import ); + + $importRow = $data->row; + $actions = wfObjectToArray( $data->actions ); + + $copy = [ + 'af_public_comments', + 'af_pattern', + 'af_comments', + 'af_deleted', + 'af_enabled', + 'af_hidden', + ]; + + foreach ( $copy as $name ) { + $row->$name = $importRow->$name; + } + } else { + $textLoads = [ + 'af_public_comments' => 'wpFilterDescription', + 'af_pattern' => 'wpFilterRules', + 'af_comments' => 'wpFilterNotes', + ]; + + foreach ( $textLoads as $col => $field ) { + $row->$col = trim( $request->getVal( $field ) ); + } + + $row->af_group = $request->getVal( 'wpFilterGroup', 'default' ); + + $row->af_deleted = $request->getBool( 'wpFilterDeleted' ); + $row->af_enabled = $request->getBool( 'wpFilterEnabled' ) && !$row->af_deleted; + $row->af_hidden = $request->getBool( 'wpFilterHidden' ); + global $wgAbuseFilterIsCentral; + $row->af_global = $request->getBool( 'wpFilterGlobal' ) && $wgAbuseFilterIsCentral; + + // Actions + global $wgAbuseFilterActions; + $actions = []; + foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) { + // Check if it's set + $enabled = $request->getBool( 'wpFilterAction' . ucfirst( $action ) ); + + if ( $enabled ) { + $parameters = []; + + if ( $action == 'throttle' ) { + // We need to load the parameters + $throttleCount = $request->getIntOrNull( 'wpFilterThrottleCount' ); + $throttlePeriod = $request->getIntOrNull( 'wpFilterThrottlePeriod' ); + $throttleGroups = explode( "\n", + trim( $request->getText( 'wpFilterThrottleGroups' ) ) ); + + $parameters[0] = $this->mFilter; // For now, anyway + $parameters[1] = "$throttleCount,$throttlePeriod"; + $parameters = array_merge( $parameters, $throttleGroups ); + } elseif ( $action == 'warn' ) { + $specMsg = $request->getVal( 'wpFilterWarnMessage' ); + + if ( $specMsg == 'other' ) { + $specMsg = $request->getVal( 'wpFilterWarnMessageOther' ); + } + + $parameters[0] = $specMsg; + } elseif ( $action == 'block' ) { + $parameters[0] = $request->getCheck( 'wpFilterBlockTalk' ) ? + 'blocktalk' : 'noTalkBlockSet'; + $parameters[1] = $request->getVal( 'wpBlockAnonDuration' ); + $parameters[2] = $request->getVal( 'wpBlockUserDuration' ); + } elseif ( $action == 'tag' ) { + $parameters = explode( "\n", trim( $request->getText( 'wpFilterTags' ) ) ); + } + + $thisAction = [ 'action' => $action, 'parameters' => $parameters ]; + $actions[$action] = $thisAction; + } + } + } + + $row->af_actions = implode( ',', array_keys( array_filter( $actions ) ) ); + + return [ $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: + * First element contains the abuse_filter row (as it was). + * Second element contains an array of abuse_filter_action rows. + */ + function loadHistoryItem( $id ) { + $dbr = wfGetDB( DB_REPLICA ); + + // Load the row. + $row = $dbr->selectRow( 'abuse_filter_history', + '*', + [ 'afh_id' => $id ], + __METHOD__ + ); + + if ( !$row ) { + return false; + } + + return AbuseFilter::translateFromHistory( $row ); + } + + /** + * @return null + */ + protected function exposeWarningMessages() { + global $wgOut, $wgAbuseFilterDefaultWarningMessage; + $wgOut->addJsConfigVars( + 'wgAbuseFilterDefaultWarningMessage', + $wgAbuseFilterDefaultWarningMessage + ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewExamine.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewExamine.php new file mode 100644 index 00000000..73cb2d10 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewExamine.php @@ -0,0 +1,219 @@ +<?php + +class AbuseFilterViewExamine extends AbuseFilterView { + public static $examineType = null; + public static $examineId = null; + + public $mCounter, $mSearchUser, $mSearchPeriodStart, $mSearchPeriodEnd; + public $mTestFilter; + + function show() { + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'abusefilter-examine' ) ); + $out->addWikiMsg( 'abusefilter-examine-intro' ); + + $this->loadParameters(); + + // Check if we've got a subpage + if ( count( $this->mParams ) > 1 && is_numeric( $this->mParams[1] ) ) { + $this->showExaminerForRC( $this->mParams[1] ); + } elseif ( count( $this->mParams ) > 2 + && $this->mParams[1] == 'log' + && is_numeric( $this->mParams[2] ) + ) { + $this->showExaminerForLogEntry( $this->mParams[2] ); + } else { + $this->showSearch(); + } + } + + function showSearch() { + $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); + $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge ); + $max = wfTimestampNow(); + $formDescriptor = [ + 'SearchUser' => [ + 'label-message' => 'abusefilter-test-user', + 'type' => 'user', + 'default' => $this->mSearchUser, + ], + 'SearchPeriodStart' => [ + 'label-message' => 'abusefilter-test-period-start', + 'type' => 'datetime', + 'default' => $this->mSearchPeriodStart, + 'min' => $min, + 'max' => $max, + ], + 'SearchPeriodEnd' => [ + 'label-message' => 'abusefilter-test-period-end', + 'type' => 'datetime', + 'default' => $this->mSearchPeriodEnd, + 'min' => $min, + 'max' => $max, + ], + ]; + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm->setWrapperLegendMsg( 'abusefilter-examine-legend' ) + ->addHiddenField( 'submit', 1 ) + ->setSubmitTextMsg( 'abusefilter-examine-submit' ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + + if ( $this->mSubmit ) { + $this->showResults(); + } + } + + function showResults() { + $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->mTestFilter ); + $output = $changesList->beginRecentChangesList(); + $this->mCounter = 1; + + $pager = new AbuseFilterExaminePager( $this, $changesList ); + + $output .= $pager->getNavigationBar() . + $pager->getBody() . + $pager->getNavigationBar(); + + $output .= $changesList->endRecentChangesList(); + + $this->getOutput()->addHTML( $output ); + } + + 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'] + ); + $out = $this->getOutput(); + if ( !$row ) { + $out->addWikiMsg( 'abusefilter-examine-notfound' ); + return; + } + + if ( !ChangesList::userCan( RecentChange::newFromRow( $row ), Revision::SUPPRESSED_ALL ) ) { + $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' ); + return; + } + + self::$examineType = 'rc'; + self::$examineId = $rcid; + + $vars = AbuseFilter::getVarsFromRCRow( $row ); + $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) ); + $this->showExaminer( $vars ); + } + + function showExaminerForLogEntry( $logid ) { + // Get data + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'abuse_filter_log', + [ 'afl_filter', 'afl_deleted', 'afl_var_dump' ], + [ '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 ) ) { + $out->addWikiMsg( 'abusefilter-log-cannot-see-details' ); + return; + } + + if ( $row->afl_deleted && !SpecialAbuseLog::canSeeHidden() ) { + $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() ) ) { + $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' ); + return; + } + } + $vars = AbuseFilter::loadVarDump( $row->afl_var_dump ); + $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) ); + $this->showExaminer( $vars ); + } + + function showExaminer( $vars ) { + $output = $this->getOutput(); + $output->enableOOUI(); + + if ( !$vars ) { + $output->addWikiMsg( 'abusefilter-examine-incompatible' ); + return; + } + + if ( $vars instanceof AbuseFilterVariableHolder ) { + $vars = $vars->exportAllVars(); + } + + $html = ''; + + $output->addModules( 'ext.abuseFilter.examine' ); + + // Add test bit + if ( $this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $tester = Xml::tags( 'h2', null, $this->msg( 'abusefilter-examine-test' )->parse() ); + $tester .= AbuseFilter::buildEditBox( $this->mTestFilter, 'wpTestFilter', false ); + $tester .= AbuseFilter::buildFilterLoader(); + $html .= Xml::tags( 'div', [ 'id' => 'mw-abusefilter-examine-editor' ], $tester ); + $html .= Xml::tags( 'p', + null, + new OOUI\ButtonInputWidget( + [ + 'label' => $this->msg( 'abusefilter-examine-test-button' )->text(), + 'id' => 'mw-abusefilter-examine-test' + ] + ) . + Xml::element( 'div', + [ + 'id' => 'mw-abusefilter-syntaxresult', + 'style' => 'display: none;' + ], ' ' + ) + ); + } + + // Variable dump + $html .= Xml::tags( + 'h2', + null, + $this->msg( 'abusefilter-examine-vars' )->parse() + ); + $html .= AbuseFilter::buildVarDumpTable( $vars, $this->getContext() ); + + $output->addHTML( $html ); + } + + function loadParameters() { + $request = $this->getRequest(); + $this->mSearchPeriodStart = $request->getText( 'wpSearchPeriodStart' ); + $this->mSearchPeriodEnd = $request->getText( 'wpSearchPeriodEnd' ); + $this->mSubmit = $request->getCheck( 'submit' ); + $this->mTestFilter = $request->getText( 'testfilter' ); + + // Normalise username + $searchUsername = $request->getText( 'wpSearchUser' ); + $userTitle = Title::newFromText( $searchUsername, NS_USER ); + $this->mSearchUser = $userTitle ? $userTitle->getText() : ''; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewHistory.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewHistory.php new file mode 100644 index 00000000..f98241c0 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewHistory.php @@ -0,0 +1,85 @@ +<?php + +class AbuseFilterViewHistory extends AbuseFilterView { + function __construct( $page, $params ) { + parent::__construct( $page, $params ); + $this->mFilter = $page->mFilter; + } + + function show() { + $out = $this->getOutput(); + $filter = $this->getRequest()->getText( 'filter' ) ?: $this->mFilter; + + if ( $filter ) { + $out->setPageTitle( $this->msg( 'abusefilter-history' )->numParams( $filter ) ); + } else { + $out->setPageTitle( $this->msg( 'abusefilter-filter-log' ) ); + } + + # Check perms. abusefilter-modify is a superset of abusefilter-view-private + if ( $filter && AbuseFilter::filterHidden( $filter ) + && !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) + ) { + $out->addWikiMsg( 'abusefilter-history-error-hidden' ); + return; + } + + # Useful links + $links = []; + if ( $filter ) { + $links['abusefilter-history-backedit'] = $this->getTitle( $filter ); + } + + foreach ( $links as $msg => $title ) { + $links[$msg] = $this->linkRenderer->makeLink( + $title, + new HtmlArmor( $this->msg( $msg )->parse() ) + ); + } + + $backlinks = $this->getLanguage()->pipeList( $links ); + $out->addHTML( Xml::tags( 'p', null, $backlinks ) ); + + # For user + $user = User::getCanonicalName( $this->getRequest()->getText( 'user' ), 'valid' ); + if ( $user ) { + $out->addSubtitle( + $this->msg( + 'abusefilter-history-foruser', + Linker::userLink( 1 /* We don't really need to get a user ID */, $user ), + $user // For GENDER + )->text() + ); + } + + $formDescriptor = [ + 'user' => [ + 'type' => 'user', + 'name' => 'user', + 'default' => $user, + 'size' => '45', + 'label-message' => 'abusefilter-history-select-user' + ], + 'filter' => [ + 'type' => 'text', + 'name' => 'filter', + 'default' => $filter, + 'size' => '45', + 'label-message' => 'abusefilter-history-select-filter' + ], + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm->setSubmitTextMsg( 'abusefilter-history-select-submit' ) + ->setWrapperLegendMsg( 'abusefilter-history-select-legend' ) + ->setAction( $this->getTitle( 'history' )->getLocalURL() ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + + $pager = new AbuseFilterHistoryPager( $filter, $this, $user, $this->linkRenderer ); + $table = $pager->getBody(); + + $out->addHTML( $pager->getNavigationBar() . $table . $pager->getNavigationBar() ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewImport.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewImport.php new file mode 100644 index 00000000..6bd4c269 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewImport.php @@ -0,0 +1,24 @@ +<?php + +class AbuseFilterViewImport extends AbuseFilterView { + function show() { + $out = $this->getOutput(); + if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $out->addWikiMsg( 'abusefilter-edit-notallowed' ); + return; + } + $url = SpecialPage::getTitleFor( 'AbuseFilter', 'new' )->getFullURL(); + + $out->addWikiMsg( 'abusefilter-import-intro' ); + + $formDescriptor = [ + 'ImportText' => [ + 'type' => 'textarea', + ] + ]; + $htmlform = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + ->setSubmitTextMsg( 'abusefilter-import-submit' ) + ->setAction( $url ) + ->show(); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewList.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewList.php new file mode 100644 index 00000000..715fa536 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewList.php @@ -0,0 +1,267 @@ +<?php + +/** + * The default view used in Special:AbuseFilter + */ +class AbuseFilterViewList extends AbuseFilterView { + function show() { + global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; + + $out = $this->getOutput(); + $request = $this->getRequest(); + + // Status info... + $this->showStatus(); + + $out->addWikiMsg( 'abusefilter-intro' ); + + // New filter button + if ( $this->canEdit() ) { + $out->enableOOUI(); + $link = new OOUI\ButtonWidget( [ + 'label' => $this->msg( 'abusefilter-new' )->text(), + 'href' => $this->getTitle( 'new' )->getFullURL(), + ] ); + $out->addHTML( $link ); + } + + // Options. + $conds = []; + $deleted = $request->getVal( 'deletedfilters' ); + $hidedisabled = $request->getBool( 'hidedisabled' ); + $defaultscope = 'all'; + if ( isset( $wgAbuseFilterCentralDB ) && !$wgAbuseFilterIsCentral ) { + // Show on remote wikis as default only local filters + $defaultscope = 'local'; + } + $scope = $request->getVal( 'rulescope', $defaultscope ); + + $searchEnabled = $this->canViewPrivate() && !( isset( $wgAbuseFilterCentralDB ) && + !$wgAbuseFilterIsCentral && $scope == 'global' ); + + if ( $searchEnabled ) { + $querypattern = $request->getVal( 'querypattern' ); + $searchmode = $request->getVal( 'searchoption', 'LIKE' ); + } else { + $querypattern = ''; + $searchmode = ''; + } + + if ( $deleted == 'show' ) { + # Nothing + } elseif ( $deleted == 'only' ) { + $conds['af_deleted'] = 1; + } else { # hide, or anything else. + $conds['af_deleted'] = 0; + $deleted = 'hide'; + } + if ( $hidedisabled ) { + $conds['af_deleted'] = 0; + $conds['af_enabled'] = 1; + } + + if ( $scope == 'local' ) { + $conds['af_global'] = 0; + } 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(); + + if ( $validreg === false ) { + $out->wrapWikiMsg( + '<div class="errorbox">$1</div>', + 'abusefilter-list-regexerror' + ); + $this->showList( + [ 'af_deleted' => 0 ], + compact( 'deleted', 'hidedisabled', '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() + ); + } + } + + $this->showList( + $conds, + compact( 'deleted', 'hidedisabled', 'querypattern', 'searchmode', 'scope', 'searchEnabled' ) + ); + } + + function showList( $conds = [ 'af_deleted' => 0 ], $optarray = [] ) { + global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral; + + $this->getOutput()->addHTML( + Xml::element( 'h2', null, $this->msg( 'abusefilter-list' )->parse() ) + ); + + $deleted = $optarray['deleted']; + $hidedisabled = $optarray['hidedisabled']; + $scope = $optarray['scope']; + $searchEnabled = $optarray['searchEnabled']; + $querypattern = $optarray['querypattern']; + $searchmode = $optarray['searchmode']; + + if ( isset( $wgAbuseFilterCentralDB ) && !$wgAbuseFilterIsCentral && $scope == 'global' ) { + $pager = new GlobalAbuseFilterPager( + $this, + $conds, + $this->linkRenderer + ); + } else { + $pager = new AbuseFilterPager( + $this, + $conds, + $this->linkRenderer, + [ $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 ( isset( $wgAbuseFilterCentralDB ) ) { + $optionsMsg = [ + 'abusefilter-list-options-scope-local' => 'local', + 'abusefilter-list-options-scope-global' => 'global', + ]; + if ( $wgAbuseFilterIsCentral ) { + // For central wiki: add third scope option + $optionsMsg['abusefilter-list-options-scope-all'] = 'all'; + } + $formDescriptor['rulescope'] = [ + 'name' => 'rulescope', + 'type' => 'radio', + 'flatlist' => true, + 'label-message' => 'abusefilter-list-options-scope', + 'options-messages' => $optionsMsg, + 'default' => $scope, + ]; + } + + $formDescriptor['info'] = [ + 'type' => 'info', + 'default' => $this->msg( 'abusefilter-list-options-disabled' )->parse(), + ]; + + $formDescriptor['hidedisabled'] = [ + 'name' => 'hidedisabled', + 'type' => 'check', + 'label-message' => 'abusefilter-list-options-hidedisabled', + 'selected' => $hidedisabled, + ]; + + // ToDo: Since this is only for saving space, we should convert it + // to use a 'hide-if' + if ( $searchEnabled ) { + $formDescriptor['querypattern'] = [ + 'name' => 'querypattern', + 'type' => 'text', + 'label-message' => 'abusefilter-list-options-searchfield', + 'placeholder' => $this->msg( 'abusefilter-list-options-searchpattern' )->text(), + 'default' => $querypattern + ]; + + $formDescriptor['searchoption'] = [ + 'name' => 'searchoption', + 'type' => 'radio', + 'flatlist' => true, + 'label-message' => 'abusefilter-list-options-searchoptions', + 'options-messages' => [ + 'abusefilter-list-options-search-like' => 'LIKE', + 'abusefilter-list-options-search-rlike' => 'RLIKE', + 'abusefilter-list-options-search-irlike' => 'IRLIKE', + ], + 'default' => $searchmode + ]; + } + + $formDescriptor['limit'] = [ + 'name' => 'limit', + 'type' => 'select', + 'label-message' => 'abusefilter-list-limit', + 'options' => $pager->getLimitSelectList(), + 'default' => $pager->getLimit(), + ]; + + HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + ->addHiddenField( 'title', $this->getTitle()->getPrefixedDBkey() ) + ->setAction( $this->getTitle()->getFullURL() ) + ->setWrapperLegendMsg( 'abusefilter-list-options' ) + ->setSubmitTextMsg( 'abusefilter-list-options-submit' ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + + $output = + $pager->getNavigationBar() . + $pager->getBody() . + $pager->getNavigationBar(); + + $this->getOutput()->addHTML( $output ); + } + + function showStatus() { + global $wgAbuseFilterConditionLimit, $wgAbuseFilterValidGroups; + + $stash = ObjectCache::getMainStashInstance(); + $overflow_count = (int)$stash->get( AbuseFilter::filterLimitReachedKey() ); + $match_count = (int)$stash->get( AbuseFilter::filterMatchesKey() ); + $total_count = 0; + foreach ( $wgAbuseFilterValidGroups as $group ) { + $total_count += (int)$stash->get( AbuseFilter::filterUsedKey( $group ) ); + } + + if ( $total_count > 0 ) { + $overflow_percent = sprintf( "%.2f", 100 * $overflow_count / $total_count ); + $match_percent = sprintf( "%.2f", 100 * $match_count / $total_count ); + + $status = $this->msg( 'abusefilter-status' ) + ->numParams( + $total_count, + $overflow_count, + $overflow_percent, + $wgAbuseFilterConditionLimit, + $match_count, + $match_percent + )->parse(); + + $status = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-status' ], $status ); + $this->getOutput()->addHTML( $status ); + } + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewRevert.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewRevert.php new file mode 100644 index 00000000..ef3773d7 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewRevert.php @@ -0,0 +1,299 @@ +<?php + +class AbuseFilterViewRevert extends AbuseFilterView { + public $origPeriodStart, $origPeriodEnd, $mPeriodStart, $mPeriodEnd; + public $mReason; + + function show() { + $lang = $this->getLanguage(); + $filter = $this->mPage->mFilter; + + $user = $this->getUser(); + $out = $this->getOutput(); + + if ( !$user->isAllowed( 'abusefilter-revert' ) ) { + throw new PermissionsError( 'abusefilter-revert' ); + } + + $this->loadParameters(); + + if ( $this->attemptRevert() ) { + return; + } + + $out->addWikiMsg( 'abusefilter-revert-intro', Message::numParam( $filter ) ); + $out->setPageTitle( $this->msg( 'abusefilter-revert-title' )->numParams( $filter ) ); + + // First, the search form. Limit dates to avoid huge queries + $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); + $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge ); + $max = wfTimestampNow(); + $filterLink = + $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', intval( $filter ) ), + $lang->formatNum( intval( $filter ) ) + ); + $searchFields = []; + $searchFields['filterid'] = [ + 'type' => 'info', + 'default' => $filterLink, + 'raw' => true, + 'label-message' => 'abusefilter-revert-filter' + ]; + $searchFields['periodstart'] = [ + 'type' => 'datetime', + 'name' => 'wpPeriodStart', + 'default' => $this->origPeriodStart, + 'label-message' => 'abusefilter-revert-periodstart', + 'min' => $min, + 'max' => $max + ]; + $searchFields['periodend'] = [ + 'type' => 'datetime', + 'name' => 'wpPeriodEnd', + 'default' => $this->origPeriodEnd, + 'label-message' => 'abusefilter-revert-periodend', + 'min' => $min, + 'max' => $max + ]; + + HTMLForm::factory( 'ooui', $searchFields, $this->getContext() ) + ->addHiddenField( 'submit', 1 ) + ->setAction( $this->getTitle( "revert/$filter" )->getLocalURL() ) + ->setWrapperLegendMsg( 'abusefilter-revert-search-legend' ) + ->setSubmitTextMsg( 'abusefilter-revert-search' ) + ->setMethod( 'post' ) + ->prepareForm() + ->displayForm( false ); + + if ( $this->mSubmit ) { + // Add a summary of everything that will be reversed. + $out->addWikiMsg( 'abusefilter-revert-preview-intro' ); + + // Look up all of them. + $results = $this->doLookup(); + $list = []; + + foreach ( $results as $result ) { + $displayActions = array_map( + [ 'AbuseFilter', 'getActionDisplay' ], + $result['actions'] ); + + $msg = $this->msg( 'abusefilter-revert-preview-item' ) + ->rawParams( + $lang->timeanddate( $result['timestamp'], true ), + Linker::userLink( $result['userid'], $result['user'] ), + $result['action'], + $this->linkRenderer->makeLink( $result['title'] ), + $lang->commaList( $displayActions ), + $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseLog' ), + $this->msg( 'abusefilter-log-detailslink' )->text(), + [], + [ 'details' => $result['id'] ] + ) + )->params( $result['user'] )->parse(); + $list[] = Xml::tags( 'li', null, $msg ); + } + + $out->addHTML( Xml::tags( 'ul', null, implode( "\n", $list ) ) ); + + // Add a button down the bottom. + $confirmForm = []; + $confirmForm['edittoken'] = [ + 'type' => 'hidden', + 'name' => 'editToken', + 'default' => $user->getEditToken( "abusefilter-revert-$filter" ) + ]; + $confirmForm['title'] = [ + 'type' => 'hidden', + 'name' => 'title', + 'default' => $this->getTitle( "revert/$filter" )->getPrefixedDBkey() + ]; + $confirmForm['wpPeriodStart'] = [ + 'type' => 'hidden', + 'name' => 'wpPeriodStart', + 'default' => $this->origPeriodStart + ]; + $confirmForm['wpPeriodEnd'] = [ + 'type' => 'hidden', + 'name' => 'wpPeriodEnd', + 'default' => $this->origPeriodEnd + ]; + $confirmForm['reason'] = [ + 'type' => 'text', + 'label-message' => 'abusefilter-revert-reasonfield', + 'name' => 'wpReason', + 'id' => 'wpReason', + ]; + HTMLForm::factory( 'ooui', $confirmForm, $this->getContext() ) + ->setAction( $this->getTitle( "revert/$filter" )->getLocalURL() ) + ->setWrapperLegendMsg( 'abusefilter-revert-confirm-legend' ) + ->setSubmitTextMsg( 'abusefilter-revert-confirm' ) + ->setMethod( 'post' ) + ->prepareForm() + ->displayForm( false ); + + } + } + + function doLookup() { + $periodStart = $this->mPeriodStart; + $periodEnd = $this->mPeriodEnd; + $filter = $this->mPage->mFilter; + + $conds = [ 'afl_filter' => $filter ]; + + $dbr = wfGetDB( DB_REPLICA ); + + if ( $periodStart ) { + $conds[] = 'afl_timestamp>' . $dbr->addQuotes( $dbr->timestamp( $periodStart ) ); + } + if ( $periodEnd ) { + $conds[] = 'afl_timestamp<' . $dbr->addQuotes( $dbr->timestamp( $periodEnd ) ); + } + + // Database query. + $res = $dbr->select( 'abuse_filter_log', '*', $conds, __METHOD__ ); + + $results = []; + foreach ( $res as $row ) { + // Don't revert if there was no action, or the action was global + if ( !$row->afl_actions || $row->afl_wiki != null ) { + continue; + } + + $actions = explode( ',', $row->afl_actions ); + $reversibleActions = [ 'block', 'blockautopromote', 'degroup' ]; + $currentReversibleActions = array_intersect( $actions, $reversibleActions ); + if ( count( $currentReversibleActions ) ) { + $results[] = [ + 'id' => $row->afl_id, + 'actions' => $currentReversibleActions, + 'user' => $row->afl_user_text, + 'userid' => $row->afl_user, + 'vars' => AbuseFilter::loadVarDump( $row->afl_var_dump ), + 'title' => Title::makeTitle( $row->afl_namespace, $row->afl_title ), + 'action' => $row->afl_action, + 'timestamp' => $row->afl_timestamp + ]; + } + } + + return $results; + } + + function loadParameters() { + $request = $this->getRequest(); + + $this->origPeriodStart = $request->getText( 'wpPeriodStart' ); + $this->mPeriodStart = strtotime( $this->origPeriodStart ); + $this->origPeriodEnd = $request->getText( 'wpPeriodEnd' ); + $this->mPeriodEnd = strtotime( $this->origPeriodEnd ); + $this->mSubmit = $request->getVal( 'submit' ); + $this->mReason = $request->getVal( 'wpReason' ); + } + + function attemptRevert() { + $filter = $this->mPage->mFilter; + $token = $this->getRequest()->getVal( 'editToken' ); + if ( !$this->getUser()->matchEditToken( $token, "abusefilter-revert-$filter" ) ) { + return false; + } + + $results = $this->doLookup(); + foreach ( $results as $result ) { + $actions = $result['actions']; + foreach ( $actions as $action ) { + $this->revertAction( $action, $result ); + } + } + $this->getOutput()->wrapWikiMsg( + '<p class="success">$1</p>', + [ + 'abusefilter-revert-success', + $filter, + $this->getLanguage()->formatNum( $filter ) + ] + ); + + return true; + } + + /** + * @param string $action + * @param array $result + * @return bool + * @throws MWException + */ + function revertAction( $action, $result ) { + switch ( $action ) { + case 'block': + $block = Block::newFromTarget( $result['user'] ); + if ( !( $block && $block->getBy() == AbuseFilter::getFilterUser()->getId() ) ) { + // Not blocked by abuse filter + return false; + } + $block->delete(); + $logEntry = new ManualLogEntry( 'block', 'unblock' ); + $logEntry->setTarget( Title::makeTitle( NS_USER, $result['user'] ) ); + $logEntry->setComment( + $this->msg( + 'abusefilter-revert-reason', $this->mPage->mFilter, $this->mReason + )->inContentLanguage()->text() + ); + $logEntry->setPerformer( $this->getUser() ); + $logEntry->publish( $logEntry->insert() ); + return true; + case 'blockautopromote': + ObjectCache::getMainStashInstance()->delete( + AbuseFilter::autoPromoteBlockKey( User::newFromId( $result['userid'] ) ) + ); + return true; + case 'degroup': + // Pull the user's groups from the vars. + $oldGroups = $result['vars']['USER_GROUPS']; + $oldGroups = explode( ',', $oldGroups ); + $oldGroups = array_diff( + $oldGroups, + array_intersect( $oldGroups, User::getImplicitGroups() ) + ); + + $rows = []; + foreach ( $oldGroups as $group ) { + $rows[] = [ + 'ug_user' => $result['userid'], + 'ug_group' => $group + ]; + } + + // Cheat a little bit. User::addGroup repeatedly is too slow. + $user = User::newFromId( $result['userid'] ); + $currentGroups = $user->getGroups(); + $newGroups = array_merge( $oldGroups, $currentGroups ); + + // Don't do anything if there are no groups to add. + if ( !count( array_diff( $newGroups, $currentGroups ) ) ) { + return false; + } + + $dbw = wfGetDB( DB_MASTER ); + $dbw->insert( 'user_groups', $rows, __METHOD__, [ 'IGNORE' ] ); + $user->invalidateCache(); + + $log = new LogPage( 'rights' ); + $log->addEntry( 'rights', $user->getUserPage(), + $this->msg( + 'abusefilter-revert-reason', + $this->mPage->mFilter, + $this->mReason + )->inContentLanguage()->text(), + [ implode( ',', $currentGroups ), implode( ',', $newGroups ) ] + ); + + return true; + } + + throw new MWException( 'Invalid action' . $action ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php new file mode 100644 index 00000000..47c4be84 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php @@ -0,0 +1,207 @@ +<?php + +class AbuseFilterViewTestBatch extends AbuseFilterView { + // Hard-coded for now. + protected static $mChangeLimit = 100; + + public $mShowNegative, $mTestPeriodStart, $mTestPeriodEnd, $mTestPage; + public $mTestUser; + + function show() { + $out = $this->getOutput(); + + AbuseFilter::disableConditionLimit(); + + if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $out->addWikiMsg( 'abusefilter-mustbeeditor' ); + return; + } + + $this->loadParameters(); + + $out->setPageTitle( $this->msg( 'abusefilter-test' ) ); + $out->addWikiMsg( 'abusefilter-test-intro', self::$mChangeLimit ); + $out->enableOOUI(); + + $output = ''; + $output .= + AbuseFilter::buildEditBox( + $this->mFilter, + 'wpTestFilter', + true, + true, + true + ) . "\n"; + + $output .= AbuseFilter::buildFilterLoader(); + $output = Xml::tags( 'div', [ 'id' => 'mw-abusefilter-test-editor' ], $output ); + + $RCMaxAge = $this->getConfig()->get( 'RCMaxAge' ); + $min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge ); + $max = wfTimestampNow(); + + // Search form + $formFields = []; + $formFields['wpTestUser'] = [ + 'name' => 'wpTestUser', + 'type' => 'user', + 'ipallowed' => true, + 'label-message' => 'abusefilter-test-user', + 'default' => $this->mTestUser + ]; + $formFields['wpTestPeriodStart'] = [ + 'name' => 'wpTestPeriodStart', + 'type' => 'datetime', + 'label-message' => 'abusefilter-test-period-start', + 'default' => $this->mTestPeriodStart, + 'min' => $min, + 'max' => $max + ]; + $formFields['wpTestPeriodEnd'] = [ + 'name' => 'wpTestPeriodEnd', + 'type' => 'datetime', + 'label-message' => 'abusefilter-test-period-end', + 'default' => $this->mTestPeriodEnd, + 'min' => $min, + 'max' => $max + ]; + $formFields['wpTestPage'] = [ + 'name' => 'wpTestPage', + 'type' => 'title', + 'label-message' => 'abusefilter-test-page', + 'default' => $this->mTestPage, + 'creatable' => true + ]; + $formFields['wpShowNegative'] = [ + 'name' => 'wpShowNegative', + 'type' => 'check', + 'label-message' => 'abusefilter-test-shownegative', + 'selected' => $this->mShowNegative + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formFields, $this->getContext() ) + ->addHiddenField( 'title', $this->getTitle( 'test' )->getPrefixedDBkey() ) + ->setId( 'wpFilterForm' ) + ->setWrapperLegendMsg( 'abusefilter-list-options' ) + ->setAction( $this->getTitle( 'test' )->getLocalURL() ) + ->setSubmitTextMsg( 'abusefilter-test-submit' ) + ->setMethod( 'post' ) + ->prepareForm(); + $htmlForm = $htmlForm->getHTML( $htmlForm ); + + $output = Xml::fieldset( $this->msg( 'abusefilter-test-legend' )->text(), $output . $htmlForm ); + $out->addHTML( $output ); + + if ( $this->getRequest()->wasPosted() ) { + $this->doTest(); + } + } + + /** + * @fixme this is similar to AbuseFilterExaminePager::getQueryInfo + */ + function doTest() { + // Quick syntax check. + $out = $this->getOutput(); + $result = AbuseFilter::checkSyntax( $this->mFilter ); + if ( $result !== true ) { + $out->addWikiMsg( 'abusefilter-test-syntaxerr' ); + return; + } + $dbr = wfGetDB( DB_REPLICA ); + + $conds = []; + + if ( (string)$this->mTestUser !== '' ) { + $conds[] = ActorMigration::newMigration()->getWhere( + $dbr, 'rc_user', User::newFromName( $this->mTestUser, false ) + )['conds']; + } + + if ( $this->mTestPeriodStart ) { + $conds[] = 'rc_timestamp >= ' . + $dbr->addQuotes( $dbr->timestamp( strtotime( $this->mTestPeriodStart ) ) ); + } + if ( $this->mTestPeriodEnd ) { + $conds[] = 'rc_timestamp <= ' . + $dbr->addQuotes( $dbr->timestamp( strtotime( $this->mTestPeriodEnd ) ) ); + } + if ( $this->mTestPage ) { + $title = Title::newFromText( $this->mTestPage ); + if ( $title instanceof Title ) { + $conds['rc_namespace'] = $title->getNamespace(); + $conds['rc_title'] = $title->getDBkey(); + } else { + $out->addWikiMsg( 'abusefilter-test-badtitle' ); + return; + } + } + + $conds[] = $this->buildTestConditions( $dbr ); + + // Get our ChangesList + $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->mFilter ); + $output = $changesList->beginRecentChangesList(); + + $rcQuery = RecentChange::getQueryInfo(); + $res = $dbr->select( + $rcQuery['tables'], + $rcQuery['fields'], + array_filter( $conds ), + __METHOD__, + [ 'LIMIT' => self::$mChangeLimit, 'ORDER BY' => 'rc_timestamp desc' ], + $rcQuery['joins'] + ); + + $counter = 1; + + foreach ( $res as $row ) { + $vars = AbuseFilter::getVarsFromRCRow( $row ); + + if ( !$vars ) { + continue; + } + + $result = AbuseFilter::checkConditions( $this->mFilter, $vars ); + + if ( $result || $this->mShowNegative ) { + // Stash result in RC item + $rc = RecentChange::newFromRow( $row ); + $rc->filterResult = $result; + $rc->counter = $counter++; + $output .= $changesList->recentChangesLine( $rc, false ); + } + } + + $output .= $changesList->endRecentChangesList(); + + $out->addHTML( $output ); + } + + function loadParameters() { + $request = $this->getRequest(); + + $this->mFilter = $request->getText( 'wpTestFilter' ); + $this->mShowNegative = $request->getBool( 'wpShowNegative' ); + $testUsername = $request->getText( 'wpTestUser' ); + $this->mTestPeriodEnd = $request->getText( 'wpTestPeriodEnd' ); + $this->mTestPeriodStart = $request->getText( 'wpTestPeriodStart' ); + $this->mTestPage = $request->getText( 'wpTestPage' ); + + if ( !$this->mFilter + && count( $this->mParams ) > 1 + && is_numeric( $this->mParams[1] ) + ) { + $dbr = wfGetDB( DB_REPLICA ); + $this->mFilter = $dbr->selectField( 'abuse_filter', + 'af_pattern', + [ 'af_id' => $this->mParams[1] ], + __METHOD__ + ); + } + + // Normalise username + $userTitle = Title::newFromText( $testUsername, NS_USER ); + $this->mTestUser = $userTitle ? $userTitle->getText() : null; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTools.php b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTools.php new file mode 100644 index 00000000..cff4b22f --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTools.php @@ -0,0 +1,56 @@ +<?php + +class AbuseFilterViewTools extends AbuseFilterView { + function show() { + $out = $this->getOutput(); + $user = $this->getUser(); + $request = $this->getRequest(); + + if ( !$user->isAllowed( 'abusefilter-modify' ) ) { + $out->addWikiMsg( 'abusefilter-mustbeeditor' ); + return; + } + + // Header + $out->addWikiMsg( 'abusefilter-tools-text' ); + + // Expression evaluator + $eval = ''; + $eval .= AbuseFilter::buildEditBox( $request->getText( 'wpTestExpr' ), 'wpTestExpr' ); + + $eval .= Xml::tags( 'p', null, + Xml::element( 'input', + [ + 'type' => 'button', + 'id' => 'mw-abusefilter-submitexpr', + 'value' => $this->msg( 'abusefilter-tools-submitexpr' )->text() ] + ) + ); + $eval .= Xml::element( 'p', [ '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 + $rac = ''; + $rac .= Xml::inputLabel( + $this->msg( 'abusefilter-tools-reautoconfirm-user' )->text(), + 'wpReAutoconfirmUser', + 'reautoconfirm-user', + 45 + ); + $rac .= ' '; + $rac .= Xml::element( + 'input', + [ + 'type' => 'button', + 'id' => 'mw-abusefilter-reautoconfirmsubmit', + 'value' => $this->msg( 'abusefilter-tools-reautoconfirm-submit' )->text() + ] + ); + $rac = Xml::fieldset( $this->msg( 'abusefilter-tools-reautoconfirm' )->text(), $rac ); + $out->addHTML( $rac ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php b/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php new file mode 100644 index 00000000..252c153d --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php @@ -0,0 +1,94 @@ +<?php + +class ApiAbuseFilterCheckMatch extends ApiBase { + public function execute() { + $params = $this->extractRequestParams(); + $this->requireOnlyOneParameter( $params, 'vars', 'rcid', 'logid' ); + + // "Anti-DoS" + if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $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 ); + } + } elseif ( $params['rcid'] ) { + $dbr = wfGetDB( DB_REPLICA ); + $rcQuery = RecentChange::getQueryInfo(); + $row = $dbr->selectRow( + $rcQuery['tables'], + $rcQuery['fields'], + [ 'rc_id' => $params['rcid'] ], + __METHOD__, + [], + $rcQuery['joins'] + ); + + if ( !$row ) { + $this->dieWithError( [ 'apierror-nosuchrcid', $params['rcid'] ] ); + } + + $vars = AbuseFilter::getVarsFromRCRow( $row ); + } elseif ( $params['logid'] ) { + $dbr = wfGetDB( DB_REPLICA ); + $row = $dbr->selectRow( + 'abuse_filter_log', + 'afl_var_dump', + [ 'afl_id' => $params['logid'] ], + __METHOD__ + ); + + if ( !$row ) { + $this->dieWithError( [ 'apierror-abusefilter-nosuchlogid', $params['logid'] ], 'nosuchlogid' ); + } + + $vars = AbuseFilter::loadVarDump( $row->afl_var_dump ); + } + + if ( AbuseFilter::checkSyntax( $params[ 'filter' ] ) !== true ) { + $this->dieWithError( 'apierror-abusefilter-badsyntax', 'badsyntax' ); + } + + $result = [ + ApiResult::META_BC_BOOLS => [ 'result' ], + 'result' => AbuseFilter::checkConditions( $params['filter'], $vars ), + ]; + + $this->getResult()->addValue( + null, + $this->getModuleName(), + $result + ); + } + + public function getAllowedParams() { + return [ + 'filter' => [ + ApiBase::PARAM_REQUIRED => true, + ], + 'vars' => null, + 'rcid' => [ + ApiBase::PARAM_TYPE => 'integer' + ], + 'logid' => [ + ApiBase::PARAM_TYPE => 'integer' + ], + ]; + } + + /** + * @see ApiBase::getExamplesMessages() + * @return array + */ + protected function getExamplesMessages() { + return [ + 'action=abusefiltercheckmatch&filter=!("autoconfirmed"%20in%20user_groups)&rcid=15' + => 'apihelp-abusefiltercheckmatch-example-1', + ]; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php b/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php new file mode 100644 index 00000000..4854024d --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php @@ -0,0 +1,49 @@ +<?php + +class ApiAbuseFilterCheckSyntax extends ApiBase { + + public function execute() { + // "Anti-DoS" + if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) { + $this->dieWithError( 'apierror-abusefilter-cantcheck', 'permissiondenied' ); + } + + $params = $this->extractRequestParams(); + $result = AbuseFilter::checkSyntax( $params[ 'filter' ] ); + + $r = []; + if ( $result === true ) { + // Everything went better than expected :) + $r['status'] = 'ok'; + } else { + $r = [ + 'status' => 'error', + 'message' => $result[0], + 'character' => $result[1], + ]; + } + + $this->getResult()->addValue( null, $this->getModuleName(), $r ); + } + + public function getAllowedParams() { + return [ + 'filter' => [ + ApiBase::PARAM_REQUIRED => true, + ], + ]; + } + + /** + * @see ApiBase::getExamplesMessages() + * @return array + */ + protected function getExamplesMessages() { + return [ + 'action=abusefilterchecksyntax&filter="foo"' + => 'apihelp-abusefilterchecksyntax-example-1', + 'action=abusefilterchecksyntax&filter="bar"%20bad_variable' + => 'apihelp-abusefilterchecksyntax-example-2', + ]; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php b/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php new file mode 100644 index 00000000..edf4b688 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php @@ -0,0 +1,30 @@ +<?php + +class ApiAbuseFilterEvalExpression extends ApiBase { + public function execute() { + $params = $this->extractRequestParams(); + + $result = AbuseFilter::evaluateExpression( $params['expression'] ); + + $this->getResult()->addValue( null, $this->getModuleName(), [ 'result' => $result ] ); + } + + public function getAllowedParams() { + return [ + 'expression' => [ + ApiBase::PARAM_REQUIRED => true, + ], + ]; + } + + /** + * @see ApiBase::getExamplesMessages() + * @return array + */ + protected function getExamplesMessages() { + return [ + 'action=abusefilterevalexpression&expression=lcase("FOO")' + => 'apihelp-abusefilterevalexpression-example-1', + ]; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php b/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php new file mode 100644 index 00000000..07f134ec --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php @@ -0,0 +1,62 @@ +<?php + +class ApiAbuseFilterUnblockAutopromote extends ApiBase { + public function execute() { + $this->checkUserRightsAny( 'abusefilter-modify' ); + + $params = $this->extractRequestParams(); + $user = User::newFromName( $params['user'] ); + + if ( $user === false ) { + $encParamName = $this->encodeParamName( 'user' ); + $this->dieWithError( + [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $param['user'] ) ], + "baduser_{$encParamName}" + ); + } + + $key = AbuseFilter::autoPromoteBlockKey( $user ); + $stash = ObjectCache::getMainStashInstance(); + if ( !$stash->get( $key ) ) { + $this->dieWithError( [ 'abusefilter-reautoconfirm-none', $user->getName() ], 'notsuspended' ); + } + + $stash->delete( $key ); + + $res = [ 'user' => $params['user'] ]; + $this->getResult()->addValue( null, $this->getModuleName(), $res ); + } + + public function mustBePosted() { + return true; + } + + public function isWriteMode() { + return true; + } + + public function getAllowedParams() { + return [ + 'user' => [ + ApiBase::PARAM_TYPE => 'user', + ApiBase::PARAM_REQUIRED => true + ], + 'token' => null, + ]; + } + + public function needsToken() { + return 'csrf'; + } + + /** + * @see ApiBase::getExamplesMessages() + * @return array + */ + protected function getExamplesMessages() { + return [ + 'action=abusefilterunblockautopromote&user=Example&token=123ABC' + => 'apihelp-abusefilterunblockautopromote-example-1', + ]; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/api/ApiQueryAbuseFilters.php b/www/wiki/extensions/AbuseFilter/includes/api/ApiQueryAbuseFilters.php new file mode 100644 index 00000000..d730483d --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/api/ApiQueryAbuseFilters.php @@ -0,0 +1,218 @@ +<?php +/** + * Created on Mar 29, 2009 + * + * AbuseFilter extension + * + * Copyright © 2008 Alex Z. mrzmanwiki AT gmail DOT com + * Based mostly on code by Bryan Tong Minh and Roan Kattouw + * + * 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 + */ + +/** + * Query module to list abuse filter details. + * + * @ingroup API + * @ingroup Extensions + */ +class ApiQueryAbuseFilters extends ApiQueryBase { + public function __construct( $query, $moduleName ) { + parent::__construct( $query, $moduleName, 'abf' ); + } + + public function execute() { + $user = $this->getUser(); + $this->checkUserRightsAny( 'abusefilter-view' ); + + $params = $this->extractRequestParams(); + + $prop = array_flip( $params['prop'] ); + $fld_id = isset( $prop['id'] ); + $fld_desc = isset( $prop['description'] ); + $fld_pattern = isset( $prop['pattern'] ); + $fld_actions = isset( $prop['actions'] ); + $fld_hits = isset( $prop['hits'] ); + $fld_comments = isset( $prop['comments'] ); + $fld_user = isset( $prop['lasteditor'] ); + $fld_time = isset( $prop['lastedittime'] ); + $fld_status = isset( $prop['status'] ); + $fld_private = isset( $prop['private'] ); + + $result = $this->getResult(); + + $this->addTables( 'abuse_filter' ); + + $this->addFields( 'af_id' ); + $this->addFields( 'af_hidden' ); + $this->addFieldsIf( 'af_hit_count', $fld_hits ); + $this->addFieldsIf( 'af_enabled', $fld_status ); + $this->addFieldsIf( 'af_deleted', $fld_status ); + $this->addFieldsIf( 'af_public_comments', $fld_desc ); + $this->addFieldsIf( 'af_pattern', $fld_pattern ); + $this->addFieldsIf( 'af_actions', $fld_actions ); + $this->addFieldsIf( 'af_comments', $fld_comments ); + $this->addFieldsIf( 'af_user_text', $fld_user ); + $this->addFieldsIf( 'af_timestamp', $fld_time ); + + $this->addOption( 'LIMIT', $params['limit'] + 1 ); + + $this->addWhereRange( 'af_id', $params['dir'], $params['startid'], $params['endid'] ); + + if ( !is_null( $params['show'] ) ) { + $show = array_flip( $params['show'] ); + + /* Check for conflicting parameters. */ + if ( ( isset( $show['enabled'] ) && isset( $show['!enabled'] ) ) + || ( isset( $show['deleted'] ) && isset( $show['!deleted'] ) ) + || ( isset( $show['private'] ) && isset( $show['!private'] ) ) + ) { + $this->dieWithError( 'apierror-show' ); + } + + $this->addWhereIf( 'af_enabled = 0', isset( $show['!enabled'] ) ); + $this->addWhereIf( 'af_enabled != 0', isset( $show['enabled'] ) ); + $this->addWhereIf( 'af_deleted = 0', isset( $show['!deleted'] ) ); + $this->addWhereIf( 'af_deleted != 0', isset( $show['deleted'] ) ); + $this->addWhereIf( 'af_hidden = 0', isset( $show['!private'] ) ); + $this->addWhereIf( 'af_hidden != 0', isset( $show['private'] ) ); + } + + $res = $this->select( __METHOD__ ); + + $showhidden = $user->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ); + + $count = 0; + foreach ( $res as $row ) { + if ( ++$count > $params['limit'] ) { + // We've had enough + $this->setContinueEnumParameter( 'startid', $row->af_id ); + break; + } + $entry = []; + if ( $fld_id ) { + $entry['id'] = intval( $row->af_id ); + } + if ( $fld_desc ) { + $entry['description'] = $row->af_public_comments; + } + if ( $fld_pattern && ( !$row->af_hidden || $showhidden ) ) { + $entry['pattern'] = $row->af_pattern; + } + if ( $fld_actions ) { + $entry['actions'] = $row->af_actions; + } + if ( $fld_hits ) { + $entry['hits'] = intval( $row->af_hit_count ); + } + if ( $fld_comments && ( !$row->af_hidden || $showhidden ) ) { + $entry['comments'] = $row->af_comments; + } + if ( $fld_user ) { + $entry['lasteditor'] = $row->af_user_text; + } + if ( $fld_time ) { + $ts = new MWTimestamp( $row->af_timestamp ); + $entry['lastedittime'] = $ts->getTimestamp( TS_ISO_8601 ); + } + if ( $fld_private && $row->af_hidden ) { + $entry['private'] = ''; + } + if ( $fld_status ) { + if ( $row->af_enabled ) { + $entry['enabled'] = ''; + } + if ( $row->af_deleted ) { + $entry['deleted'] = ''; + } + } + if ( $entry ) { + $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry ); + if ( !$fit ) { + $this->setContinueEnumParameter( 'startid', $row->af_id ); + break; + } + } + } + $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'filter' ); + } + + public function getAllowedParams() { + return [ + 'startid' => [ + ApiBase::PARAM_TYPE => 'integer' + ], + 'endid' => [ + ApiBase::PARAM_TYPE => 'integer', + ], + 'dir' => [ + ApiBase::PARAM_TYPE => [ + 'older', + 'newer' + ], + ApiBase::PARAM_DFLT => 'newer', + ApiBase::PARAM_HELP_MSG => 'api-help-param-direction', + ], + 'show' => [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => [ + 'enabled', + '!enabled', + 'deleted', + '!deleted', + 'private', + '!private', + ], + ], + 'limit' => [ + ApiBase::PARAM_DFLT => 10, + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MIN => 1, + ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1, + ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2 + ], + 'prop' => [ + ApiBase::PARAM_DFLT => 'id|description|actions|status', + ApiBase::PARAM_TYPE => [ + 'id', + 'description', + 'pattern', + 'actions', + 'hits', + 'comments', + 'lasteditor', + 'lastedittime', + 'status', + 'private', + ], + ApiBase::PARAM_ISMULTI => true + ] + ]; + } + + /** + * @see ApiBase::getExamplesMessages() + * @return array + */ + protected function getExamplesMessages() { + return [ + 'action=query&list=abusefilters&abfshow=enabled|!private' + => 'apihelp-query+abusefilters-example-1', + 'action=query&list=abusefilters&abfprop=id|description|pattern' + => 'apihelp-query+abusefilters-example-2', + ]; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/api/ApiQueryAbuseLog.php b/www/wiki/extensions/AbuseFilter/includes/api/ApiQueryAbuseLog.php new file mode 100644 index 00000000..6cd4f17c --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/api/ApiQueryAbuseLog.php @@ -0,0 +1,313 @@ +<?php +/** + * Created on Mar 28, 2009 + * + * AbuseFilter extension + * + * Copyright © 2008 Alex Z. mrzmanwiki AT gmail DOT com + * Based mostly on code by Bryan Tong Minh and Roan Kattouw + * + * 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 + */ + +/** + * Query module to list abuse log entries. + * + * @ingroup API + * @ingroup Extensions + */ +class ApiQueryAbuseLog extends ApiQueryBase { + public function __construct( $query, $moduleName ) { + parent::__construct( $query, $moduleName, 'afl' ); + } + + public function execute() { + global $wgAbuseFilterIsCentral; + + $user = $this->getUser(); + $errors = $this->getTitle()->getUserPermissionsErrors( + 'abusefilter-log', $user, true, [ 'ns-specialprotected' ] ); + if ( count( $errors ) ) { + $this->dieStatus( $this->errorArrayToStatus( $errors ) ); + return; + } + + $params = $this->extractRequestParams(); + + $prop = array_flip( $params['prop'] ); + $fld_ids = isset( $prop['ids'] ); + $fld_filter = isset( $prop['filter'] ); + $fld_user = isset( $prop['user'] ); + $fld_title = isset( $prop['title'] ); + $fld_action = isset( $prop['action'] ); + $fld_details = isset( $prop['details'] ); + $fld_result = isset( $prop['result'] ); + $fld_timestamp = isset( $prop['timestamp'] ); + $fld_hidden = isset( $prop['hidden'] ); + $fld_revid = isset( $prop['revid'] ); + $fld_wiki = $wgAbuseFilterIsCentral && isset( $prop['wiki'] ); + + if ( $fld_details ) { + $this->checkUserRightsAny( 'abusefilter-log-detail' ); + } + // Match permissions for viewing events on private filters to SpecialAbuseLog (bug 42814) + if ( $params['filter'] && + !( AbuseFilterView::canViewPrivate() || $user->isAllowed( '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 ) ) { + $this->dieWithError( + [ 'apierror-permissiondenied', $this->msg( 'action-abusefilter-log-private' ) ] + ); + } + } + } + + $result = $this->getResult(); + + $this->addTables( 'abuse_filter_log' ); + $this->addFields( 'afl_timestamp' ); + $this->addFields( 'afl_rev_id' ); + $this->addFields( 'afl_deleted' ); + $this->addFields( 'afl_filter' ); + $this->addFieldsIf( 'afl_id', $fld_ids ); + $this->addFieldsIf( 'afl_user_text', $fld_user ); + $this->addFieldsIf( [ 'afl_namespace', 'afl_title' ], $fld_title ); + $this->addFieldsIf( 'afl_action', $fld_action ); + $this->addFieldsIf( 'afl_var_dump', $fld_details ); + $this->addFieldsIf( 'afl_actions', $fld_result ); + $this->addFieldsIf( 'afl_wiki', $fld_wiki ); + + if ( $fld_filter ) { + $this->addTables( 'abuse_filter' ); + $this->addFields( 'af_public_comments' ); + $this->addJoinConds( [ 'abuse_filter' => [ 'LEFT JOIN', + 'af_id=afl_filter' ] ] ); + } + + $this->addOption( 'LIMIT', $params['limit'] + 1 ); + + $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'] ) ) { + // It's an IP, sanitize it + $params['user'] = IP::sanitizeIP( $params['user'] ); + $userId = 0; + } + + if ( isset( $userId ) ) { + // Only add the WHERE for user in case it's either a valid user + // (but not necessary an existing one) or an IP. + $this->addWhere( + [ + 'afl_user' => $userId, + 'afl_user_text' => $params['user'] + ] + ); + } + } + + $this->addWhereIf( [ 'afl_filter' => $params['filter'] ], isset( $params['filter'] ) ); + $this->addWhereIf( $notDeletedCond, !SpecialAbuseLog::canSeeHidden( $user ) ); + if ( isset( $params['wiki'] ) ) { + // 'wiki' won't be set if $wgAbuseFilterIsCentral = false + $this->addWhereIf( [ 'afl_wiki' => $params['wiki'] ], $wgAbuseFilterIsCentral ); + } + + $title = $params['title']; + if ( !is_null( $title ) ) { + $titleObj = Title::newFromText( $title ); + if ( is_null( $titleObj ) ) { + $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] ); + } + $this->addWhereFld( 'afl_namespace', $titleObj->getNamespace() ); + $this->addWhereFld( 'afl_title', $titleObj->getDBkey() ); + } + $res = $this->select( __METHOD__ ); + + $count = 0; + foreach ( $res as $row ) { + if ( ++$count > $params['limit'] ) { + // We've had enough + $ts = new MWTimestamp( $row->afl_timestamp ); + $this->setContinueEnumParameter( 'start', $ts->getTimestamp( TS_ISO_8601 ) ); + break; + } + $hidden = SpecialAbuseLog::isHidden( $row ); + if ( $hidden === true && !SpecialAbuseLog::canSeeHidden() ) { + continue; + } elseif ( $hidden === 'implicit' ) { + $rev = Revision::newFromId( $row->afl_rev_id ); + if ( !$rev->userCan( Revision::SUPPRESSED_ALL, $user ) ) { + continue; + } + } + $canSeeDetails = SpecialAbuseLog::canSeeDetails( $row->afl_filter ); + + $entry = []; + if ( $fld_ids ) { + $entry['id'] = intval( $row->afl_id ); + $entry['filter_id'] = ''; + if ( $canSeeDetails ) { + $entry['filter_id'] = $row->afl_filter; + } + } + if ( $fld_filter ) { + $globalIndex = AbuseFilter::decodeGlobalName( $row->afl_filter ); + if ( $globalIndex ) { + $entry['filter'] = AbuseFilter::getGlobalFilterDescription( $globalIndex ); + } else { + $entry['filter'] = $row->af_public_comments; + } + } + if ( $fld_user ) { + $entry['user'] = $row->afl_user_text; + } + if ( $fld_wiki ) { + $entry['wiki'] = $row->afl_wiki; + } + if ( $fld_title ) { + $title = Title::makeTitle( $row->afl_namespace, $row->afl_title ); + ApiQueryBase::addTitleInfo( $entry, $title ); + } + if ( $fld_action ) { + $entry['action'] = $row->afl_action; + } + if ( $fld_result ) { + $entry['result'] = $row->afl_actions; + } + if ( $fld_revid && !is_null( $row->afl_rev_id ) ) { + $entry['revid'] = ''; + if ( $canSeeDetails ) { + $entry['revid'] = $row->afl_rev_id; + } + } + if ( $fld_timestamp ) { + $ts = new MWTimestamp( $row->afl_timestamp ); + $entry['timestamp'] = $ts->getTimestamp( TS_ISO_8601 ); + } + if ( $fld_details ) { + $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 ); + } + } + } + + if ( $fld_hidden && $hidden ) { + $entry['hidden'] = $hidden; + } + + if ( $entry ) { + $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry ); + if ( !$fit ) { + $ts = new MWTimestamp( $row->afl_timestamp ); + $this->setContinueEnumParameter( 'start', $ts->getTimestamp( TS_ISO_8601 ) ); + break; + } + } + } + $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'item' ); + } + + public function getAllowedParams() { + global $wgAbuseFilterIsCentral; + + $params = [ + 'start' => [ + ApiBase::PARAM_TYPE => 'timestamp' + ], + 'end' => [ + ApiBase::PARAM_TYPE => 'timestamp' + ], + 'dir' => [ + ApiBase::PARAM_TYPE => [ + 'newer', + 'older' + ], + ApiBase::PARAM_DFLT => 'older', + ApiBase::PARAM_HELP_MSG => 'api-help-param-direction', + ], + 'user' => null, + 'title' => null, + 'filter' => [ + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_ISMULTI => true + ], + 'limit' => [ + ApiBase::PARAM_DFLT => 10, + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MIN => 1, + ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1, + ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2 + ], + 'prop' => [ + ApiBase::PARAM_DFLT => 'ids|user|title|action|result|timestamp|hidden|revid', + ApiBase::PARAM_TYPE => [ + 'ids', + 'filter', + 'user', + 'title', + 'action', + 'details', + 'result', + 'timestamp', + 'hidden', + 'revid', + ], + ApiBase::PARAM_ISMULTI => true + ] + ]; + if ( $wgAbuseFilterIsCentral ) { + $params['wiki'] = [ + ApiBase::PARAM_TYPE => 'string', + ]; + $params['prop'][ApiBase::PARAM_DFLT] .= '|wiki'; + $params['prop'][ApiBase::PARAM_TYPE][] = 'wiki'; + } + return $params; + } + + /** + * @see ApiBase::getExamplesMessages() + * @return array + */ + protected function getExamplesMessages() { + return [ + 'action=query&list=abuselog' + => 'apihelp-query+abuselog-example-1', + 'action=query&list=abuselog&afltitle=API' + => 'apihelp-query+abuselog-example-2', + ]; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php b/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php new file mode 100644 index 00000000..495bd4f8 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php @@ -0,0 +1,72 @@ +<?php + +class AbuseFilterExaminePager extends ReverseChronologicalPager { + /** + * @param AbuseFilterViewExamine $page + * @param AbuseFilterChangesList $changesList + */ + function __construct( $page, $changesList ) { + parent::__construct(); + $this->mChangesList = $changesList; + $this->mPage = $page; + } + + /** + * @fixme this is similar to AbuseFilterViewTestBatch::doTest + * @return array + */ + function getQueryInfo() { + $dbr = wfGetDB( DB_REPLICA ); + $conds = []; + + if ( (string)$this->mPage->mSearchUser !== '' ) { + $conds[] = ActorMigration::newMigration()->getWhere( + $dbr, 'rc_user', User::newFromName( $this->mPage->mSearchUser, false ) + )['conds']; + } + + $startTS = strtotime( $this->mPage->mSearchPeriodStart ); + if ( $startTS ) { + $conds[] = 'rc_timestamp>=' . $dbr->addQuotes( $dbr->timestamp( $startTS ) ); + } + $endTS = strtotime( $this->mPage->mSearchPeriodEnd ); + if ( $endTS ) { + $conds[] = 'rc_timestamp<=' . $dbr->addQuotes( $dbr->timestamp( $endTS ) ); + } + + $conds[] = $this->mPage->buildTestConditions( $dbr ); + + $rcQuery = RecentChange::getQueryInfo(); + $info = [ + 'tables' => $rcQuery['tables'], + 'fields' => $rcQuery['fields'], + 'conds' => array_filter( $conds ), + 'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ], + 'join_conds' => $rcQuery['joins'], + ]; + + return $info; + } + + /** + * @param stdClass $row + * @return string + */ + public function formatRow( $row ) { + $rc = RecentChange::newFromRow( $row ); + $rc->counter = $this->mPage->mCounter++; + return $this->mChangesList->recentChangesLine( $rc, false ); + } + + function getIndexField() { + return 'rc_id'; + } + + function getTitle() { + return $this->mPage->getTitle( 'examine' ); + } + + function getEmptyBody() { + return $this->msg( 'abusefilter-examine-noresults' )->parseAsBlock(); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php b/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php new file mode 100644 index 00000000..375b940e --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php @@ -0,0 +1,204 @@ +<?php + +class AbuseFilterHistoryPager extends TablePager { + + protected $linkRenderer; + /** + * @param string $filter + * @param ContextSource $page + * @param string $user User name + * @param \MediaWiki\Linker\LinkRenderer $linkRenderer + */ + function __construct( $filter, $page, $user, $linkRenderer ) { + $this->mFilter = $filter; + $this->mPage = $page; + $this->mUser = $user; + $this->mDefaultDirection = true; + $this->linkRenderer = $linkRenderer; + parent::__construct( $this->mPage->getContext() ); + } + + function getFieldNames() { + static $headers = null; + + if ( !empty( $headers ) ) { + return $headers; + } + + $headers = [ + 'afh_timestamp' => 'abusefilter-history-timestamp', + 'afh_user_text' => 'abusefilter-history-user', + 'afh_public_comments' => 'abusefilter-history-public', + 'afh_flags' => 'abusefilter-history-flags', + 'afh_actions' => 'abusefilter-history-actions', + 'afh_id' => 'abusefilter-history-diff', + ]; + + if ( !$this->mFilter ) { + // awful hack + $headers = [ 'afh_filter' => 'abusefilter-history-filterid' ] + $headers; + unset( $headers['afh_comments'] ); + } + + foreach ( $headers as &$msg ) { + $msg = $this->msg( $msg )->text(); + } + + return $headers; + } + + function formatValue( $name, $value ) { + $lang = $this->getLanguage(); + + $row = $this->mCurrentRow; + + switch ( $name ) { + case 'afh_filter': + $formatted = $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->afh_filter ) ), + $lang->formatNum( $row->afh_filter ) + ); + break; + case 'afh_timestamp': + $title = SpecialPage::getTitleFor( 'AbuseFilter', + 'history/' . $row->afh_filter . '/item/' . $row->afh_id ); + $formatted = $this->linkRenderer->makeLink( + $title, + $lang->timeanddate( $row->afh_timestamp, true ) + ); + break; + case 'afh_user_text': + $formatted = + Linker::userLink( $row->afh_user, $row->afh_user_text ) . ' ' . + Linker::userToolLinks( $row->afh_user, $row->afh_user_text ); + break; + case 'afh_public_comments': + $formatted = htmlspecialchars( $value, ENT_QUOTES, 'UTF-8', false ); + break; + case 'afh_flags': + $formatted = AbuseFilter::formatFlags( $value ); + break; + case 'afh_actions': + $actions = unserialize( $value ); + + $display_actions = ''; + + foreach ( $actions as $action => $parameters ) { + $displayAction = AbuseFilter::formatAction( $action, $parameters ); + $display_actions .= Xml::tags( 'li', null, $displayAction ); + } + $display_actions = Xml::tags( 'ul', null, $display_actions ); + + $formatted = $display_actions; + break; + case 'afh_id': + $formatted = ''; + if ( AbuseFilter::getFirstFilterChange( $row->afh_filter ) != $value ) { + // Set a link to a diff with the previous version if this isn't the first edit to the filter + $title = $this->mPage->getTitle( + 'history/' . $row->afh_filter . "/diff/prev/$value" ); + $formatted = $this->linkRenderer->makeLink( + $title, + new HtmlArmor( $this->msg( 'abusefilter-history-diff' )->parse() ) + ); + } + break; + default: + $formatted = "Unable to format $name"; + break; + } + + $mappings = array_flip( AbuseFilter::$history_mappings ) + + [ 'afh_actions' => 'actions', 'afh_id' => 'id' ]; + $changed = explode( ',', $row->afh_changed_fields ); + + $fieldChanged = false; + if ( $name == 'afh_flags' ) { + // This is a bit freaky, but it works. + // Basically, returns true if any of those filters are in the $changed array. + $filters = [ 'af_enabled', 'af_hidden', 'af_deleted', 'af_global' ]; + if ( count( array_diff( $filters, $changed ) ) < count( $filters ) ) { + $fieldChanged = true; + } + } elseif ( in_array( $mappings[$name], $changed ) ) { + $fieldChanged = true; + } + + if ( $fieldChanged ) { + $formatted = Xml::tags( 'div', + [ 'class' => 'mw-abusefilter-history-changed' ], + $formatted + ); + } + + return $formatted; + } + + function getQueryInfo() { + $info = [ + 'tables' => [ 'abuse_filter_history', 'abuse_filter' ], + 'fields' => [ + 'afh_filter', + 'afh_timestamp', + 'afh_user_text', + 'afh_public_comments', + 'afh_flags', + 'afh_comments', + 'afh_actions', + 'afh_id', + 'afh_user', + 'afh_changed_fields', + 'afh_pattern', + 'afh_id', + 'af_hidden' + ], + 'conds' => [], + 'join_conds' => [ + 'abuse_filter' => + [ + 'LEFT JOIN', + 'afh_filter=af_id', + ], + ], + ]; + + if ( $this->mUser ) { + $info['conds']['afh_user_text'] = $this->mUser; + } + + if ( $this->mFilter ) { + $info['conds']['afh_filter'] = $this->mFilter; + } + + if ( !$this->getUser()->isAllowedAny( + 'abusefilter-modify', 'abusefilter-view-private' ) + ) { + // Hide data the user can't see. + $info['conds']['af_hidden'] = 0; + } + + return $info; + } + + function getIndexField() { + return 'afh_timestamp'; + } + + function getDefaultSort() { + return 'afh_timestamp'; + } + + function isFieldSortable( $name ) { + $sortable_fields = [ 'afh_timestamp', 'afh_user_text' ]; + return in_array( $name, $sortable_fields ); + } + + /** + * Title used for self-links. + * + * @return Title + */ + function getTitle() { + return $this->mPage->getTitle( 'history/' . $this->mFilter ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterPager.php b/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterPager.php new file mode 100644 index 00000000..f4e62ad7 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterPager.php @@ -0,0 +1,260 @@ +<?php + +/** + * Class to build paginated filter list + */ +class AbuseFilterPager extends TablePager { + + /** + * @var \MediaWiki\Linker\LinkRenderer + */ + protected $linkRenderer; + + function __construct( $page, $conds, $linkRenderer, $query ) { + $this->mPage = $page; + $this->mConds = $conds; + $this->linkRenderer = $linkRenderer; + $this->mQuery = $query; + parent::__construct( $this->mPage->getContext() ); + } + + function getQueryInfo() { + return [ + 'tables' => [ 'abuse_filter' ], + 'fields' => [ + 'af_id', + 'af_enabled', + 'af_deleted', + 'af_pattern', + 'af_global', + 'af_public_comments', + 'af_hidden', + 'af_hit_count', + 'af_timestamp', + 'af_user_text', + 'af_user', + 'af_actions', + 'af_group', + ], + 'conds' => $this->mConds, + ]; + } + + function getFieldNames() { + static $headers = null; + + if ( !empty( $headers ) ) { + return $headers; + } + + $headers = [ + 'af_id' => 'abusefilter-list-id', + 'af_public_comments' => 'abusefilter-list-public', + 'af_actions' => 'abusefilter-list-consequences', + 'af_enabled' => 'abusefilter-list-status', + 'af_timestamp' => 'abusefilter-list-lastmodified', + 'af_hidden' => 'abusefilter-list-visibility', + ]; + + if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) { + $headers['af_hit_count'] = 'abusefilter-list-hitcount'; + } + + if ( AbuseFilterView::canViewPrivate() && !empty( $this->mQuery[0] ) ) { + $headers['af_pattern'] = 'abusefilter-list-pattern'; + } + + global $wgAbuseFilterValidGroups; + if ( count( $wgAbuseFilterValidGroups ) > 1 ) { + $headers['af_group'] = 'abusefilter-list-group'; + } + + foreach ( $headers as &$msg ) { + $msg = $this->msg( $msg )->text(); + } + + return $headers; + } + + function formatValue( $name, $value ) { + $lang = $this->getLanguage(); + $row = $this->mCurrentRow; + + switch ( $name ) { + case 'af_id': + return $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', intval( $value ) ), + $lang->formatNum( intval( $value ) ) + ); + case 'af_pattern': + if ( $this->mQuery[1] === 'LIKE' ) { + $position = mb_strpos( + strtolower( $row->af_pattern ), + strtolower( $this->mQuery[0] ), + 0, + 'UTF8' + ); + 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, 'UTF8' ) ); + } + $length = mb_strlen( $this->mQuery[0], 'UTF8' ); + } elseif ( $this->mQuery[1] === 'RLIKE' ) { + Wikimedia\suppressWarnings(); + $check = preg_match( + '/' . $this->mQuery[0] . '/', + $row->af_pattern, + $matches, + PREG_OFFSET_CAPTURE + ); + Wikimedia\restoreWarnings(); + // This may happen in case of catastrophic backtracking + if ( $check === false ) { + return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50, 'UTF8' ) ); + } + $length = mb_strlen( $matches[0][0], 'UTF8' ); + $position = $matches[0][1]; + } elseif ( $this->mQuery[1] === 'IRLIKE' ) { + Wikimedia\suppressWarnings(); + $check = preg_match( + '/' . $this->mQuery[0] . '/i', + $row->af_pattern, + $matches, + PREG_OFFSET_CAPTURE + ); + Wikimedia\restoreWarnings(); + // This may happen in case of catastrophic backtracking + if ( $check === false ) { + return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50, 'UTF8' ) ); + } + $length = mb_strlen( $matches[0][0], 'UTF8' ); + $position = $matches[0][1]; + } + $remaining = 50 - $length; + if ( $remaining <= 0 ) { + $pattern = '<b>' . + htmlspecialchars( mb_substr( $row->af_pattern, 0, 50, 'UTF8' ) ) . + '</b>'; + } else { + $minoffset = max( $position - round( $remaining / 2 ), 0 ); + $pattern = mb_substr( $row->af_pattern, $minoffset, 50, 'UTF8' ); + $pattern = + htmlspecialchars( mb_substr( $pattern, 0, $position - $minoffset, 'UTF8' ) ) . + '<b>' . + htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length, 'UTF8' ) ) . + '</b>' . + htmlspecialchars( mb_substr( + $pattern, + $position - $minoffset + $length, + $remaining - ( $position - $minoffset + $length ), + 'UTF8' + ) + ); + } + return $pattern; + case 'af_public_comments': + return $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->af_id ) ), + $value + ); + case 'af_actions': + $actions = explode( ',', $value ); + $displayActions = []; + foreach ( $actions as $action ) { + $displayActions[] = AbuseFilter::getActionDisplay( $action ); + } + return htmlspecialchars( $lang->commaList( $displayActions ) ); + case 'af_enabled': + $statuses = []; + if ( $row->af_deleted ) { + $statuses[] = $this->msg( 'abusefilter-deleted' )->parse(); + } elseif ( $row->af_enabled ) { + $statuses[] = $this->msg( 'abusefilter-enabled' )->parse(); + } else { + $statuses[] = $this->msg( 'abusefilter-disabled' )->parse(); + } + + global $wgAbuseFilterIsCentral; + if ( $row->af_global && $wgAbuseFilterIsCentral ) { + $statuses[] = $this->msg( 'abusefilter-status-global' )->parse(); + } + + return $lang->commaList( $statuses ); + case 'af_hidden': + $msg = $value ? 'abusefilter-hidden' : 'abusefilter-unhidden'; + return $this->msg( $msg )->parse(); + case 'af_hit_count': + if ( SpecialAbuseLog::canSeeDetails( $row->af_id, $row->af_hidden ) ) { + $count_display = $this->msg( 'abusefilter-hitcount' ) + ->numParams( $value )->parse(); + $link = $this->linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'AbuseLog' ), + $count_display, + [], + [ 'wpSearchFilter' => $row->af_id ] + ); + } else { + $link = ""; + } + return $link; + case 'af_timestamp': + $userLink = + Linker::userLink( + $row->af_user, + $row->af_user_text + ) . + Linker::userToolLinks( + $row->af_user, + $row->af_user_text + ); + $user = $row->af_user_text; + return $this->msg( 'abusefilter-edit-lastmod-text' ) + ->rawParams( $lang->timeanddate( $value, true ), + $userLink, + $lang->date( $value, true ), + $lang->time( $value, true ), + $user + )->parse(); + case 'af_group': + return AbuseFilter::nameGroup( $value ); + break; + default: + throw new MWException( "Unknown row type $name!" ); + } + } + + function getDefaultSort() { + return 'af_id'; + } + + function getTableClass() { + return 'TablePager mw-abusefilter-list-scrollable'; + } + + function getRowClass( $row ) { + if ( $row->af_enabled ) { + return 'mw-abusefilter-list-enabled'; + } elseif ( $row->af_deleted ) { + return 'mw-abusefilter-list-deleted'; + } else { + return 'mw-abusefilter-list-disabled'; + } + } + + function isFieldSortable( $name ) { + $sortable_fields = [ + 'af_id', + 'af_enabled', + 'af_throttled', + 'af_user_text', + 'af_timestamp', + 'af_hidden', + 'af_group', + ]; + if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) { + $sortable_fields[] = 'af_hit_count'; + } + return in_array( $name, $sortable_fields ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseLogPager.php b/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseLogPager.php new file mode 100644 index 00000000..8b4513fe --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/pagers/AbuseLogPager.php @@ -0,0 +1,79 @@ +<?php + +use Wikimedia\Rdbms\ResultWrapper; + +class AbuseLogPager extends ReverseChronologicalPager { + /** + * @var SpecialAbuseLog + */ + public $mForm; + + /** + * @var array + */ + public $mConds; + + /** + * @param SpecialAbuseLog $form + * @param array $conds + */ + function __construct( $form, $conds = [] ) { + $this->mForm = $form; + $this->mConds = $conds; + parent::__construct(); + } + + function formatRow( $row ) { + return $this->mForm->formatRow( $row ); + } + + function getQueryInfo() { + $conds = $this->mConds; + + $info = [ + 'tables' => [ 'abuse_filter_log', 'abuse_filter' ], + 'fields' => '*', + 'conds' => $conds, + 'join_conds' => + [ 'abuse_filter' => + [ + 'LEFT JOIN', + 'af_id=afl_filter', + ], + ], + ]; + + if ( !$this->mForm->canSeeHidden() ) { + $db = $this->mDb; + $info['conds'][] = SpecialAbuseLog::getNotDeletedCond( $db ); + } + + return $info; + } + + /** + * @param ResultWrapper $result + */ + protected function preprocessResults( $result ) { + if ( $this->getNumRows() === 0 ) { + return; + } + + $lb = new LinkBatch(); + $lb->setCaller( __METHOD__ ); + foreach ( $result as $row ) { + // Only for local wiki results + if ( !$row->afl_wiki ) { + $lb->add( $row->afl_namespace, $row->afl_title ); + $lb->add( NS_USER, $row->afl_user ); + $lb->add( NS_USER_TALK, $row->afl_user_text ); + } + } + $lb->execute(); + $result->seek( 0 ); + } + + function getIndexField() { + return 'afl_timestamp'; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php b/www/wiki/extensions/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php new file mode 100644 index 00000000..36c84a01 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php @@ -0,0 +1,70 @@ +<?php + +/** + * Class to build paginated filter list for wikis using global abuse filters + */ +class GlobalAbuseFilterPager extends AbuseFilterPager { + function __construct( $page, $conds, $linkRenderer ) { + parent::__construct( $page, $conds, $linkRenderer, [ '', 'LIKE' ] ); + global $wgAbuseFilterCentralDB; + $this->mDb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB ); + } + + function formatValue( $name, $value ) { + $lang = $this->getLanguage(); + $row = $this->mCurrentRow; + + switch ( $name ) { + case 'af_id': + return $lang->formatNum( intval( $value ) ); + case 'af_public_comments': + return $this->getOutput()->parseInline( $value ); + case 'af_actions': + $actions = explode( ',', $value ); + $displayActions = []; + foreach ( $actions as $action ) { + $displayActions[] = AbuseFilter::getActionDisplay( $action ); + } + return htmlspecialchars( $lang->commaList( $displayActions ) ); + case 'af_enabled': + $statuses = []; + if ( $row->af_deleted ) { + $statuses[] = $this->msg( 'abusefilter-deleted' )->parse(); + } elseif ( $row->af_enabled ) { + $statuses[] = $this->msg( 'abusefilter-enabled' )->parse(); + } else { + $statuses[] = $this->msg( 'abusefilter-disabled' )->parse(); + } + if ( $row->af_global ) { + $statuses[] = $this->msg( 'abusefilter-status-global' )->parse(); + } + + return $lang->commaList( $statuses ); + case 'af_hidden': + $msg = $value ? 'abusefilter-hidden' : 'abusefilter-unhidden'; + return $this->msg( $msg )->parse(); + case 'af_hit_count': + // If the rule is hidden, don't show it, even to priviledged local admins + if ( $row->af_hidden ) { + return ''; + } + return $this->msg( 'abusefilter-hitcount' )->numParams( $value )->parse(); + case 'af_timestamp': + $user = $row->af_user_text; + return $this->msg( + 'abusefilter-edit-lastmod-text', + $lang->timeanddate( $value, true ), + $user, + $lang->date( $value, true ), + $lang->time( $value, true ), + $user + )->parse(); + case 'af_group': + // If this is global, local name probably doesn't exist, but try + return AbuseFilter::nameGroup( $value ); + break; + default: + throw new MWException( "Unknown row type $name!" ); + } + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AFPData.php b/www/wiki/extensions/AbuseFilter/includes/parser/AFPData.php new file mode 100644 index 00000000..ff1faa98 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AFPData.php @@ -0,0 +1,497 @@ +<?php + +class AFPData { + // Datatypes + const DINT = 'int'; + const DSTRING = 'string'; + const DNULL = 'null'; + const DBOOL = 'bool'; + const DFLOAT = 'float'; + const DLIST = 'list'; + + // 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 $type; + public $data; + + /** + * @param string $type + * @param null $val + */ + public function __construct( $type = self::DNULL, $val = null ) { + $this->type = $type; + $this->data = $val; + } + + /** + * @param mixed $var + * @return 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::DLIST, $result ); + } elseif ( is_null( $var ) ) { + return new AFPData(); + } else { + 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(); + } + if ( $target == self::DNULL ) { + return new AFPData(); + } + + if ( $orig->type == self::DLIST ) { + if ( $target == self::DBOOL ) { + return new AFPData( self::DBOOL, (bool)count( $orig->data ) ); + } + if ( $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 ) { + $s = ''; + foreach ( $orig->data as $item ) { + $s .= $item->toString() . "\n"; + } + + return new AFPData( self::DSTRING, $s ); + } + } + + if ( $target == self::DBOOL ) { + return new AFPData( self::DBOOL, (bool)$orig->data ); + } + if ( $target == self::DFLOAT ) { + return new AFPData( self::DFLOAT, floatval( $orig->data ) ); + } + if ( $target == self::DINT ) { + return new AFPData( self::DINT, intval( $orig->data ) ); + } + if ( $target == self::DSTRING ) { + return new AFPData( self::DSTRING, strval( $orig->data ) ); + } + if ( $target == self::DLIST ) { + return new AFPData( self::DLIST, [ $orig ] ); + } + } + + /** + * @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 ); + } + + return new AFPData( self::DBOOL, strpos( $b, $a ) !== false ); + } + + /** + * @param AFPData $a + * @param AFPData $b + * @return AFPData + */ + public static function keywordContains( $a, $b ) { + $a = $a->toString(); + $b = $b->toString(); + + if ( $a == '' || $b == '' ) { + return new AFPData( self::DBOOL, false ); + } + + return new AFPData( self::DBOOL, strpos( $a, $b ) !== false ); + } + + /** + * @param string $value + * @param mixed $list + * @return bool + */ + public static function listContains( $value, $list ) { + // Should use built-in PHP function somehow + foreach ( $list->data as $item ) { + if ( self::equals( $value, $item ) ) { + return true; + } + } + + return false; + } + + /** + * @ToDo Should we also build a proper system to compare arrays with different types? + * @param AFPData $d1 + * @param AFPData $d2 + * @param bool $strict whether to also check types + * @return bool + */ + public static function equals( $d1, $d2, $strict = false ) { + if ( $d1->type != self::DLIST && $d2->type != self::DLIST ) { + $typecheck = $d1->type == $d2->type || !$strict; + return $typecheck && $d1->toString() === $d2->toString(); + } elseif ( $d1->type == self::DLIST && $d2->type == self::DLIST ) { + $data1 = $d1->data; + $data2 = $d2->data; + if ( count( $data1 ) !== count( $data2 ) ) { + return false; + } + $length = count( $data1 ); + for ( $i = 0; $i < $length; $i++ ) { + $result = self::equals( $data1[$i], $data2[$i], $strict ); + if ( $result === false ) { + return false; + } + } + return true; + } else { + // Trying to compare an array to something else + return false; + } + } + + /** + * @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, + [ 'unspecified error in preg_match()', $pattern ] + ); + } + + return new AFPData( self::DBOOL, (bool)$result ); + } + + /** + * @param string $str + * @param string $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() ); + } else { + return new AFPData( $data->type, -$data->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 == '|' ) { + return new AFPData( self::DBOOL, $a || $b ); + } + if ( $op == '&' ) { + return new AFPData( self::DBOOL, $a && $b ); + } + if ( $op == '^' ) { + return new AFPData( self::DBOOL, $a xor $b ); + } + throw new AFPException( "Invalid boolean operation: {$op}" ); // Should never happen. + } + + /** + * @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 ) ); + } + if ( $op == '!=' ) { + return new AFPData( self::DBOOL, !self::equals( $a, $b ) ); + } + 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(); + $b = $b->toString(); + if ( $op == '>' ) { + return new AFPData( self::DBOOL, $a > $b ); + } + if ( $op == '<' ) { + return new AFPData( self::DBOOL, $a < $b ); + } + if ( $op == '>=' ) { + return new AFPData( self::DBOOL, $a >= $b ); + } + if ( $op == '<=' ) { + return new AFPData( self::DBOOL, $a <= $b ); + } + throw new AFPException( "Invalid comparison operation: {$op}" ); // Should never happen + } + + /** + * @param AFPData $a + * @param AFPData $b + * @param string $op + * @param int $pos + * @return AFPData + * @throws AFPUserVisibleException + * @throws AFPException + */ + public static function mulRel( $a, $b, $op, $pos ) { + $a = $a->toNumber(); + $b = $b->toNumber(); + + if ( $op != '*' && $b == 0 ) { + throw new AFPUserVisibleException( 'dividebyzero', $pos, [ $a ] ); + } + + if ( $op == '*' ) { + $data = $a * $b; + } elseif ( $op == '/' ) { + $data = $a / $b; + } elseif ( $op == '%' ) { + $data = $a % $b; + } else { + // Should never happen + throw new AFPException( "Invalid multiplication-related operation: {$op}" ); + } + + if ( $data === (int)$data ) { + $data = intval( $data ); + $type = self::DINT; + } else { + $data = floatval( $data ); + $type = 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::DLIST && $b->type == self::DLIST ) { + return new AFPData( self::DLIST, array_merge( $a->toList(), $b->toList() ) ); + } else { + $res = $a->toNumber() + $b->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 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 ); + } + } + + /** Convert shorteners */ + + /** + * @throws MWException + * @return mixed + */ + public function toNative() { + switch ( $this->type ) { + case self::DBOOL: + return $this->toBool(); + case self::DSTRING: + return $this->toString(); + case self::DFLOAT: + return $this->toFloat(); + case self::DINT: + return $this->toInt(); + case self::DLIST: + $input = $this->toList(); + $output = []; + foreach ( $input as $item ) { + $output[] = $item->toNative(); + } + + return $output; + case self::DNULL: + return null; + default: + throw new MWException( "Unknown type" ); + } + } + + /** + * @return bool + */ + public function toBool() { + return self::castTypes( $this, self::DBOOL )->data; + } + + /** + * @return string + */ + public function toString() { + return self::castTypes( $this, self::DSTRING )->data; + } + + /** + * @return float + */ + public function toFloat() { + return self::castTypes( $this, self::DFLOAT )->data; + } + + /** + * @return int + */ + public function toInt() { + return self::castTypes( $this, self::DINT )->data; + } + + /** + * @return int|float + */ + public function toNumber() { + return $this->type == self::DINT ? $this->toInt() : $this->toFloat(); + } + + public function toList() { + return self::castTypes( $this, self::DLIST )->data; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AFPException.php b/www/wiki/extensions/AbuseFilter/includes/parser/AFPException.php new file mode 100644 index 00000000..51fe4442 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AFPException.php @@ -0,0 +1,4 @@ +<?php + +class AFPException extends MWException { +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AFPParserState.php b/www/wiki/extensions/AbuseFilter/includes/parser/AFPParserState.php new file mode 100644 index 00000000..7a4f5a73 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AFPParserState.php @@ -0,0 +1,10 @@ +<?php + +class AFPParserState { + public $pos, $token; + + public function __construct( $token, $pos ) { + $this->token = $token; + $this->pos = $pos; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AFPToken.php b/www/wiki/extensions/AbuseFilter/includes/parser/AFPToken.php new file mode 100644 index 00000000..2f7d9c99 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AFPToken.php @@ -0,0 +1,61 @@ +<?php +/** + * Abuse filter parser. + * Copyright © Victor Vasiliev, 2008. + * Based on ideas by Andrew Garrett + * Distributed under GNU GPL v2 terms. + * + * Types of token: + * * T_NONE - special-purpose token + * * T_BRACE - ( or ) + * * T_COMMA - , + * * T_OP - operator like + or ^ + * * T_NUMBER - number + * * T_STRING - string, in "" or '' + * * T_KEYWORD - keyword + * * T_ID - identifier + * * T_STATEMENT_SEPARATOR - ; + * * T_SQUARE_BRACKETS - [ or ] + * + * Levels of parsing: + * * Entry - catches unexpected characters + * * Semicolon - ; + * * Set - := + * * Conditionls (IF) - if-then-else-end, cond ? a :b + * * BoolOps (BO) - &, |, ^ + * * CompOps (CO) - ==, !=, ===, !==, >, <, >=, <= + * * SumRel (SR) - +, - + * * MulRel (MR) - *, /, % + * * Pow (P) - ** + * * BoolNeg (BN) - ! operation + * * SpecialOperators (SO) - in and like + * * Unarys (U) - plus and minus in cases like -5 or -(2 * +2) + * * ListElement (LE) - list[number] + * * Braces (B) - ( and ) + * * Functions (F) + * * Atom (A) - return value + */ +class AFPToken { + // Types of tken + const TNONE = 'T_NONE'; + const TID = 'T_ID'; + const TKEYWORD = 'T_KEYWORD'; + 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 $type; + public $value; + public $pos; + + public function __construct( $type = self::TNONE, $value = null, $pos = 0 ) { + $this->type = $type; + $this->value = $value; + $this->pos = $pos; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AFPTreeNode.php b/www/wiki/extensions/AbuseFilter/includes/parser/AFPTreeNode.php new file mode 100644 index 00000000..e185616c --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AFPTreeNode.php @@ -0,0 +1,126 @@ +<?php +/** + * Represents a node of a parser tree. + */ +class AFPTreeNode { + // Each of the constants below represents a node corresponding to a level + // of the parser, from the top of the tree to the bottom. + + // ENTRY is always one-element and thus does not have its own node. + + // 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'; + + // ASSIGNMENT (formerly known as SET) is a node which is responsible for + // assigning values to variables. ASSIGNMENT is a (variable name [string], + // value [tree node]) tuple, INDEX_ASSIGNMENT (which is used to assign + // values at list offsets) is a (variable name [string], index [tree node], + // value [tree node]) tuple, and LIST_APPEND has the form of (variable name + // [string], value [tree node]). + const ASSIGNMENT = 'ASSIGNMENT'; + const INDEX_ASSIGNMENT = 'INDEX_ASSIGNMENT'; + const LIST_APPEND = 'LIST_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'; + + // LOGIC is a logic operator accepted by AFPData::boolOp. The format is + // (operation, left operand, right operand). + const LOGIC = 'LOGIC'; + + // COMPARE is a comparison operator accepted by AFPData::boolOp. The format is + // (operation, left operand, right operand). + const COMPARE = 'COMPARE'; + + // SUM_REL is either '+' or '-'. The format is (operation, left operand, + // right operand). + 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'; + + // POW is an exponentiation operator. The format is (base, exponent). + const POW = 'POW'; + + // BOOL_INVERT is a boolean inversion operator. The format is (operand). + 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'; + + // UNARY is either unary minus or unary plus. The format is (operator, + // operand). + const UNARY = 'UNARY'; + + // LIST_INDEX is an operation of accessing a list by an offset. The format + // is (list, offset). + const LIST_INDEX = 'LIST_INDEX'; + + // Since parenthesis only manipulate precedence of the operators, they are + // not explicitly represented in the tree. + + // 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'; + + // LIST_DEFINITION is a list literal. The $children field contains tree + // nodes for the values of each of the list element used. + const LIST_DEFINITION = 'LIST_DEFINITION'; + + // ATOM is a node representing a literal. The only element of $children is a + // token corresponding to the literal. + const ATOM = 'ATOM'; + + /** @var string Type of the node, one of the constants above */ + public $type; + /** + * Parameters of the value. Typically it is an array of children nodes, + * which might be either strings (for parametrization of the node) or another + * node. In case of ATOM it's a parser token. + * @var AFPTreeNode[]|string[]|AFPToken + */ + public $children; + + // Position used for error reporting. + public $position; + + public function __construct( $type, $children, $position ) { + $this->type = $type; + $this->children = $children; + $this->position = $position; + } + + public function toDebugString() { + return implode( "\n", $this->toDebugStringInner() ); + } + + private function toDebugStringInner() { + if ( $this->type == self::ATOM ) { + return [ "ATOM({$this->children->type} {$this->children->value})" ]; + } + + $align = function ( $line ) { + return ' ' . $line; + }; + + $lines = [ "{$this->type}" ]; + foreach ( $this->children as $subnode ) { + if ( $subnode instanceof AFPTreeNode ) { + $sublines = array_map( $align, $subnode->toDebugStringInner() ); + } elseif ( is_string( $subnode ) ) { + $sublines = [ " {$subnode}" ]; + } else { + throw new AFPException( "Each node parameter has to be either a node or a string" ); + } + + $lines = array_merge( $lines, $sublines ); + } + return $lines; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AFPTreeParser.php b/www/wiki/extensions/AbuseFilter/includes/parser/AFPTreeParser.php new file mode 100644 index 00000000..345adcb8 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AFPTreeParser.php @@ -0,0 +1,611 @@ +<?php + +/** + * A version of the abuse filter parser that separates parsing the filter and + * evaluating it into different passes, allowing the parse tree to be cached. + * + * @file + */ + +/** + * A parser that transforms the text of the filter into a parse tree. + */ +class AFPTreeParser { + // The tokenized representation of the filter parsed. + public $mTokens; + + // Current token handled by the parser and its position. + public $mCur, $mPos; + + const CACHE_VERSION = 2; + + /** + * Create a new instance + */ + public function __construct() { + $this->resetState(); + } + + public function resetState() { + $this->mTokens = []; + $this->mPos = 0; + } + + /** + * Advances the parser to the next token in the filter code. + */ + protected function move() { + list( $this->mCur, $this->mPos ) = $this->mTokens[$this->mPos]; + } + + /** + * getState() function allows parser state to be rollbacked to several tokens + * back. + * + * @return AFPParserState + */ + protected function getState() { + return new AFPParserState( $this->mCur, $this->mPos ); + } + + /** + * setState() function allows parser state to be rollbacked to several tokens + * back. + * + * @param AFPParserState $state + */ + protected function setState( AFPParserState $state ) { + $this->mCur = $state->token; + $this->mPos = $state->pos; + } + + /** + * Parse the supplied filter source code into a tree. + * + * @param string $code + * @throws AFPUserVisibleException + * @return AFPTreeNode|null + */ + public function parse( $code ) { + $this->mTokens = AbuseFilterTokenizer::tokenize( $code ); + $this->mPos = 0; + + return $this->doLevelEntry(); + } + + /* Levels */ + + /** + * Handles unexpected characters after the expression. + * @return AFPTreeNode|null + * @throws AFPUserVisibleException + */ + protected function doLevelEntry() { + $result = $this->doLevelSemicolon(); + + if ( $this->mCur->type != AFPToken::TNONE ) { + throw new AFPUserVisibleException( + 'unexpectedatend', + $this->mPos, [ $this->mCur->type ] + ); + } + + return $result; + } + + /** + * Handles the semicolon operator. + * + * @return AFPTreeNode|null + */ + protected function doLevelSemicolon() { + $statements = []; + + do { + $this->move(); + $position = $this->mPos; + + if ( $this->mCur->type == AFPToken::TNONE ) { + break; + } + + // Allow empty statements. + if ( $this->mCur->type == AFPToken::TSTATEMENTSEPARATOR ) { + continue; + } + + $statements[] = $this->doLevelSet(); + $position = $this->mPos; + } while ( $this->mCur->type == AFPToken::TSTATEMENTSEPARATOR ); + + // Flatten the tree if possible. + if ( count( $statements ) == 0 ) { + return null; + } elseif ( count( $statements ) == 1 ) { + return $statements[0]; + } else { + return new AFPTreeNode( AFPTreeNode::SEMICOLON, $statements, $position ); + } + } + + /** + * Handles variable assignment. + * + * @return AFPTreeNode + * @throws AFPUserVisibleException + */ + protected function doLevelSet() { + if ( $this->mCur->type == AFPToken::TID ) { + $varname = $this->mCur->value; + + // Speculatively parse the assignment statement assuming it can + // potentially be an assignment, but roll back if it isn't. + $initialState = $this->getState(); + $this->move(); + + if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) { + $position = $this->mPos; + $this->move(); + $value = $this->doLevelSet(); + + return new AFPTreeNode( AFPTreeNode::ASSIGNMENT, [ $varname, $value ], $position ); + } + + if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) { + $this->move(); + + 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 == ']' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, + [ ']', $this->mCur->type, $this->mCur->value ] ); + } + } + + $this->move(); + if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) { + $position = $this->mPos; + $this->move(); + $value = $this->doLevelSet(); + if ( $index === 'append' ) { + return new AFPTreeNode( + AFPTreeNode::LIST_APPEND, [ $varname, $value ], $position ); + } else { + return new AFPTreeNode( + AFPTreeNode::INDEX_ASSIGNMENT, + [ $varname, $index, $value ], + $position + ); + } + } + } + + // If we reached this point, we did not find an assignment. Roll back + // and assume this was just a literal. + $this->setState( $initialState ); + } + + return $this->doLevelConditions(); + } + + /** + * Handles ternary operator and if-then-else-end. + * + * @return AFPTreeNode + * @throws AFPUserVisibleException + */ + protected function doLevelConditions() { + 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' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', + $this->mPos, + [ + 'then', + $this->mCur->type, + $this->mCur->value + ] + ); + } + $this->move(); + + $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 + ] + ); + } + $this->move(); + + $valueIfFalse = $this->doLevelConditions(); + + if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'end' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', + $this->mPos, + [ + 'end', + $this->mCur->type, + $this->mCur->value + ] + ); + } + $this->move(); + + return new AFPTreeNode( + AFPTreeNode::CONDITIONAL, + [ $condition, $valueIfTrue, $valueIfFalse ], + $position + ); + } + + $condition = $this->doLevelBoolOps(); + 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 == ':' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', + $this->mPos, + [ + ':', + $this->mCur->type, + $this->mCur->value + ] + ); + } + $this->move(); + + $valueIfFalse = $this->doLevelConditions(); + return new AFPTreeNode( + AFPTreeNode::CONDITIONAL, + [ $condition, $valueIfTrue, $valueIfFalse ], + $position + ); + } + + return $condition; + } + + /** + * Handles logic operators. + * + * @return AFPTreeNode + */ + protected function doLevelBoolOps() { + $leftOperand = $this->doLevelCompares(); + $ops = [ '&', '|', '^' ]; + while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $op = $this->mCur->value; + $position = $this->mPos; + $this->move(); + + $rightOperand = $this->doLevelCompares(); + + $leftOperand = new AFPTreeNode( + AFPTreeNode::LOGIC, + [ $op, $leftOperand, $rightOperand ], + $position + ); + } + return $leftOperand; + } + + /** + * Handles comparison operators. + * + * @return AFPTreeNode + */ + protected function doLevelCompares() { + $leftOperand = $this->doLevelSumRels(); + $ops = [ '==', '===', '!=', '!==', '<', '>', '<=', '>=', '=' ]; + while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $op = $this->mCur->value; + $position = $this->mPos; + $this->move(); + $rightOperand = $this->doLevelSumRels(); + $leftOperand = new AFPTreeNode( + AFPTreeNode::COMPARE, + [ $op, $leftOperand, $rightOperand ], + $position + ); + } + return $leftOperand; + } + + /** + * Handle addition and subtraction. + * + * @return AFPTreeNode + */ + protected function doLevelSumRels() { + $leftOperand = $this->doLevelMulRels(); + $ops = [ '+', '-' ]; + while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $op = $this->mCur->value; + $position = $this->mPos; + $this->move(); + $rightOperand = $this->doLevelMulRels(); + $leftOperand = new AFPTreeNode( + AFPTreeNode::SUM_REL, + [ $op, $leftOperand, $rightOperand ], + $position + ); + } + return $leftOperand; + } + + /** + * Handles multiplication and division. + * + * @return AFPTreeNode + */ + protected function doLevelMulRels() { + $leftOperand = $this->doLevelPow(); + $ops = [ '*', '/', '%' ]; + while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $op = $this->mCur->value; + $position = $this->mPos; + $this->move(); + $rightOperand = $this->doLevelPow(); + $leftOperand = new AFPTreeNode( + AFPTreeNode::MUL_REL, + [ $op, $leftOperand, $rightOperand ], + $position + ); + } + return $leftOperand; + } + + /** + * Handles exponentiation. + * + * @return AFPTreeNode + */ + protected function doLevelPow() { + $base = $this->doLevelBoolInvert(); + while ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '**' ) { + $position = $this->mPos; + $this->move(); + $exponent = $this->doLevelBoolInvert(); + $base = new AFPTreeNode( AFPTreeNode::POW, [ $base, $exponent ], $position ); + } + return $base; + } + + /** + * Handles boolean inversion. + * + * @return AFPTreeNode + */ + protected function doLevelBoolInvert() { + if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '!' ) { + $position = $this->mPos; + $this->move(); + $argument = $this->doLevelKeywordOperators(); + return new AFPTreeNode( AFPTreeNode::BOOL_INVERT, [ $argument ], $position ); + } + + return $this->doLevelKeywordOperators(); + } + + /** + * Handles keyword operators. + * + * @return AFPTreeNode + */ + protected function doLevelKeywordOperators() { + $leftOperand = $this->doLevelUnarys(); + $keyword = strtolower( $this->mCur->value ); + if ( $this->mCur->type == AFPToken::TKEYWORD && + in_array( $keyword, array_keys( AbuseFilterParser::$mKeywords ) ) + ) { + $position = $this->mPos; + $this->move(); + $rightOperand = $this->doLevelUnarys(); + + return new AFPTreeNode( + AFPTreeNode::KEYWORD_OPERATOR, + [ $keyword, $leftOperand, $rightOperand ], + $position + ); + } + + return $leftOperand; + } + + /** + * Handles unary operators. + * + * @return AFPTreeNode + */ + protected function doLevelUnarys() { + $op = $this->mCur->value; + if ( $this->mCur->type == AFPToken::TOP && ( $op == "+" || $op == "-" ) ) { + $position = $this->mPos; + $this->move(); + $argument = $this->doLevelListElements(); + return new AFPTreeNode( AFPTreeNode::UNARY, [ $op, $argument ], $position ); + } + return $this->doLevelListElements(); + } + + /** + * Handles accessing a list element by an offset. + * + * @return AFPTreeNode + * @throws AFPUserVisibleException + */ + protected function doLevelListElements() { + $list = $this->doLevelParenthesis(); + while ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) { + $position = $this->mPos; + $index = $this->doLevelSemicolon(); + $list = new AFPTreeNode( AFPTreeNode::LIST_INDEX, [ $list, $index ], $position ); + + if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', $this->mPos, + [ ']', $this->mCur->type, $this->mCur->value ] ); + } + $this->move(); + } + + return $list; + } + + /** + * Handles parenthesis. + * + * @return AFPTreeNode + * @throws AFPUserVisibleException + */ + protected function doLevelParenthesis() { + if ( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == '(' ) { + $result = $this->doLevelSemicolon(); + + if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == ')' ) ) { + throw new AFPUserVisibleException( + 'expectednotfound', + $this->mPos, + [ ')', $this->mCur->type, $this->mCur->value ] + ); + } + $this->move(); + + return $result; + } + + return $this->doLevelFunction(); + } + + /** + * Handles function calls. + * + * @return AFPTreeNode + * @throws AFPUserVisibleException + */ + protected function doLevelFunction() { + if ( $this->mCur->type == AFPToken::TID && + isset( AbuseFilterParser::$mFunctions[$this->mCur->value] ) + ) { + $func = $this->mCur->value; + $position = $this->mPos; + $this->move(); + if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != '(' ) { + throw new AFPUserVisibleException( 'expectednotfound', + $this->mPos, + [ + '(', + $this->mCur->type, + $this->mCur->value + ] + ); + } + + $args = []; + do { + $args[] = $this->doLevelSemicolon(); + } while ( $this->mCur->type == AFPToken::TCOMMA ); + + if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) { + throw new AFPUserVisibleException( 'expectednotfound', + $this->mPos, + [ + ')', + $this->mCur->type, + $this->mCur->value + ] + ); + } + $this->move(); + + array_unshift( $args, $func ); + return new AFPTreeNode( AFPTreeNode::FUNCTION_CALL, $args, $position ); + } + + return $this->doLevelAtom(); + } + + /** + * Handle literals. + * @return AFPTreeNode + * @throws AFPUserVisibleException + */ + protected function doLevelAtom() { + $tok = $this->mCur->value; + switch ( $this->mCur->type ) { + case AFPToken::TID: + case AFPToken::TSTRING: + case AFPToken::TFLOAT: + case AFPToken::TINT: + $result = new AFPTreeNode( AFPTreeNode::ATOM, $this->mCur, $this->mPos ); + break; + case AFPToken::TKEYWORD: + if ( in_array( $tok, [ "true", "false", "null" ] ) ) { + $result = new AFPTreeNode( AFPTreeNode::ATOM, $this->mCur, $this->mPos ); + break; + } + + throw new AFPUserVisibleException( + 'unrecognisedkeyword', + $this->mPos, + [ $tok ] + ); + /** @noinspection PhpMissingBreakStatementInspection */ + case AFPToken::TSQUAREBRACKET: + if ( $this->mCur->value == '[' ) { + $list = []; + while ( true ) { + $this->move(); + if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + break; + } + + $list[] = $this->doLevelSet(); + + if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + break; + } + if ( $this->mCur->type != AFPToken::TCOMMA ) { + throw new AFPUserVisibleException( + 'expectednotfound', + $this->mPos, + [ ', or ]', $this->mCur->type, $this->mCur->value ] + ); + } + } + + $result = new AFPTreeNode( AFPTreeNode::LIST_DEFINITION, $list, $this->mPos ); + break; + } + + // Fallthrough expected + default: + throw new AFPUserVisibleException( + 'unexpectedtoken', + $this->mPos, + [ + $this->mCur->type, + $this->mCur->value + ] + ); + } + + $this->move(); + return $result; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AFPUserVisibleException.php b/www/wiki/extensions/AbuseFilter/includes/parser/AFPUserVisibleException.php new file mode 100644 index 00000000..b6e89d03 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AFPUserVisibleException.php @@ -0,0 +1,40 @@ +<?php + +// Exceptions that we might conceivably want to report to ordinary users +// (i.e. exceptions that don't represent bugs in the extension itself) +class AFPUserVisibleException extends AFPException { + public $mExceptionId; + public $mPosition; + public $mParams; + + /** + * @param string $exception_id + * @param int $position + * @param array $params + */ + function __construct( $exception_id, $position, $params ) { + $this->mExceptionID = $exception_id; + $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 ); + } + + public function getMessageObj() { + // Give grep a chance to find the usages: + // abusefilter-exception-unexpectedatend, abusefilter-exception-expectednotfound + // abusefilter-exception-unrecognisedkeyword, abusefilter-exception-unexpectedtoken + // abusefilter-exception-unclosedstring, abusefilter-exception-invalidoperator + // abusefilter-exception-unrecognisedtoken, abusefilter-exception-noparams + // abusefilter-exception-dividebyzero, abusefilter-exception-unrecognisedvar + // abusefilter-exception-notenoughargs, abusefilter-exception-regexfailure + // abusefilter-exception-overridebuiltin, abusefilter-exception-outofbounds + // abusefilter-exception-notlist, abusefilter-exception-unclosedcomment + return wfMessage( + 'abusefilter-exception-' . $this->mExceptionID, + array_merge( [ $this->mPosition ], $this->mParams ) + ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterCachingParser.php b/www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterCachingParser.php new file mode 100644 index 00000000..37384356 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterCachingParser.php @@ -0,0 +1,279 @@ +<?php +/** + * AbuseFilterCachingParser is the version of AbuseFilterParser which parses + * the code into an abstract syntax tree before evaluating it, and caches that + * tree. + * + * It currently inherits AbuseFilterParser in order to avoid code duplication. + * In future, this code will replace current AbuseFilterParser entirely. + */ +class AbuseFilterCachingParser extends AbuseFilterParser { + /** + * Return the generated version of the parser for cache invalidation + * purposes. Automatically tracks list of all functions and invalidates the + * cache if it is changed. + * @return string + */ + public static function getCacheVersion() { + static $version = null; + if ( $version !== null ) { + return $version; + } + + $versionKey = [ + AFPTreeParser::CACHE_VERSION, + AbuseFilterTokenizer::CACHE_VERSION, + array_keys( AbuseFilterParser::$mFunctions ), + array_keys( AbuseFilterParser::$mKeywords ), + ]; + $version = hash( 'sha256', serialize( $versionKey ) ); + + return $version; + } + + public function resetState() { + $this->mVars = new AbuseFilterVariableHolder; + $this->mCur = new AFPToken(); + } + + public function intEval( $code ) { + static $cache = null; + if ( !$cache ) { + $cache = ObjectCache::getLocalServerInstance( 'hash' ); + } + + $tree = $cache->getWithSetCallback( + $cache->makeGlobalKey( + __CLASS__, + self::getCacheVersion(), + hash( 'sha256', $code ) + ), + $cache::TTL_DAY, + function () use ( $code ) { + $parser = new AFPTreeParser(); + return $parser->parse( $code ) ?: false; + } + ); + + return $tree + ? $this->evalNode( $tree ) + : new AFPData( AFPData::DNULL, null ); + } + + /** + * Evaluate the value of the specified AST node. + * + * @param AFPTreeNode $node The node to evaluate. + * @return AFPData + * @throws AFPException + * @throws AFPUserVisibleException + * @throws MWException + */ + public function evalNode( AFPTreeNode $node ) { + // A lot of AbuseFilterParser features rely on $this->mCur->pos or + // $this->mPos for error reporting. + // FIXME: this is a hack which needs to be removed when the parsers are + // merged. + $this->mPos = $node->position; + $this->mCur->pos = $node->position; + + switch ( $node->type ) { + case AFPTreeNode::ATOM: + $tok = $node->children; + switch ( $tok->type ) { + case AFPToken::TID: + return $this->getVarValue( strtolower( $tok->value ) ); + case AFPToken::TSTRING: + return new AFPData( AFPData::DSTRING, $tok->value ); + case AFPToken::TFLOAT: + return new AFPData( AFPData::DFLOAT, $tok->value ); + case AFPToken::TINT: + return new AFPData( AFPData::DINT, $tok->value ); + /** @noinspection PhpMissingBreakStatementInspection */ + case AFPToken::TKEYWORD: + switch ( $tok->value ) { + case "true": + return new AFPData( AFPData::DBOOL, true ); + case "false": + return new AFPData( AFPData::DBOOL, false ); + case "null": + return new AFPData(); + } + // Fallthrough intended + default: + throw new AFPException( "Unknown token provided in the ATOM node" ); + } + case AFPTreeNode::LIST_DEFINITION: + $items = array_map( [ $this, 'evalNode' ], $node->children ); + return new AFPData( AFPData::DLIST, $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 = []; + } + + return $result; + + case AFPTreeNode::LIST_INDEX: + list( $list, $offset ) = $node->children; + + $list = $this->evalNode( $list ); + if ( $list->type != AFPData::DLIST ) { + throw new AFPUserVisibleException( 'notlist', $node->position, [] ); + } + + $offset = $this->evalNode( $offset )->toInt(); + + $list = $list->toList(); + if ( count( $list ) <= $offset ) { + throw new AFPUserVisibleException( 'outofbounds', $node->position, + [ $offset, count( $list ) ] ); + } + + return $list[$offset]; + + case AFPTreeNode::UNARY: + list( $operation, $argument ) = $node->children; + $argument = $this->evalNode( $argument ); + if ( $operation == '-' ) { + return AFPData::unaryMinus( $argument ); + } + 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; + case AFPTreeNode::BOOL_INVERT: + list( $argument ) = $node->children; + $argument = $this->evalNode( $argument ); + return AFPData::boolInvert( $argument ); + + case AFPTreeNode::POW: + list( $base, $exponent ) = $node->children; + $base = $this->evalNode( $base ); + $exponent = $this->evalNode( $exponent ); + return AFPData::pow( $base, $exponent ); + + case AFPTreeNode::MUL_REL: + list( $op, $leftOperand, $rightOperand ) = $node->children; + $leftOperand = $this->evalNode( $leftOperand ); + $rightOperand = $this->evalNode( $rightOperand ); + return AFPData::mulRel( $leftOperand, $rightOperand, $op, /* FIXME */ + 0 ); + + case AFPTreeNode::SUM_REL: + list( $op, $leftOperand, $rightOperand ) = $node->children; + $leftOperand = $this->evalNode( $leftOperand ); + $rightOperand = $this->evalNode( $rightOperand ); + switch ( $op ) { + case '+': + return AFPData::sum( $leftOperand, $rightOperand ); + case '-': + return AFPData::sub( $leftOperand, $rightOperand ); + default: + throw new AFPException( "Unknown sum-related operator: {$op}" ); + } + + 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 ); + + case AFPTreeNode::LOGIC: + list( $op, $leftOperand, $rightOperand ) = $node->children; + $leftOperand = $this->evalNode( $leftOperand ); + $value = $leftOperand->toBool(); + // Short-circuit. + if ( ( !$value && $op == '&' ) || ( $value && $op == '|' ) ) { + return $leftOperand; + } + $rightOperand = $this->evalNode( $rightOperand ); + return AFPData::boolOp( $leftOperand, $rightOperand, $op ); + + case AFPTreeNode::CONDITIONAL: + list( $condition, $valueIfTrue, $valueIfFalse ) = $node->children; + $condition = $this->evalNode( $condition ); + if ( $condition->toBool() ) { + return $this->evalNode( $valueIfTrue ); + } else { + return $this->evalNode( $valueIfFalse ); + } + + case AFPTreeNode::ASSIGNMENT: + list( $varName, $value ) = $node->children; + $value = $this->evalNode( $value ); + $this->setUserVariable( $varName, $value ); + return $value; + + case AFPTreeNode::INDEX_ASSIGNMENT: + list( $varName, $offset, $value ) = $node->children; + + $list = $this->mVars->getVar( $varName ); + if ( $list->type != AFPData::DLIST ) { + throw new AFPUserVisibleException( 'notlist', $node->position, [] ); + } + + $offset = $this->evalNode( $offset )->toInt(); + + $list = $list->toList(); + if ( count( $list ) <= $offset ) { + throw new AFPUserVisibleException( 'outofbounds', $node->position, + [ $offset, count( $list ) ] ); + } + + $list[$offset] = $this->evalNode( $value ); + $this->setUserVariable( $varName, new AFPData( AFPData::DLIST, $list ) ); + return $value; + + case AFPTreeNode::LIST_APPEND: + list( $varName, $value ) = $node->children; + + $list = $this->mVars->getVar( $varName ); + if ( $list->type != AFPData::DLIST ) { + throw new AFPUserVisibleException( 'notlist', $node->position, [] ); + } + + $list = $list->toList(); + $list[] = $this->evalNode( $value ); + $this->setUserVariable( $varName, new AFPData( AFPData::DLIST, $list ) ); + return $value; + + case AFPTreeNode::SEMICOLON: + $lastValue = null; + foreach ( $node->children as $statement ) { + $lastValue = $this->evalNode( $statement ); + } + + return $lastValue; + default: + throw new AFPException( "Unknown node type passed: {$node->type}" ); + } + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterParser.php b/www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterParser.php new file mode 100644 index 00000000..50f8dddc --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterParser.php @@ -0,0 +1,1560 @@ +<?php + +use Wikimedia\Equivset\Equivset; + +class AbuseFilterParser { + public $mCode, $mTokens, $mPos, $mCur, $mShortCircuit, $mAllowShort, $mLen; + + /** + * @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', + 'substr' => 'funcSubstr', + 'strlen' => 'funcLen', + 'strpos' => 'funcStrPos', + 'str_replace' => 'funcStrReplace', + 'rescape' => 'funcStrRegexEscape', + 'set' => 'funcSetVar', + 'set_var' => 'funcSetVar', + ]; + + // Functions that affect parser state, and shouldn't be cached. + public static $ActiveFunctions = [ + 'funcSetVar', + ]; + + public static $mKeywords = [ + 'in' => 'keywordIn', + 'like' => 'keywordLike', + 'matches' => 'keywordLike', + 'contains' => 'keywordContains', + 'rlike' => 'keywordRegex', + 'irlike' => 'keywordRegexInsensitive', + 'regex' => 'keywordRegex', + ]; + + public static $funcCache = []; + + /** + * @var Equivset + */ + protected static $equivset; + + /** + * Create a new instance + * + * @param AbuseFilterVariableHolder $vars + */ + public function __construct( $vars = null ) { + $this->resetState(); + if ( $vars instanceof AbuseFilterVariableHolder ) { + $this->mVars = $vars; + } + } + + public function resetState() { + $this->mCode = ''; + $this->mTokens = []; + $this->mVars = new AbuseFilterVariableHolder; + $this->mPos = 0; + $this->mShortCircuit = false; + $this->mAllowShort = true; + } + + /** + * @param string $filter + * @return array|bool + */ + public function checkSyntax( $filter ) { + try { + $origAS = $this->mAllowShort; + $this->mAllowShort = false; + $this->parse( $filter ); + } catch ( AFPUserVisibleException $excep ) { + $this->mAllowShort = $origAS; + + return [ $excep->getMessageObj()->text(), $excep->mPosition ]; + } + $this->mAllowShort = $origAS; + + return true; + } + + /** + * @param string $name + * @param mixed $value + */ + public function setVar( $name, $value ) { + $this->mVars->setVar( $name, $value ); + } + + /** + * @param mixed $vars + */ + public function setVars( $vars ) { + if ( is_array( $vars ) ) { + foreach ( $vars as $name => $var ) { + $this->setVar( $name, $var ); + } + } elseif ( $vars instanceof AbuseFilterVariableHolder ) { + $this->mVars->addHolders( $vars ); + } + } + + /** + * @return AFPToken + */ + protected function move() { + list( $this->mCur, $this->mPos ) = $this->mTokens[$this->mPos]; + } + + /** + * getState() function allows parser state to be rollbacked to several tokens back + * @return AFPParserState + */ + protected function getState() { + return new AFPParserState( $this->mCur, $this->mPos ); + } + + /** + * setState() function allows parser state to be rollbacked to several tokens back + * @param AFPParserState $state + */ + protected function setState( AFPParserState $state ) { + $this->mCur = $state->token; + $this->mPos = $state->pos; + } + + /** + * @return mixed + * @throws AFPUserVisibleException + */ + protected function skipOverBraces() { + if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == '(' ) || + !$this->mShortCircuit + ) { + return; + } + + $braces = 1; + while ( $this->mCur->type != AFPToken::TNONE && $braces > 0 ) { + $this->move(); + if ( $this->mCur->type == AFPToken::TBRACE ) { + if ( $this->mCur->value == '(' ) { + $braces++; + } elseif ( $this->mCur->value == ')' ) { + $braces--; + } + } + } + if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == ')' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ ')' ] ); + } + } + + /** + * @param string $code + * @return bool + */ + public function parse( $code ) { + return $this->intEval( $code )->toBool(); + } + + /** + * @param string $filter + * @return string + */ + public function evaluateExpression( $filter ) { + return $this->intEval( $filter )->toString(); + } + + /** + * @param string $code + * @return AFPData + */ + public function intEval( $code ) { + // Setup, resetting + $this->mCode = $code; + $this->mTokens = AbuseFilterTokenizer::tokenize( $code ); + $this->mPos = 0; + $this->mLen = strlen( $code ); + $this->mShortCircuit = false; + + $result = new AFPData(); + $this->doLevelEntry( $result ); + + return $result; + } + + /** + * @param string $a + * @param string $b + * @return int + */ + static function lengthCompare( $a, $b ) { + if ( strlen( $a ) == strlen( $b ) ) { + return 0; + } + + return ( strlen( $a ) < strlen( $b ) ) ? -1 : 1; + } + + /* Levels */ + + /** + * Handles unexpected characters after the expression + * + * @param AFPData &$result + * @throws AFPUserVisibleException + */ + protected function doLevelEntry( &$result ) { + $this->doLevelSemicolon( $result ); + + if ( $this->mCur->type != AFPToken::TNONE ) { + throw new AFPUserVisibleException( + 'unexpectedatend', + $this->mCur->pos, [ $this->mCur->type ] + ); + } + } + + /** + * Handles multiple expressions + * @param AFPData &$result + */ + protected function doLevelSemicolon( &$result ) { + do { + $this->move(); + if ( $this->mCur->type != AFPToken::TSTATEMENTSEPARATOR ) { + $this->doLevelSet( $result ); + } + } while ( $this->mCur->type == AFPToken::TSTATEMENTSEPARATOR ); + } + + /** + * Handles multiple expressions + * + * @param AFPData &$result + * @throws AFPUserVisibleException + */ + protected function doLevelSet( &$result ) { + if ( $this->mCur->type == AFPToken::TID ) { + $varname = $this->mCur->value; + $prev = $this->getState(); + $this->move(); + + if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) { + $this->move(); + $this->doLevelSet( $result ); + $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 ] + ); + } + $list = $this->mVars->getVar( $varname ); + if ( $list->type != AFPData::DLIST ) { + throw new AFPUserVisibleException( 'notlist', $this->mCur->pos, [] ); + } + $list = $list->toList(); + $this->move(); + if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + $idx = 'new'; + } else { + $this->setState( $prev ); + $this->move(); + $idx = new AFPData(); + $this->doLevelSemicolon( $idx ); + $idx = $idx->toInt(); + if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, + [ ']', $this->mCur->type, $this->mCur->value ] ); + } + if ( count( $list ) <= $idx ) { + throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos, + [ $idx, count( $result->data ) ] ); + } + } + $this->move(); + if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) { + $this->move(); + $this->doLevelSet( $result ); + if ( $idx === 'new' ) { + $list[] = $result; + } else { + $list[$idx] = $result; + } + $this->setUserVariable( $varname, new AFPData( AFPData::DLIST, $list ) ); + + return; + } else { + $this->setState( $prev ); + } + } else { + $this->setState( $prev ); + } + } + $this->doLevelConditions( $result ); + } + + /** + * @param AFPData &$result + * @throws AFPUserVisibleException + */ + protected function doLevelConditions( &$result ) { + 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' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', + $this->mCur->pos, + [ + 'then', + $this->mCur->type, + $this->mCur->value + ] + ); + } + $this->move(); + + $r1 = new AFPData(); + $r2 = new AFPData(); + + $isTrue = $result->toBool(); + + if ( !$isTrue ) { + $scOrig = $this->mShortCircuit; + $this->mShortCircuit = $this->mAllowShort; + } + $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 ( $isTrue ) { + $scOrig = $this->mShortCircuit; + $this->mShortCircuit = $this->mAllowShort; + } + $this->doLevelConditions( $r2 ); + if ( $isTrue ) { + $this->mShortCircuit = $scOrig; + } + + if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'end' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', + $this->mCur->pos, + [ + 'end', + $this->mCur->type, + $this->mCur->value + ] + ); + } + $this->move(); + + if ( $result->toBool() ) { + $result = $r1; + } else { + $result = $r2; + } + } else { + $this->doLevelBoolOps( $result ); + if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '?' ) { + $this->move(); + $r1 = new AFPData(); + $r2 = new AFPData(); + + $isTrue = $result->toBool(); + + if ( !$isTrue ) { + $scOrig = $this->mShortCircuit; + $this->mShortCircuit = $this->mAllowShort; + } + $this->doLevelConditions( $r1 ); + if ( !$isTrue ) { + $this->mShortCircuit = $scOrig; + } + + if ( !( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':' ) ) { + throw new AFPUserVisibleException( 'expectednotfound', + $this->mCur->pos, + [ + ':', + $this->mCur->type, + $this->mCur->value + ] + ); + } + $this->move(); + + if ( $isTrue ) { + $scOrig = $this->mShortCircuit; + $this->mShortCircuit = $this->mAllowShort; + } + $this->doLevelConditions( $r2 ); + if ( $isTrue ) { + $this->mShortCircuit = $scOrig; + } + + if ( $isTrue ) { + $result = $r1; + } else { + $result = $r2; + } + } + } + } + + /** + * @param AFPData &$result + */ + protected function doLevelBoolOps( &$result ) { + $this->doLevelCompares( $result ); + $ops = [ '&', '|', '^' ]; + while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $op = $this->mCur->value; + $this->move(); + $r2 = new AFPData(); + + // We can go on quickly as either one statement with | is true or on with & is false + if ( ( $op == '&' && !$result->toBool() ) || ( $op == '|' && $result->toBool() ) ) { + $orig = $this->mShortCircuit; + $this->mShortCircuit = $this->mAllowShort; + $this->doLevelCompares( $r2 ); + $this->mShortCircuit = $orig; + $result = new AFPData( AFPData::DBOOL, $result->toBool() ); + continue; + } + + $this->doLevelCompares( $r2 ); + + $result = AFPData::boolOp( $result, $r2, $op ); + } + } + + /** + * @param string &$result + */ + protected function doLevelCompares( &$result ) { + $this->doLevelSumRels( $result ); + $ops = [ '==', '===', '!=', '!==', '<', '>', '<=', '>=', '=' ]; + while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $op = $this->mCur->value; + $this->move(); + $r2 = new AFPData(); + $this->doLevelSumRels( $r2 ); + if ( $this->mShortCircuit ) { + break; // The result doesn't matter. + } + AbuseFilter::triggerLimiter(); + $result = AFPData::compareOp( $result, $r2, $op ); + } + } + + /** + * @param string &$result + */ + protected function doLevelSumRels( &$result ) { + $this->doLevelMulRels( $result ); + $ops = [ '+', '-' ]; + while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $op = $this->mCur->value; + $this->move(); + $r2 = new AFPData(); + $this->doLevelMulRels( $r2 ); + if ( $this->mShortCircuit ) { + break; // The result doesn't matter. + } + if ( $op == '+' ) { + $result = AFPData::sum( $result, $r2 ); + } + if ( $op == '-' ) { + $result = AFPData::sub( $result, $r2 ); + } + } + } + + /** + * @param string &$result + */ + protected function doLevelMulRels( &$result ) { + $this->doLevelPow( $result ); + $ops = [ '*', '/', '%' ]; + while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) { + $op = $this->mCur->value; + $this->move(); + $r2 = new AFPData(); + $this->doLevelPow( $r2 ); + if ( $this->mShortCircuit ) { + break; // The result doesn't matter. + } + $result = AFPData::mulRel( $result, $r2, $op, $this->mCur->pos ); + } + } + + /** + * @param string &$result + */ + protected function doLevelPow( &$result ) { + $this->doLevelBoolInvert( $result ); + while ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '**' ) { + $this->move(); + $expanent = new AFPData(); + $this->doLevelBoolInvert( $expanent ); + if ( $this->mShortCircuit ) { + break; // The result doesn't matter. + } + $result = AFPData::pow( $result, $expanent ); + } + } + + /** + * @param string &$result + */ + protected function doLevelBoolInvert( &$result ) { + if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '!' ) { + $this->move(); + $this->doLevelSpecialWords( $result ); + if ( $this->mShortCircuit ) { + return; // The result doesn't matter. + } + $result = AFPData::boolInvert( $result ); + } else { + $this->doLevelSpecialWords( $result ); + } + } + + /** + * @param string &$result + */ + protected function doLevelSpecialWords( &$result ) { + $this->doLevelUnarys( $result ); + $keyword = strtolower( $this->mCur->value ); + if ( $this->mCur->type == AFPToken::TKEYWORD + && in_array( $keyword, array_keys( self::$mKeywords ) ) + ) { + $func = self::$mKeywords[$keyword]; + $this->move(); + $r2 = new AFPData(); + $this->doLevelUnarys( $r2 ); + + if ( $this->mShortCircuit ) { + return; // The result doesn't matter. + } + + AbuseFilter::triggerLimiter(); + + $result = AFPData::$func( $result, $r2, $this->mCur->pos ); + } + } + + /** + * @param string &$result + */ + protected function doLevelUnarys( &$result ) { + $op = $this->mCur->value; + if ( $this->mCur->type == AFPToken::TOP && ( $op == "+" || $op == "-" ) ) { + $this->move(); + $this->doLevelListElements( $result ); + if ( $this->mShortCircuit ) { + return; // The result doesn't matter. + } + if ( $op == '-' ) { + $result = AFPData::unaryMinus( $result ); + } + } else { + $this->doLevelListElements( $result ); + } + } + + /** + * @param string &$result + * @throws AFPUserVisibleException + */ + protected function doLevelListElements( &$result ) { + $this->doLevelBraces( $result ); + while ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) { + $idx = new AFPData(); + $this->doLevelSemicolon( $idx ); + 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::DLIST ) { + if ( count( $result->data ) <= $idx ) { + throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos, + [ $idx, count( $result->data ) ] ); + } + $result = $result->data[$idx]; + } else { + throw new AFPUserVisibleException( 'notlist', $this->mCur->pos, [] ); + } + $this->move(); + } + } + + /** + * @param string &$result + * @throws AFPUserVisibleException + */ + protected function doLevelBraces( &$result ) { + if ( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == '(' ) { + if ( $this->mShortCircuit ) { + $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(); + } else { + $this->doLevelFunction( $result ); + } + } + + /** + * @param string &$result + * @throws AFPUserVisibleException + */ + protected function doLevelFunction( &$result ) { + if ( $this->mCur->type == AFPToken::TID && isset( self::$mFunctions[$this->mCur->value] ) ) { + $func = self::$mFunctions[$this->mCur->value]; + $this->move(); + 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 ) { + $this->skipOverBraces(); + $this->move(); + + return; // The result doesn't matter. + } + + $args = []; + do { + $r = new AFPData(); + $this->doLevelSemicolon( $r ); + $args[] = $r; + } while ( $this->mCur->type == AFPToken::TCOMMA ); + + if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) { + throw new AFPUserVisibleException( 'expectednotfound', + $this->mCur->pos, + [ + ')', + $this->mCur->type, + $this->mCur->value + ] + ); + } + $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 = []; + } + } else { + $this->doLevelAtom( $result ); + } + } + + /** + * @param string &$result + * @throws AFPUserVisibleException + * @return AFPData + */ + protected function doLevelAtom( &$result ) { + $tok = $this->mCur->value; + switch ( $this->mCur->type ) { + case AFPToken::TID: + if ( $this->mShortCircuit ) { + break; + } + $var = strtolower( $tok ); + $result = $this->getVarValue( $var ); + break; + case AFPToken::TSTRING: + $result = new AFPData( AFPData::DSTRING, $tok ); + break; + case AFPToken::TFLOAT: + $result = new AFPData( AFPData::DFLOAT, $tok ); + break; + case AFPToken::TINT: + $result = new AFPData( AFPData::DINT, $tok ); + break; + case AFPToken::TKEYWORD: + if ( $tok == "true" ) { + $result = new AFPData( AFPData::DBOOL, true ); + } elseif ( $tok == "false" ) { + $result = new AFPData( AFPData::DBOOL, false ); + } elseif ( $tok == "null" ) { + $result = new AFPData(); + } else { + throw new AFPUserVisibleException( + 'unrecognisedkeyword', + $this->mCur->pos, + [ $tok ] + ); + } + break; + case AFPToken::TNONE: + return; // Handled at entry level + case AFPToken::TBRACE: + if ( $this->mCur->value == ')' ) { + return; // Handled at the entry level + } + case AFPToken::TSQUAREBRACKET: + if ( $this->mCur->value == '[' ) { + $list = []; + while ( true ) { + $this->move(); + if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + break; + } + $item = new AFPData(); + $this->doLevelSet( $item ); + $list[] = $item; + if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) { + break; + } + if ( $this->mCur->type != AFPToken::TCOMMA ) { + throw new AFPUserVisibleException( + 'expectednotfound', + $this->mCur->pos, + [ ', or ]', $this->mCur->type, $this->mCur->value ] + ); + } + } + $result = new AFPData( AFPData::DLIST, $list ); + break; + } + default: + throw new AFPUserVisibleException( + 'unexpectedtoken', + $this->mCur->pos, + [ + $this->mCur->type, + $this->mCur->value + ] + ); + } + $this->move(); + } + + /* End of levels */ + + /** + * @param string $var + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function getVarValue( $var ) { + $var = strtolower( $var ); + $builderValues = AbuseFilter::getBuilderValues(); + if ( !( array_key_exists( $var, $builderValues['vars'] ) + || $this->mVars->varIsSet( $var ) ) + ) { + // If the variable is invalid, throw an exception + throw new AFPUserVisibleException( + 'unrecognisedvar', + $this->mCur->pos, + [ $var ] + ); + } else { + return $this->mVars->getVar( $var ); + } + } + + /** + * @param string $name + * @param string $value + * @throws AFPUserVisibleException + */ + protected function setUserVariable( $name, $value ) { + $builderValues = AbuseFilter::getBuilderValues(); + if ( array_key_exists( $name, $builderValues['vars'] ) ) { + throw new AFPUserVisibleException( 'overridebuiltin', $this->mCur->pos, [ $name ] ); + } + $this->mVars->setVar( $name, $value ); + } + + // Built-in functions + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcLc( $args ) { + global $wgContLang; + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'lc', 2, count( $args ) ] + ); + } + $s = $args[0]->toString(); + + return new AFPData( AFPData::DSTRING, $wgContLang->lc( $s ) ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcUc( $args ) { + global $wgContLang; + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'uc', 2, count( $args ) ] + ); + } + $s = $args[0]->toString(); + + return new AFPData( AFPData::DSTRING, $wgContLang->uc( $s ) ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcLen( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'len', 2, count( $args ) ] + ); + } + if ( $args[0]->type == AFPData::DLIST ) { + // Don't use toString on lists, but count + return new AFPData( AFPData::DINT, count( $args[0]->data ) ); + } + $s = $args[0]->toString(); + + return new AFPData( AFPData::DINT, mb_strlen( $s, 'utf-8' ) ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcSimpleNorm( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'simplenorm', 2, count( $args ) ] + ); + } + $s = $args[0]->toString(); + + $s = preg_replace( '/[\d\W]+/', '', $s ); + $s = strtolower( $s ); + + return new AFPData( AFPData::DSTRING, $s ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcSpecialRatio( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'specialratio', 1, count( $args ) ] + ); + } + $s = $args[0]->toString(); + + if ( !strlen( $s ) ) { + return new AFPData( AFPData::DFLOAT, 0 ); + } + + $nospecials = $this->rmspecials( $s ); + + $val = 1. - ( ( mb_strlen( $nospecials ) / mb_strlen( $s ) ) ); + + return new AFPData( AFPData::DFLOAT, $val ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcCount( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'count', 1, count( $args ) ] + ); + } + + if ( $args[0]->type == AFPData::DLIST && count( $args ) == 1 ) { + return new AFPData( AFPData::DINT, count( $args[0]->data ) ); + } + + if ( count( $args ) == 1 ) { + $count = count( explode( ',', $args[0]->toString() ) ); + } else { + $needle = $args[0]->toString(); + $haystack = $args[1]->toString(); + + // T62203: Keep empty parameters from causing PHP warnings + if ( $needle === '' ) { + $count = 0; + } else { + $count = substr_count( $haystack, $needle ); + } + } + + return new AFPData( AFPData::DINT, $count ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + * @throws Exception + */ + protected function funcRCount( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'rcount', 1, count( $args ) ] + ); + } + + if ( count( $args ) == 1 ) { + $count = count( explode( ',', $args[0]->toString() ) ); + } else { + $needle = $args[0]->toString(); + $haystack = $args[1]->toString(); + + # Munge the regex + $needle = preg_replace( '!(\\\\\\\\)*(\\\\)?/!', '$1\/', $needle ); + $needle = "/$needle/u"; + + // Omit the '$matches' argument to avoid computing them, just count. + $count = preg_match_all( $needle, $haystack ); + + if ( $count === false ) { + throw new AFPUserVisibleException( + 'regexfailure', + $this->mCur->pos, + [ 'unspecified error in preg_match_all()', $needle ] + ); + } + } + + return new AFPData( AFPData::DINT, $count ); + } + + /** + * Returns an array of matches of needle in the haystack, the first one for the whole regex, + * the other ones for every capturing group. + * + * @param array $args + * @return AFPData A list of matches. + * @throws AFPUserVisibleException + */ + protected function funcGetMatches( $args ) { + if ( count( $args ) < 2 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'get_matches', 2, count( $args ) ] + ); + } + $needle = $args[0]->toString(); + $haystack = $args[1]->toString(); + + // Count the amount of capturing groups in the submitted pattern. + // This way we can return a fixed-dimension array, much easier to manage. + // First, strip away escaped parentheses + $sanitized = preg_replace( '/(\\\\\\\\)*\\\\\(/', '', $needle ); + // Then strip starting parentheses of non-capturing groups + // (also atomics, lookahead and so on, even if not every of them is supported) + $sanitized = preg_replace( '/\(\?/', '', $sanitized ); + // Finally create an array of falses with dimension = # of capturing groups + $groupscount = substr_count( $sanitized, '(' ) + 1; + $falsy = array_fill( 0, $groupscount, false ); + + // Munge the regex by escaping slashes + $needle = preg_replace( '!(\\\\\\\\)*(\\\\)?/!', '$1\/', $needle ); + $needle = "/$needle/u"; + + // Suppress and restore are here for the same reason as T177744 + Wikimedia\suppressWarnings(); + $check = preg_match( $needle, $haystack, $matches ); + Wikimedia\restoreWarnings(); + + if ( $check === false ) { + throw new AFPUserVisibleException( + 'regexfailure', + $this->mCur->pos, + [ 'unspecified error in preg_match()', $needle ] + ); + } + + // Returned array has non-empty positions identical to the ones returned + // by the third parameter of a standard preg_match call ($matches in this case). + // We want an union with falsy to return a fixed-dimention array. + return AFPData::newFromPHPVar( $matches + $falsy ); + } + + /** + * @param array $args + * @return AFPData + * @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(); + + $result = IP::isInRange( $ip, $range ); + + return new AFPData( AFPData::DBOOL, $result ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcCCNorm( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'ccnorm', 1, count( $args ) ] + ); + } + $s = $args[0]->toString(); + + $s = html_entity_decode( $s, ENT_QUOTES, 'UTF-8' ); + $s = $this->ccnorm( $s ); + + return new AFPData( AFPData::DSTRING, $s ); + } + + /** + * @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 ) ); + } + + /** + * @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 ) ); + } + + /** + * Normalize and search a string for multiple substrings in OR mode + * + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcCCNormContainsAny( $args ) { + if ( count( $args ) < 2 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'ccnorm_contains_any', 2, count( $args ) ] + ); + } + + $s = array_shift( $args ); + + return new AFPData( AFPData::DBOOL, self::contains( $s, $args, true, true ) ); + } + + /** + * Normalize and search a string for multiple substrings in AND mode + * + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcCCNormContainsAll( $args ) { + if ( count( $args ) < 2 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'ccnorm_contains_all', 2, count( $args ) ] + ); + } + + $s = array_shift( $args ); + + return new AFPData( AFPData::DBOOL, self::contains( $s, $args, false, true ) ); + } + + /** + * Search for substrings in a string + * + * Use is_any to determine wether to use logic OR (true) or AND (false). + * + * Use normalize = true to make use of ccnorm and + * normalize both sides of the search. + * + * @param AFPData $string + * @param AFPData[] $values + * @param bool $is_any + * @param bool $normalize + * + * @return bool + */ + protected static function contains( $string, $values, $is_any = true, $normalize = false ) { + $string = $string->toString(); + if ( $string == '' ) { + return false; + } + + if ( $normalize ) { + $string = self::ccnorm( $string ); + } + + foreach ( $values as $needle ) { + $needle = $needle->toString(); + if ( $normalize ) { + $needle = self::ccnorm( $needle ); + } + if ( $needle === '' ) { + // T62203: Keep empty parameters from causing PHP warnings + continue; + } + + $is_found = strpos( $string, $needle ) !== false; + if ( $is_found === $is_any ) { + // If I'm here and it's ANY (OR) it means that something is found. + // Just enough! Found! + // If I'm here and it's ALL (AND) it means that something isn't found. + // Just enough! Not found! + return $is_found; + } + } + + // If I'm here and it's ANY (OR) it means that nothing was found: + // return false (because $is_any is true) + // If I'm here and it's ALL (AND) it means that everything were found: + // return true (because $is_any is false) + return ! $is_any; + } + + /** + * @param string $s + * @return mixed + */ + protected static function ccnorm( $s ) { + // Instatiate a single version of the equivset so the data is not loaded + // more than once. + if ( !self::$equivset ) { + self::$equivset = new Equivset(); + } + + return self::$equivset->normalize( $s ); + } + + /** + * @param string $s + * @return array|string + */ + protected function rmspecials( $s ) { + return preg_replace( '/[^\p{L}\p{N}]/u', '', $s ); + } + + /** + * @param string $s + * @return array|string + */ + protected function rmdoubles( $s ) { + return preg_replace( '/(.)\1+/us', '\1', $s ); + } + + /** + * @param string $s + * @return array|string + */ + protected function rmwhitespace( $s ) { + return preg_replace( '/\s+/u', '', $s ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcRMSpecials( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'rmspecials', 1, count( $args ) ] + ); + } + $s = $args[0]->toString(); + + $s = $this->rmspecials( $s ); + + return new AFPData( AFPData::DSTRING, $s ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcRMWhitespace( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'rmwhitespace', 1, count( $args ) ] + ); + } + $s = $args[0]->toString(); + + $s = $this->rmwhitespace( $s ); + + return new AFPData( AFPData::DSTRING, $s ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcRMDoubles( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'rmdoubles', 1, count( $args ) ] + ); + } + $s = $args[0]->toString(); + + $s = $this->rmdoubles( $s ); + + return new AFPData( AFPData::DSTRING, $s ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcNorm( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( + 'notenoughargs', + $this->mCur->pos, + [ 'norm', 1, count( $args ) ] + ); + } + $s = $args[0]->toString(); + + $s = $this->ccnorm( $s ); + $s = $this->rmdoubles( $s ); + $s = $this->rmspecials( $s ); + $s = $this->rmwhitespace( $s ); + + return new AFPData( AFPData::DSTRING, $s ); + } + + /** + * @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(); + + if ( isset( $args[2] ) ) { + $length = $args[2]->toInt(); + + $result = mb_substr( $s, $offset, $length ); + } else { + $result = mb_substr( $s, $offset ); + } + + return new AFPData( AFPData::DSTRING, $result ); + } + + /** + * @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(); + + // 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 ); + } + + if ( $result === false ) { + $result = -1; + } + + return new AFPData( AFPData::DINT, $result ); + } + + /** + * @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(); + + return new AFPData( AFPData::DSTRING, str_replace( $search, $replace, $subject ) ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function funcStrRegexEscape( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( 'notenoughargs', $this->mCur->pos, + [ 'rescape', 1, count( $args ) ] ); + } + + $string = $args[0]->toString(); + + // preg_quote does not need the second parameter, since rlike takes + // care of the delimiter symbol itself + return new AFPData( AFPData::DSTRING, preg_quote( $string ) ); + } + + /** + * @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]; + + $this->setUserVariable( $varName, $value ); + + return $value; + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function castString( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( 'noparams', $this->mCur->pos, [ __METHOD__ ] ); + } + $val = $args[0]; + + return AFPData::castTypes( $val, AFPData::DSTRING ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function castInt( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( 'noparams', $this->mCur->pos, [ __METHOD__ ] ); + } + $val = $args[0]; + + return AFPData::castTypes( $val, AFPData::DINT ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function castFloat( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( 'noparams', $this->mCur->pos, [ __METHOD__ ] ); + } + $val = $args[0]; + + return AFPData::castTypes( $val, AFPData::DFLOAT ); + } + + /** + * @param array $args + * @return AFPData + * @throws AFPUserVisibleException + */ + protected function castBool( $args ) { + if ( count( $args ) < 1 ) { + throw new AFPUserVisibleException( 'noparams', $this->mCur->pos, [ __METHOD__ ] ); + } + $val = $args[0]; + + return AFPData::castTypes( $val, AFPData::DBOOL ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterTokenizer.php b/www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterTokenizer.php new file mode 100644 index 00000000..a97fccaf --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterTokenizer.php @@ -0,0 +1,258 @@ +<?php + +use MediaWiki\MediaWikiServices; + +/** + * 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 = + '/(\!\=\=|\!\=|\!|\*\*|\*|\/|\+|\-|%|&|\||\^|\:\=|\?|\:|\<\=|\<|\>\=|\>|\=\=\=|\=\=|\=)/A'; + const RADIX_RE = '/([0-9A-Fa-f]+(?:\.\d*)?|\.\d+)([bxo])?/Au'; + 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 = [ + '!==', '!=', '!', // Inequality + '**', '*', // Multiplication/exponentiation + '/', '+', '-', '%', // Other arithmetic + '&', '|', '^', // Logic + ':=', // Setting + '?', ':', // Ternery + '<=', '<', // Less than + '>=', '>', // Greater than + '===', '==', '=', // Equality + ]; + + public static $punctuation = [ + ',' => AFPToken::TCOMMA, + '(' => AFPToken::TBRACE, + ')' => AFPToken::TBRACE, + '[' => AFPToken::TSQUAREBRACKET, + ']' => AFPToken::TSQUAREBRACKET, + ';' => AFPToken::TSTATEMENTSEPARATOR, + ]; + + public static $bases = [ + 'b' => 2, + 'x' => 16, + 'o' => 8 + ]; + + public static $baseCharsRe = [ + 2 => '/^[01]+$/', + 8 => '/^[0-8]+$/', + 16 => '/^[0-9A-Fa-f]+$/', + 10 => '/^[0-9.]+$/', + ]; + + public static $keywords = [ + 'in', 'like', 'true', 'false', 'null', 'contains', 'matches', + 'rlike', 'irlike', 'regex', 'if', 'then', 'else', 'end', + ]; + + /** + * @param string $code + * @return array + * @throws AFPException + * @throws AFPUserVisibleException + */ + static function tokenize( $code ) { + static $tokenizerCache = null; + + if ( !$tokenizerCache ) { + $tokenizerCache = ObjectCache::getLocalServerInstance( 'hash' ); + } + + static $stats = null; + + if ( !$stats ) { + $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); + } + + $cacheKey = wfGlobalCacheKey( __CLASS__, self::CACHE_VERSION, crc32( $code ) ); + + $tokens = $tokenizerCache->get( $cacheKey ); + + if ( $tokens ) { + $stats->increment( 'AbuseFilter.tokenizerCache.hit' ); + return $tokens; + } + + $stats->increment( 'AbuseFilter.tokenizerCache.miss' ); + $tokens = []; + $curPos = 0; + + do { + $prevPos = $curPos; + $token = self::nextToken( $code, $curPos ); + $tokens[ $token->pos ] = [ $token, $curPos ]; + } while ( $curPos !== $prevPos ); + + $tokenizerCache->set( $cacheKey, $tokens, 60 * 60 * 24 ); + + return $tokens; + } + + /** + * @param string $code + * @param int &$offset + * @return AFPToken + * @throws AFPException + * @throws AFPUserVisibleException + */ + protected static function nextToken( $code, &$offset ) { + $matches = []; + $start = $offset; + + // Read past comments + while ( preg_match( self::COMMENT_START_RE, $code, $matches, 0, $offset ) ) { + if ( strpos( $code, '*/', $offset ) === false ) { + throw new AFPUserVisibleException( + 'unclosedcomment', $offset, [] ); + } + $offset = strpos( $code, '*/', $offset ) + 2; + } + + // Spaces + $offset += strspn( $code, self::WHITESPACE, $offset ); + if ( $offset >= strlen( $code ) ) { + return new AFPToken( AFPToken::TNONE, '', $start ); + } + + $chr = $code[$offset]; + + // Punctuation + if ( isset( self::$punctuation[$chr] ) ) { + $offset++; + return new AFPToken( self::$punctuation[$chr], $chr, $start ); + } + + // String literal + if ( $chr === '"' || $chr === "'" ) { + return self::readStringLiteral( $code, $offset, $start ); + } + + $matches = []; + + // Operators + if ( preg_match( self::OPERATOR_RE, $code, $matches, 0, $offset ) ) { + $token = $matches[0]; + $offset += strlen( $token ); + return new AFPToken( AFPToken::TOP, $token, $start ); + } + + // Numbers + if ( preg_match( self::RADIX_RE, $code, $matches, 0, $offset ) ) { + $token = $matches[0]; + $input = $matches[1]; + $baseChar = isset( $matches[2] ) ? $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 ) ) { + $num = $base !== 10 ? base_convert( $input, $base, 10 ) : $input; + $offset += strlen( $token ); + return ( strpos( $input, '.' ) !== false ) + ? new AFPToken( AFPToken::TFLOAT, floatval( $num ), $start ) + : new AFPToken( AFPToken::TINT, intval( $num ), $start ); + } + } + + // IDs / Keywords + + if ( preg_match( self::ID_SYMBOL_RE, $code, $matches, 0, $offset ) ) { + $token = $matches[0]; + $offset += strlen( $token ); + $type = in_array( $token, self::$keywords ) + ? AFPToken::TKEYWORD + : AFPToken::TID; + return new AFPToken( $type, $token, $start ); + } + + throw new AFPUserVisibleException( + 'unrecognisedtoken', $start, [ substr( $code, $start ) ] ); + } + + /** + * @param string $code + * @param int &$offset + * @param int $start + * @return AFPToken + * @throws AFPException + * @throws AFPUserVisibleException + */ + protected static function readStringLiteral( $code, &$offset, $start ) { + $type = $code[$offset]; + $offset++; + $length = strlen( $code ); + $token = ''; + while ( $offset < $length ) { + if ( $code[$offset] === $type ) { + $offset++; + return new AFPToken( AFPToken::TSTRING, $token, $start ); + } + + // Performance: Use a PHP function (implemented in C) + // to scan ahead. + $addLength = strcspn( $code, $type . "\\", $offset ); + if ( $addLength ) { + $token .= substr( $code, $offset, $addLength ); + $offset += $addLength; + } elseif ( $code[$offset] == '\\' ) { + switch ( $code[$offset + 1] ) { + case '\\': + $token .= '\\'; + break; + case $type: + $token .= $type; + break; + case 'n'; + $token .= "\n"; + break; + case 'r': + $token .= "\r"; + break; + case 't': + $token .= "\t"; + break; + case 'x': + $chr = substr( $code, $offset + 2, 2 ); + + if ( preg_match( '/^[0-9A-Fa-f]{2}$/', $chr ) ) { + $chr = base_convert( $chr, 16, 10 ); + $token .= chr( $chr ); + $offset += 2; # \xXX -- 2 done later + } else { + $token .= 'x'; + } + break; + default: + $token .= "\\" . $code[$offset + 1]; + } + + $offset += 2; + + } else { + $token .= $code[$offset]; + $offset++; + } + } + throw new AFPUserVisibleException( 'unclosedstring', $offset, [] ); + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/special/SpecialAbuseFilter.php b/www/wiki/extensions/AbuseFilter/includes/special/SpecialAbuseFilter.php new file mode 100644 index 00000000..1a4cbf4d --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/special/SpecialAbuseFilter.php @@ -0,0 +1,134 @@ +<?php + +class SpecialAbuseFilter extends SpecialPage { + public $mFilter, $mHistoryID; + + public function __construct() { + parent::__construct( 'AbuseFilter', 'abusefilter-view' ); + } + + public function doesWrites() { + return true; + } + + public function execute( $subpage ) { + $out = $this->getOutput(); + $request = $this->getRequest(); + + $out->addModuleStyles( 'ext.abuseFilter' ); + $view = 'AbuseFilterViewList'; + + $this->setHeaders(); + + $this->loadParameters( $subpage ); + $out->setPageTitle( $this->msg( 'abusefilter-management' ) ); + + // Are we allowed? + $this->checkPermissions(); + + if ( $request->getVal( 'result' ) == 'success' ) { + $out->setSubtitle( $this->msg( 'abusefilter-edit-done-subtitle' ) ); + $changedFilter = intval( $request->getVal( 'changedfilter' ) ); + $changeId = intval( $request->getVal( 'changeid' ) ); + $out->wrapWikiMsg( '<p class="success">$1</p>', + [ + 'abusefilter-edit-done', + $changedFilter, + $changeId, + $this->getLanguage()->formatNum( $changedFilter ) + ] + ); + } + + $this->mHistoryID = null; + $pageType = 'home'; + + $params = explode( '/', $subpage ); + + // Filter by removing blanks. + foreach ( $params as $index => $param ) { + if ( $param === '' ) { + unset( $params[$index] ); + } + } + $params = array_values( $params ); + + if ( $subpage == 'tools' ) { + $view = 'AbuseFilterViewTools'; + $pageType = 'tools'; + $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); + } + + 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' ) { + $view = 'AbuseFilterViewTestBatch'; + $pageType = 'test'; + $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); + } + + 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' ) ) { + $pageType = ''; + if ( count( $params ) == 1 ) { + $view = 'AbuseFilterViewHistory'; + $pageType = 'recentchanges'; + } elseif ( count( $params ) == 2 ) { + # Second param is a filter ID + $view = 'AbuseFilterViewHistory'; + $this->mFilter = $params[1]; + } elseif ( count( $params ) == 4 && $params[2] == 'item' ) { + $this->mFilter = $params[1]; + $this->mHistoryID = $params[3]; + $view = 'AbuseFilterViewEdit'; + } elseif ( count( $params ) == 5 && $params[2] == 'diff' ) { + // Special:AbuseFilter/history/<filter>/diff/<oldid>/<newid> + $view = 'AbuseFilterViewDiff'; + } + } + + if ( is_numeric( $subpage ) || $subpage == 'new' ) { + $this->mFilter = $subpage; + $view = 'AbuseFilterViewEdit'; + $pageType = 'edit'; + } + + if ( $subpage == 'import' ) { + $view = 'AbuseFilterViewImport'; + $pageType = 'import'; + } + + // Links at the top + AbuseFilter::addNavigationLinks( + $this->getContext(), $pageType, $this->getLinkRenderer() ); + + /** @var AbuseFilterView $v */ + $v = new $view( $this, $params ); + $v->show(); + } + + function loadParameters( $subpage ) { + $filter = $subpage; + + if ( !is_numeric( $filter ) && $filter != 'new' ) { + $filter = $this->getRequest()->getIntOrNull( 'wpFilter' ); + } + $this->mFilter = $filter; + } + + /** + * @return string + */ + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/www/wiki/extensions/AbuseFilter/includes/special/SpecialAbuseLog.php b/www/wiki/extensions/AbuseFilter/includes/special/SpecialAbuseLog.php new file mode 100644 index 00000000..b1d9e410 --- /dev/null +++ b/www/wiki/extensions/AbuseFilter/includes/special/SpecialAbuseLog.php @@ -0,0 +1,1049 @@ +<?php + +class SpecialAbuseLog extends SpecialPage { + /** + * @var User + */ + protected $mSearchUser; + + /** + * @var Title + */ + protected $mSearchTitle; + + /** + * @var string + */ + protected $mSearchActionTaken; + + protected $mSearchWiki; + + protected $mSearchFilter; + + protected $mSearchEntries; + + protected $mSearchImpact; + + public function __construct() { + parent::__construct( 'AbuseLog', 'abusefilter-log' ); + } + + public function doesWrites() { + return true; + } + + /** + * Main routine + * + * $parameter string is converted into the $args array, which can come in + * three shapes: + * + * An array of size 2: only if the URL is like Special:AbuseLog/private/id + * where id is the log identifier. In this case, the private details of the + * log (e.g. IP address) will be shown. + * + * An array of size 1: either the URL is like Special:AbuseLog/id where + * the id is log identifier, in which case the details of the log except for + * private bits (e.g. IP address) are shown, or the URL is incomplete as in + * Special:AbuseLog/private (without speciying id), in which case a warning + * is shown to the user + * + * An array of size 0 when URL is like Special:AbuseLog or an array of size + * 1 when the URL is like Special:AbuseFilter/ (i.e. without anything after + * the slash). In this case, if the parameter `hide` was passed, it will be + * used as the identifier of the log entry that we want to hide; otherwise, + * the abuse logs are shown as a list, with a search form above the list. + * + * @param string $parameter URL parameters + */ + public function execute( $parameter ) { + $out = $this->getOutput(); + $request = $this->getRequest(); + + AbuseFilter::addNavigationLinks( + $this->getContext(), 'log', $this->getLinkRenderer() ); + + $this->setHeaders(); + $this->outputHeader( 'abusefilter-log-summary' ); + $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 ) ) { + // Go away. + $out->showPermissionsErrorPage( $errors, 'abusefilter-log' ); + + return; + } + + $detailsid = $request->getIntOrNull( 'details' ); + $hideid = $request->getIntOrNull( 'hide' ); + $args = explode( '/', $parameter ); + + if ( count( $args ) === 2 && $args[0] === 'private' ) { + $this->showPrivateDetails( $args[1] ); + } elseif ( count( $args ) === 1 && $args[0] !== '' ) { + if ( $args[0] === 'private' ) { + $out->addWikiMsg( 'abusefilter-invalid-request-noid' ); + } else { + $this->showDetails( $args[0] ); + } + } else { + if ( $hideid ) { + $this->showHideForm( $hideid ); + } else { + $this->searchForm(); + $this->showList(); + } + } + } + + function loadParameters() { + global $wgAbuseFilterIsCentral; + + $request = $this->getRequest(); + + $this->mSearchUser = trim( $request->getText( 'wpSearchUser' ) ); + if ( $wgAbuseFilterIsCentral ) { + $this->mSearchWiki = $request->getText( 'wpSearchWiki' ); + } + + $u = User::newFromName( $this->mSearchUser ); + if ( $u ) { + $this->mSearchUser = $u->getName(); // Username normalisation + } elseif ( IP::isIPAddress( $this->mSearchUser ) ) { + // It's an IP + $this->mSearchUser = IP::sanitizeIP( $this->mSearchUser ); + } else { + $this->mSearchUser = null; + } + + $this->mSearchTitle = $request->getText( 'wpSearchTitle' ); + $this->mSearchFilter = null; + $this->mSearchActionTaken = $request->getText( 'wpSearchActionTaken' ); + if ( self::canSeeDetails() ) { + $this->mSearchFilter = $request->getText( 'wpSearchFilter' ); + } + + $this->mSearchEntries = $request->getText( 'wpSearchEntries' ); + $this->mSearchImpact = $request->getText( 'wpSearchImpact' ); + } + + /** + * @return string[] + */ + private function getAllActions() { + global $wgAbuseFilterActions, $wgAbuseFilterCustomActionsHandlers; + return array_unique( + array_merge( + array_keys( $wgAbuseFilterActions ), + array_keys( $wgAbuseFilterCustomActionsHandlers ) + ) + ); + } + + function searchForm() { + global $wgAbuseFilterIsCentral; + + $formDescriptor = [ + 'SearchUser' => [ + 'label-message' => 'abusefilter-log-search-user', + 'type' => 'user', + 'default' => $this->mSearchUser, + ], + 'SearchTitle' => [ + 'label-message' => 'abusefilter-log-search-title', + 'type' => 'title', + 'default' => $this->mSearchTitle, + ], + 'SearchImpact' => [ + 'label-message' => 'abusefilter-log-search-impact', + 'type' => 'select', + 'options' => [ + $this->msg( 'abusefilter-log-search-impact-all' )->text() => 0, + $this->msg( 'abusefilter-log-search-impact-saved' )->text() => 1, + $this->msg( 'abusefilter-log-search-impact-not-saved' )->text() => 2, + ], + ], + ]; + $options = [ + $this->msg( 'abusefilter-log-noactions' )->text() => 'noactions', + $this->msg( 'abusefilter-log-search-action-taken-any' )->text() => '', + ]; + foreach ( $this->getAllActions() as $action ) { + $key = AbuseFilter::getActionDisplay( $action ); + $options[$key] = $action; + } + ksort( $options ); + $formDescriptor['SearchActionTaken'] = [ + 'label-message' => 'abusefilter-log-search-action-taken-label', + 'type' => 'select', + 'options' => $options, + ]; + if ( self::canSeeHidden() ) { + $formDescriptor['SearchEntries'] = [ + 'type' => 'select', + 'label-message' => 'abusefilter-log-search-entries-label', + 'options' => [ + $this->msg( 'abusefilter-log-search-entries-all' )->text() => 0, + $this->msg( 'abusefilter-log-search-entries-hidden' )->text() => 1, + $this->msg( 'abusefilter-log-search-entries-visible' )->text() => 2, + ], + ]; + } + if ( self::canSeeDetails() ) { + $formDescriptor['SearchFilter'] = [ + 'label-message' => 'abusefilter-log-search-filter', + 'type' => 'text', + 'default' => $this->mSearchFilter, + ]; + } + if ( $wgAbuseFilterIsCentral ) { + // 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', + 'type' => 'text', + 'default' => $this->mSearchWiki, + ]; + } + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + ->setWrapperLegendMsg( 'abusefilter-log-search' ) + ->setSubmitTextMsg( 'abusefilter-log-search-submit' ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * @param string $id + * @return mixed + */ + function showHideForm( $id ) { + if ( !$this->getUser()->isAllowed( 'abusefilter-hide-log' ) ) { + $this->getOutput()->addWikiMsg( 'abusefilter-log-hide-forbidden' ); + + return; + } + + $dbr = wfGetDB( DB_REPLICA ); + + $row = $dbr->selectRow( + [ 'abuse_filter_log', 'abuse_filter' ], + 'afl_deleted', + [ 'afl_id' => $id ], + __METHOD__, + [], + [ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ] + ); + + if ( !$row ) { + return; + } + + $hideReasonsOther = $this->msg( 'revdelete-reasonotherlist' )->text(); + $hideReasons = $this->msg( 'revdelete-reason-dropdown' )->text(); + $hideReasons = Xml::listDropDownOptions( $hideReasons, [ 'other' => $hideReasonsOther ] ); + + $formInfo = [ + 'logid' => [ + 'type' => 'info', + 'default' => (string)$id, + 'label-message' => 'abusefilter-log-hide-id', + ], + 'dropdownreason' => [ + 'type' => 'select', + 'options' => $hideReasons, + 'label-message' => 'abusefilter-log-hide-reason' + ], + 'reason' => [ + 'type' => 'text', + 'label-message' => 'abusefilter-log-hide-reason-other', + ], + 'hidden' => [ + 'type' => 'toggle', + 'default' => $row->afl_deleted, + 'label-message' => 'abusefilter-log-hide-hidden', + ], + ]; + + HTMLForm::factory( 'ooui', $formInfo, $this->getContext() ) + ->setTitle( $this->getPageTitle() ) + ->setWrapperLegend( $this->msg( 'abusefilter-log-hide-legend' )->text() ) + ->addHiddenField( 'hide', $id ) + ->setSubmitCallback( [ $this, 'saveHideForm' ] ) + ->show(); + } + + /** + * @param array $fields + * @return bool + */ + function saveHideForm( $fields ) { + $logid = $this->getRequest()->getVal( 'hide' ); + + $dbw = wfGetDB( DB_MASTER ); + + $dbw->update( + 'abuse_filter_log', + [ 'afl_deleted' => $fields['hidden'] ], + [ 'afl_id' => $logid ], + __METHOD__ + ); + + $reason = $fields['dropdownreason']; + if ( $reason === 'other' ) { + $reason = $fields['reason']; + } elseif ( $fields['reason'] !== '' ) { + $reason .= + $this->msg( 'colon-separator' )->inContentLanguage()->text() . $fields['reason']; + } + + $logPage = new LogPage( 'suppress' ); + $action = $fields['hidden'] ? 'hide-afl' : 'unhide-afl'; + + $logPage->addEntry( $action, $this->getPageTitle( $logid ), $reason ); + + $this->getOutput()->redirect( SpecialPage::getTitleFor( 'AbuseLog' )->getFullURL() ); + + return true; + } + + function showList() { + $out = $this->getOutput(); + + // Generate conditions list. + $conds = []; + + if ( $this->mSearchUser ) { + $user = User::newFromName( $this->mSearchUser ); + + if ( !$user ) { + $conds['afl_user'] = 0; + $conds['afl_user_text'] = $this->mSearchUser; + } else { + $conds['afl_user'] = $user->getId(); + $conds['afl_user_text'] = $user->getName(); + } + } + + if ( $this->mSearchWiki ) { + if ( $this->mSearchWiki == wfWikiID() ) { + $conds['afl_wiki'] = null; + } else { + $conds['afl_wiki'] = $this->mSearchWiki; + } + } + + if ( $this->mSearchFilter ) { + $searchFilters = array_map( 'trim', explode( '|', $this->mSearchFilter ) ); + // 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' ) + ) { + $searchedForPrivate = false; + foreach ( $searchFilters as $index => $filter ) { + if ( AbuseFilter::filterHidden( $filter ) ) { + unset( $searchFilters[$index] ); + $searchedForPrivate = true; + } + } + if ( $searchedForPrivate ) { + $out->addWikiMsg( 'abusefilter-log-private-not-included' ); + } + } + if ( empty( $searchFilters ) ) { + $out->addWikiMsg( 'abusefilter-log-noresults' ); + + return; + } + $conds['afl_filter'] = $searchFilters; + } + + $searchTitle = Title::newFromText( $this->mSearchTitle ); + if ( $this->mSearchTitle && $searchTitle ) { + $conds['afl_namespace'] = $searchTitle->getNamespace(); + $conds['afl_title'] = $searchTitle->getDBkey(); + } + + $dbr = wfGetDB( DB_REPLICA ); + if ( self::canSeeHidden() ) { + if ( $this->mSearchEntries == '1' ) { + $conds['afl_deleted'] = 1; + } elseif ( $this->mSearchEntries == '2' ) { + $conds[] = self::getNotDeletedCond( $dbr ); + } + } + + if ( in_array( $this->mSearchImpact, [ '1', '2' ] ) ) { + $unsuccessfulActionConds = $dbr->makeList( [ + 'afl_rev_id' => null, + 'afl_log_id' => null, + ], LIST_AND ); + if ( $this->mSearchImpact == '1' ) { + $conds[] = "NOT ( $unsuccessfulActionConds )"; + } else { + $conds[] = $unsuccessfulActionConds; + } + } + + if ( $this->mSearchActionTaken ) { + if ( in_array( $this->mSearchActionTaken, $this->getAllActions() ) ) { + $list = [ 'afl_actions' => $this->mSearchActionTaken ]; + $list[] = 'afl_actions' . $dbr->buildLike( + $this->mSearchActionTaken, ',', $dbr->anyString() ); + $list[] = 'afl_actions' . $dbr->buildLike( + $dbr->anyString(), ',', $this->mSearchActionTaken ); + $list[] = 'afl_actions' . $dbr->buildLike( + $dbr->anyString(), + ',', $this->mSearchActionTaken, ',', + $dbr->anyString() + ); + $conds[] = $dbr->makeList( $list, LIST_OR ); + } elseif ( $this->mSearchActionTaken === 'noactions' ) { + $conds['afl_actions'] = ''; + } + } + + $pager = new AbuseLogPager( $this, $conds ); + $pager->doQuery(); + $result = $pager->getResult(); + if ( $result && $result->numRows() !== 0 ) { + $out->addHTML( $pager->getNavigationBar() . + Xml::tags( 'ul', [ 'class' => 'plainlinks' ], $pager->getBody() ) . + $pager->getNavigationBar() ); + } else { + $out->addWikiMsg( 'abusefilter-log-noresults' ); + } + } + + /** + * @param string $id + * @return mixed + */ + function showDetails( $id ) { + $out = $this->getOutput(); + + $dbr = wfGetDB( DB_REPLICA ); + + $row = $dbr->selectRow( + [ 'abuse_filter_log', 'abuse_filter' ], + '*', + [ 'afl_id' => $id ], + __METHOD__, + [], + [ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ] + ); + + if ( !$row ) { + $out->addWikiMsg( 'abusefilter-log-nonexistent' ); + + return; + } + + if ( AbuseFilter::decodeGlobalName( $row->afl_filter ) ) { + $filter_hidden = null; + } else { + $filter_hidden = $row->af_hidden; + } + + if ( !self::canSeeDetails( $row->afl_filter, $filter_hidden ) ) { + $out->addWikiMsg( 'abusefilter-log-cannot-see-details' ); + + return; + } + + if ( self::isHidden( $row ) && !self::canSeeHidden() ) { + $out->addWikiMsg( 'abusefilter-log-details-hidden' ); + + 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( + 'legend', + null, + $this->msg( 'abusefilter-log-details-legend' ) + ->numParams( $id ) + ->text() + ); + $output .= Xml::tags( 'p', null, $this->formatRow( $row, false ) ); + + // Load data + $vars = AbuseFilter::loadVarDump( $row->afl_var_dump ); + $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) ); + + // Diff, if available + if ( $vars && $vars->getVar( 'action' )->toString() == 'edit' ) { + $old_wikitext = $vars->getVar( 'old_wikitext' )->toString(); + $new_wikitext = $vars->getVar( 'new_wikitext' )->toString(); + + $diffEngine = new DifferenceEngine( $this->getContext() ); + + $diffEngine->showDiffStyle(); + + $formattedDiff = $diffEngine->generateTextDiffBody( $old_wikitext, $new_wikitext ); + $formattedDiff = $diffEngine->addHeader( $formattedDiff, '', '' ); + + $output .= + Xml::tags( + 'h3', + null, + $this->msg( 'abusefilter-log-details-diff' )->parse() + ); + + $output .= $formattedDiff; + } + + $output .= Xml::element( 'h3', null, $this->msg( 'abusefilter-log-details-vars' )->text() ); + + // Build a table. + $output .= AbuseFilter::buildVarDumpTable( $vars, $this->getContext() ); + + if ( self::canSeePrivate() ) { + $formDescriptor = [ + 'Reason' => [ + 'label-message' => 'abusefilter-view-private-reason', + 'type' => 'text', + 'size' => 45, + ], + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm->setWrapperLegendMsg( 'abusefilter-view-private' ) + ->setAction( $this->getPageTitle( 'private/' . $id )->getLocalURL() ) + ->setSubmitTextMsg( 'abusefilter-view-private-submit' ) + ->setMethod( 'post' ) + ->prepareForm(); + + $output .= $htmlForm->getHTML( false ); + } + + $output = Xml::tags( 'fieldset', null, $output ); + + $out->addHTML( $output ); + } + + /** + * @param string $id + * @return null + */ + function showPrivateDetails( $id ) { + global $wgAbuseFilterPrivateLog; + + $lang = $this->getLanguage(); + $out = $this->getOutput(); + $request = $this->getRequest(); + + $dbr = wfGetDB( DB_REPLICA ); + + $reason = $request->getText( 'wpReason' ); + + // Make sure it is a valid request + $token = $request->getVal( 'wpEditToken' ); + if ( !$request->wasPosted() || !$this->getUser()->matchEditToken( $token ) ) { + $out->wrapWikiMsg( '<div class="errorbox">$1</div>', + [ 'abusefilter-invalid-request', $id ] ); + + return; + } + + if ( !$this->checkReason( $reason ) ) { + $out->addWikiMsg( 'abusefilter-noreason' ); + $this->showDetails( $id ); + return false; + } + + $row = $dbr->selectRow( + [ 'abuse_filter_log', 'abuse_filter' ], + [ 'afl_id', 'afl_filter', 'afl_user_text', 'afl_timestamp', 'afl_ip', 'af_id', + 'af_public_comments', 'af_hidden' ], + [ 'afl_id' => $id ], + __METHOD__, + [], + [ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ] + ); + + if ( !$row ) { + $out->addWikiMsg( 'abusefilter-log-nonexistent' ); + + return; + } + + if ( AbuseFilter::decodeGlobalName( $row->afl_filter ) ) { + $filter_hidden = null; + } else { + $filter_hidden = $row->af_hidden; + } + + if ( !self::canSeeDetails( $row->afl_filter, $filter_hidden ) ) { + $out->addWikiMsg( 'abusefilter-log-cannot-see-details' ); + + return; + } + + if ( !self::canSeePrivate( $row->afl_filter, $filter_hidden ) ) { + $out->addWikiMsg( 'abusefilter-log-cannot-see-private-details' ); + + return; + } + + // Log accessing private details + if ( $wgAbuseFilterPrivateLog ) { + $user = $this->getUser(); + self::addLogEntry( $id, $reason, $user ); + } + + // Show private details (IP). + $output = Xml::element( + 'legend', + null, + $this->msg( 'abusefilter-log-details-private' )->text() + ); + + $header = + Xml::element( 'th', null, $this->msg( 'abusefilter-log-details-var' )->text() ) . + Xml::element( 'th', null, $this->msg( 'abusefilter-log-details-val' )->text() ); + + $output .= + Xml::openElement( 'table', + [ + 'class' => 'wikitable mw-abuselog-private', + 'style' => 'width: 80%;' + ] + ) . + Xml::openElement( 'tbody' ); + $output .= $header; + + // Log ID + $linkRenderer = $this->getLinkRenderer(); + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-log-details-id' )->text() + ) . + Xml::openElement( 'td' ) . + $linkRenderer->makeKnownLink( + $this->getPageTitle( $row->afl_id ), + $lang->formatNum( $row->afl_id ) + ) . + Xml::closeElement( 'td' ) + ); + + // Timestamp + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-edit-builder-vars-timestamp-expanded' )->text() + ) . + Xml::element( 'td', + null, + $lang->timeanddate( $row->afl_timestamp, true ) + ) + ); + + // User + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-edit-builder-vars-user-name' )->text() + ) . + Xml::element( 'td', + null, + $row->afl_user_text + ) + ); + + // Filter ID + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-list-id' )->text() + ) . + Xml::openElement( 'td' ) . + $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ), + $lang->formatNum( $row->af_id ) + ) . + Xml::closeElement( 'td' ) + ); + + // Filter description + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-list-public' )->text() + ) . + Xml::element( 'td', + null, + $row->af_public_comments + ) + ); + + // IP address + if ( $row->afl_ip !== '' ) { + if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) && + $this->getUser()->isAllowed( 'checkuser' ) ) { + $CULink = ' · ' . $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( + 'CheckUser', + $row->afl_ip + ), + $this->msg( 'abusefilter-log-details-checkuser' )->text() + ); + } else { + $CULink = ''; + } + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-log-details-ip' )->text() + ) . + Xml::tags( + 'td', + null, + self::getUserLinks( 0, $row->afl_ip ) . $CULink + ) + ); + } else { + $output .= + Xml::tags( 'tr', null, + Xml::element( 'td', + [ 'style' => 'width: 30%;' ], + $this->msg( 'abusefilter-log-details-ip' )->text() + ) . + Xml::element( + 'td', + null, + $this->msg( 'abusefilter-log-ip-not-available' )->text() + ) + ); + } + + $output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' ); + + $output = Xml::tags( 'fieldset', null, $output ); + + $out->addHTML( $output ); + } + + /** + * If specifying a reason for viewing private details of abuse log is required + * then it makes sure that a reason is provided. + * + * @param string $reason + * @return bool + */ + protected function checkReason( $reason ) { + global $wgAbuseFilterForceSummary; + return ( !$wgAbuseFilterForceSummary || strlen( $reason ) > 0 ); + } + + /** + * @param int $logID int The ID of the AbuseFilter log that was accessed + * @param string $reason The reason provided for accessing private details + * @param User $user The user who accessed the private details + * @return void + */ + public static function addLogEntry( $logID, $reason, $user ) { + $target = self::getTitleFor( 'AbuseLog', $logID ); + + $logEntry = new ManualLogEntry( 'abusefilterprivatedetails', 'access' ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $target ); + $logEntry->setParameters( [ + '4::logid' => $logID, + ] ); + $logEntry->setComment( $reason ); + + $logEntry->insert(); + } + + /** + * @param string $filter_id + * @param bool $filter_hidden + * @return bool + */ + 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 ); + } + if ( $filter_hidden ) { + return $wgUser->isAllowed( 'abusefilter-log-detail' ) && ( + AbuseFilterView::canViewPrivate() || $wgUser->isAllowed( 'abusefilter-log-private' ) + ); + } + } + + return $wgUser->isAllowed( 'abusefilter-log-detail' ); + } + + /** + * @return bool + */ + static function canSeePrivate() { + global $wgUser; + + return $wgUser->isAllowed( 'abusefilter-private' ); + } + + /** + * @return bool + */ + static function canSeeHidden() { + global $wgUser; + + return $wgUser->isAllowed( 'abusefilter-hidden-log' ); + } + + /** + * @param stdClass $row + * @param bool $isListItem + * @return String + */ + function formatRow( $row, $isListItem = true ) { + $user = $this->getUser(); + $lang = $this->getLanguage(); + + $actionLinks = []; + + $title = Title::makeTitle( $row->afl_namespace, $row->afl_title ); + + $diffLink = false; + $isHidden = self::isHidden( $row ); + + if ( !self::canSeeHidden() && $isHidden ) { + return ''; + } + + $linkRenderer = $this->getLinkRenderer(); + + if ( !$row->afl_wiki ) { + $pageLink = $linkRenderer->makeLink( $title ); + if ( $row->afl_rev_id && $title->exists() ) { + $diffLink = $linkRenderer->makeKnownLink( + $title, + new HtmlArmor( $this->msg( 'abusefilter-log-diff' )->parse() ), + [], + [ 'diff' => 'prev', 'oldid' => $row->afl_rev_id ] ); + } + } else { + $pageLink = WikiMap::makeForeignLink( $row->afl_wiki, $row->afl_title ); + + if ( $row->afl_rev_id ) { + $diffUrl = WikiMap::getForeignURL( $row->afl_wiki, $row->afl_title ); + $diffUrl = wfAppendQuery( $diffUrl, + [ 'diff' => 'prev', 'oldid' => $row->afl_rev_id ] ); + + $diffLink = Linker::makeExternalLink( $diffUrl, + $this->msg( 'abusefilter-log-diff' )->parse() ); + } + } + + if ( !$row->afl_wiki ) { + // Local user + $userLink = self::getUserLinks( $row->afl_user, $row->afl_user_text ); + } else { + $userLink = WikiMap::foreignUserLink( $row->afl_wiki, $row->afl_user_text ); + $userLink .= ' (' . WikiMap::getWikiName( $row->afl_wiki ) . ')'; + } + + $timestamp = $lang->timeanddate( $row->afl_timestamp, true ); + + $actions_taken = $row->afl_actions; + if ( !strlen( trim( $actions_taken ) ) ) { + $actions_taken = $this->msg( 'abusefilter-log-noactions' )->escaped(); + } else { + $actions = explode( ',', $actions_taken ); + $displayActions = []; + + foreach ( $actions as $action ) { + $displayActions[] = AbuseFilter::getActionDisplay( $action ); + } + $actions_taken = $lang->commaList( $displayActions ); + } + + $globalIndex = AbuseFilter::decodeGlobalName( $row->afl_filter ); + + if ( $globalIndex ) { + // Pull global filter description + $escaped_comments = Sanitizer::escapeHtmlAllowEntities( + AbuseFilter::getGlobalFilterDescription( $globalIndex ) ); + $filter_hidden = null; + } else { + $escaped_comments = Sanitizer::escapeHtmlAllowEntities( + $row->af_public_comments ); + $filter_hidden = $row->af_hidden; + } + + if ( self::canSeeDetails( $row->afl_filter, $filter_hidden ) ) { + if ( $isListItem ) { + $detailsLink = $linkRenderer->makeKnownLink( + $this->getPageTitle( $row->afl_id ), + $this->msg( 'abusefilter-log-detailslink' )->text() + ); + $actionLinks[] = $detailsLink; + } + + $examineTitle = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/log/' . $row->afl_id ); + $examineLink = $linkRenderer->makeKnownLink( + $examineTitle, + new HtmlArmor( $this->msg( 'abusefilter-changeslist-examine' )->parse() ) + ); + $actionLinks[] = $examineLink; + + if ( $diffLink ) { + $actionLinks[] = $diffLink; + } + + if ( $user->isAllowed( 'abusefilter-hide-log' ) ) { + $hideLink = $linkRenderer->makeKnownLink( + $this->getPageTitle(), + $this->msg( 'abusefilter-log-hidelink' )->text(), + [], + [ 'hide' => $row->afl_id ] + ); + + $actionLinks[] = $hideLink; + } + + if ( $globalIndex ) { + global $wgAbuseFilterCentralDB; + $globalURL = + WikiMap::getForeignURL( $wgAbuseFilterCentralDB, + 'Special:AbuseFilter/' . $globalIndex ); + + $linkText = $this->msg( 'abusefilter-log-detailedentry-global' ) + ->numParams( $globalIndex )->escaped(); + $filterLink = Linker::makeExternalLink( $globalURL, $linkText ); + } else { + $title = SpecialPage::getTitleFor( 'AbuseFilter', $row->afl_filter ); + $linkText = $this->msg( 'abusefilter-log-detailedentry-local' ) + ->numParams( $row->afl_filter )->text(); + $filterLink = $linkRenderer->makeKnownLink( $title, $linkText ); + } + $description = $this->msg( 'abusefilter-log-detailedentry-meta' )->rawParams( + $timestamp, + $userLink, + $filterLink, + $row->afl_action, + $pageLink, + $actions_taken, + $escaped_comments, + $lang->pipeList( $actionLinks ) + )->params( $row->afl_user_text )->parse(); + } else { + if ( $diffLink ) { + $msg = 'abusefilter-log-entry-withdiff'; + } else { + $msg = 'abusefilter-log-entry'; + } + $description = $this->msg( $msg )->rawParams( + $timestamp, + $userLink, + $row->afl_action, + $pageLink, + $actions_taken, + $escaped_comments, + $diffLink // Passing $7 to 'abusefilter-log-entry' will do nothing, as it's not used. + )->params( $row->afl_user_text )->parse(); + } + + if ( $isHidden === true ) { + $description .= ' ' . + $this->msg( 'abusefilter-log-hidden' )->parse(); + $class = 'afl-hidden'; + } elseif ( $isHidden === 'implicit' ) { + $description .= ' ' . + $this->msg( 'abusefilter-log-hidden-implicit' )->parse(); + } + + if ( $isListItem ) { + return Xml::tags( 'li', isset( $class ) ? [ 'class' => $class ] : null, $description ); + } else { + return Xml::tags( 'span', isset( $class ) ? [ 'class' => $class ] : null, $description ); + } + } + + /** + * @param int $userId + * @param string $userName + * @return string + */ + protected static function getUserLinks( $userId, $userName ) { + static $cache = []; + + if ( !isset( $cache[$userName][$userId] ) ) { + $cache[$userName][$userId] = Linker::userLink( $userId, $userName ) . + Linker::userToolLinks( $userId, $userName, true ); + } + + return $cache[$userName][$userId]; + } + + /** + * @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. + * + * @return bool|string true if the item is explicitly hidden, false if it is not. + * The string 'implicit' if it is hidden because the corresponding revision is hidden. + */ + public static function isHidden( $row ) { + if ( $row->afl_rev_id ) { + $revision = Revision::newFromId( $row->afl_rev_id ); + if ( $revision && $revision->getVisibility() != 0 ) { + return 'implicit'; + } + } + + return (bool)$row->afl_deleted; + } + + /** + * @return string + */ + protected function getGroupName() { + return 'changes'; + } +} |