summaryrefslogtreecommitdiff
path: root/www/wiki/extensions/AbuseFilter/includes
diff options
context:
space:
mode:
Diffstat (limited to 'www/wiki/extensions/AbuseFilter/includes')
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/AFComputedVariable.php445
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/AbuseFilter.php2796
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/AbuseFilterChangesList.php118
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/AbuseFilterHooks.php851
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php53
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php46
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/AbuseFilterVariableHolder.php226
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/AbuseLogHitFormatter.php60
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/TableDiffFormatterFullContext.php36
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterView.php112
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewDiff.php387
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewEdit.php1252
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewExamine.php219
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewHistory.php85
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewImport.php24
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewList.php267
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewRevert.php299
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php207
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/Views/AbuseFilterViewTools.php56
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php94
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php49
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php30
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php62
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/api/ApiQueryAbuseFilters.php218
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/api/ApiQueryAbuseLog.php313
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php72
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php204
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/pagers/AbuseFilterPager.php260
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/pagers/AbuseLogPager.php79
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php70
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AFPData.php497
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AFPException.php4
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AFPParserState.php10
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AFPToken.php61
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AFPTreeNode.php126
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AFPTreeParser.php611
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AFPUserVisibleException.php40
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterCachingParser.php279
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterParser.php1560
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/parser/AbuseFilterTokenizer.php258
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/special/SpecialAbuseFilter.php134
-rw-r--r--www/wiki/extensions/AbuseFilter/includes/special/SpecialAbuseLog.php1049
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;' ],
+ '&#160;' );
+ }
+
+ // 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;'
+ ], '&#160;'
+ )
+ );
+ }
+
+ // 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 .= '&#160;';
+ $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 = '&nbsp;&middot;&nbsp;' . $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';
+ }
+}