summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBrian Evans <grknight@gentoo.org>2020-10-02 14:32:39 -0400
committerBrian Evans <grknight@gentoo.org>2020-10-02 14:32:39 -0400
commit1f029fca0e032ee20673003d136f8603984b0841 (patch)
tree50d3a1748543abec2e7bbc3d94a7290cb57a78d2 /AbuseFilter/includes
parentUpdate Echo to 1.35 (diff)
downloadextensions-1f029fca0e032ee20673003d136f8603984b0841.tar.gz
extensions-1f029fca0e032ee20673003d136f8603984b0841.tar.bz2
extensions-1f029fca0e032ee20673003d136f8603984b0841.zip
Update AbuseFilter to 1.35
Signed-off-by: Brian Evans <grknight@gentoo.org>
Diffstat (limited to 'AbuseFilter/includes')
-rw-r--r--AbuseFilter/includes/AFComputedVariable.php267
-rw-r--r--AbuseFilter/includes/AbuseFilter.php2815
-rw-r--r--AbuseFilter/includes/AbuseFilterChangesList.php31
-rw-r--r--AbuseFilter/includes/AbuseFilterHooks.php783
-rw-r--r--AbuseFilter/includes/AbuseFilterModifyLogFormatter.php1
-rw-r--r--AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php25
-rw-r--r--AbuseFilter/includes/AbuseFilterRightsLogFormatter.php42
-rw-r--r--AbuseFilter/includes/AbuseFilterRunner.php1434
-rw-r--r--AbuseFilter/includes/AbuseFilterServices.php15
-rw-r--r--AbuseFilter/includes/AbuseFilterVariableHolder.php257
-rw-r--r--AbuseFilter/includes/AbuseLogHitFormatter.php14
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterAlterVariablesHook.php27
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterBuilderHook.php15
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterComputeVariableHook.php26
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterContentToStringHook.php23
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterDeprecatedVariablesHook.php16
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterFilterActionHook.php26
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterGenerateGenericVarsHook.php23
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterGenerateTitleVarsHook.php28
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterGenerateUserVarsHook.php26
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterHookRunner.php290
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterInterceptVariableHook.php26
-rw-r--r--AbuseFilter/includes/Hooks/AbuseFilterShouldFilterActionHook.php28
-rw-r--r--AbuseFilter/includes/KeywordsManager.php283
-rw-r--r--AbuseFilter/includes/ServiceWiring.php13
-rw-r--r--AbuseFilter/includes/VariableGenerator/RCVariableGenerator.php235
-rw-r--r--AbuseFilter/includes/VariableGenerator/RunVariableGenerator.php316
-rw-r--r--AbuseFilter/includes/VariableGenerator/VariableGenerator.php230
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterView.php192
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterViewDiff.php82
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterViewEdit.php665
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterViewExamine.php84
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterViewHistory.php17
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterViewImport.php5
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterViewList.php199
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterViewRevert.php76
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php89
-rw-r--r--AbuseFilter/includes/Views/AbuseFilterViewTools.php46
-rw-r--r--AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php36
-rw-r--r--AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php4
-rw-r--r--AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php45
-rw-r--r--AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php22
-rw-r--r--AbuseFilter/includes/api/ApiAbuseLogPrivateDetails.php110
-rw-r--r--AbuseFilter/includes/api/ApiQueryAbuseFilters.php13
-rw-r--r--AbuseFilter/includes/api/ApiQueryAbuseLog.php74
-rw-r--r--AbuseFilter/includes/pagers/AbuseFilterExaminePager.php11
-rw-r--r--AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php124
-rw-r--r--AbuseFilter/includes/pagers/AbuseFilterPager.php247
-rw-r--r--AbuseFilter/includes/pagers/AbuseLogPager.php66
-rw-r--r--AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php9
-rw-r--r--AbuseFilter/includes/parser/AFPData.php491
-rw-r--r--AbuseFilter/includes/parser/AFPParserState.php2
-rw-r--r--AbuseFilter/includes/parser/AFPSyntaxTree.php27
-rw-r--r--AbuseFilter/includes/parser/AFPToken.php31
-rw-r--r--AbuseFilter/includes/parser/AFPTransitionBase.php143
-rw-r--r--AbuseFilter/includes/parser/AFPTreeNode.php108
-rw-r--r--AbuseFilter/includes/parser/AFPTreeParser.php277
-rw-r--r--AbuseFilter/includes/parser/AFPUserVisibleException.php22
-rw-r--r--AbuseFilter/includes/parser/AbuseFilterCachingParser.php274
-rw-r--r--AbuseFilter/includes/parser/AbuseFilterParser.php1341
-rw-r--r--AbuseFilter/includes/parser/AbuseFilterTokenizer.php144
-rw-r--r--AbuseFilter/includes/special/AbuseFilterSpecialPage.php65
-rw-r--r--AbuseFilter/includes/special/SpecialAbuseFilter.php72
-rw-r--r--AbuseFilter/includes/special/SpecialAbuseLog.php686
64 files changed, 8359 insertions, 4855 deletions
diff --git a/AbuseFilter/includes/AFComputedVariable.php b/AbuseFilter/includes/AFComputedVariable.php
index 15847598..4a3a5c55 100644
--- a/AbuseFilter/includes/AFComputedVariable.php
+++ b/AbuseFilter/includes/AFComputedVariable.php
@@ -1,14 +1,32 @@
<?php
-use Wikimedia\Rdbms\Database;
-use MediaWiki\MediaWikiServices;
+use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\IPUtils;
+use Wikimedia\Rdbms\Database;
class AFComputedVariable {
- public $mMethod, $mParameters;
+ /**
+ * @var string The method used to compute the variable
+ */
+ public $mMethod;
+ /**
+ * @var array Parameters to be used with the specified method
+ */
+ public $mParameters;
+ /**
+ * @var User[] Cache containing User objects already constructed
+ */
public static $userCache = [];
+ /**
+ * @var WikiPage[] Cache containing Page objects already constructed
+ */
public static $articleCache = [];
+ /** @var float The amount of time to subtract from profiling */
+ public static $profilingExtraTime = 0;
+
/**
* @param string $method
* @param array $parameters
@@ -23,11 +41,12 @@ class AFComputedVariable {
*
*
* @param string $wikitext
- * @param Article $article
+ * @param WikiPage $article
+ * @param User $user Context user
*
* @return object
*/
- public function parseNonEditWikitext( $wikitext, $article ) {
+ public function parseNonEditWikitext( $wikitext, WikiPage $article, User $user ) {
static $cache = [];
$cacheKey = md5( $wikitext ) . ':' . $article->getTitle()->getPrefixedText();
@@ -36,11 +55,10 @@ class AFComputedVariable {
return $cache[$cacheKey];
}
- global $wgParser;
$edit = (object)[];
- $options = new ParserOptions;
- $options->setTidy( true );
- $edit->output = $wgParser->parse( $wikitext, $article->getTitle(), $options );
+ $options = ParserOptions::newFromUser( $user );
+ $parser = MediaWikiServices::getInstance()->getParser();
+ $edit->output = $parser->parse( $wikitext, $article->getTitle(), $options );
$cache[$cacheKey] = $edit;
return $edit;
@@ -72,30 +90,25 @@ class AFComputedVariable {
}
if ( $user instanceof User ) {
- self::$userCache[$username] = $user;
- return $user;
- }
-
- if ( IP::isIPAddress( $username ) ) {
- $u = new User;
- $u->setName( $username );
- self::$userCache[$username] = $u;
- return $u;
+ $ret = $user;
+ } elseif ( IPUtils::isIPAddress( $username ) ) {
+ $ret = new User;
+ $ret->setName( $username );
+ } else {
+ $ret = User::newFromName( $username );
+ $ret->load();
}
+ self::$userCache[$username] = $ret;
- $user = User::newFromName( $username );
- $user->load();
- self::$userCache[$username] = $user;
-
- return $user;
+ return $ret;
}
/**
* @param int $namespace
* @param string $title
- * @return Article
+ * @return WikiPage
*/
- public static function articleFromTitle( $namespace, $title ) {
+ public function pageFromTitle( $namespace, $title ) {
if ( isset( self::$articleCache["$namespace:$title"] ) ) {
return self::$articleCache["$namespace:$title"];
}
@@ -105,20 +118,30 @@ class AFComputedVariable {
}
$logger = LoggerFactory::getInstance( 'AbuseFilter' );
- $logger->debug( "Creating article object for $namespace:$title in cache" );
+ $logger->debug( "Creating wikipage object for $namespace:$title in cache" );
- // TODO: use WikiPage instead!
- $t = Title::makeTitle( $namespace, $title );
- self::$articleCache["$namespace:$title"] = new Article( $t );
+ $t = $this->buildTitle( $namespace, $title );
+ self::$articleCache["$namespace:$title"] = WikiPage::factory( $t );
return self::$articleCache["$namespace:$title"];
}
/**
- * @param Article $article
+ * Mockable wrapper
+ *
+ * @param int $namespace
+ * @param string $title
+ * @return Title
+ */
+ protected function buildTitle( $namespace, $title ) : Title {
+ return Title::makeTitle( $namespace, $title );
+ }
+
+ /**
+ * @param WikiPage $article
* @return array
*/
- public static function getLinksFromDB( $article ) {
+ public static function getLinksFromDB( WikiPage $article ) {
// Stolen from ConfirmEdit, SimpleCaptcha::getLinksFromTracker
$id = $article->getId();
if ( !$id ) {
@@ -126,35 +149,46 @@ class AFComputedVariable {
}
$dbr = wfGetDB( DB_REPLICA );
- $res = $dbr->select(
+ return $dbr->selectFieldValues(
'externallinks',
- [ 'el_to' ],
+ 'el_to',
[ 'el_from' => $id ],
__METHOD__
);
- $links = [];
- foreach ( $res as $row ) {
- $links[] = $row->el_to;
- }
- return $links;
}
/**
* @param AbuseFilterVariableHolder $vars
- * @return AFPData|array|int|mixed|null|string
+ * @return AFPData
* @throws MWException
* @throws AFPException
*/
- public function compute( $vars ) {
+ public function compute( AbuseFilterVariableHolder $vars ) {
+ // TODO: find a way to inject the User object from hook parameters.
+ global $wgUser;
+
+ // Used for parsing wikitext from saved revisions and checking for
+ // whether to show fields. Do not use $wgUser below here, in preparation
+ // for eventually injecting. See T246733
+ $computeForUser = $wgUser;
+
+ $vars->setLogger( LoggerFactory::getInstance( 'AbuseFilter' ) );
$parameters = $this->mParameters;
$result = null;
- if ( !Hooks::run( 'AbuseFilter-interceptVariable',
- [ $this->mMethod, $vars, $parameters, &$result ] ) ) {
+ $hookRunner = AbuseFilterHookRunner::getRunner();
+
+ if ( !$hookRunner->onAbuseFilterInterceptVariable(
+ $this->mMethod,
+ $vars,
+ $parameters,
+ $result
+ ) ) {
return $result instanceof AFPData
? $result : AFPData::newFromPHPVar( $result );
}
+ $services = MediaWikiServices::getInstance();
switch ( $this->mMethod ) {
case 'diff':
// Currently unused. Kept for backwards compatibility since it remains
@@ -186,13 +220,12 @@ class AFComputedVariable {
$diff = $vars->getVar( $parameters['diff-var'] )->toString();
$line_prefix = $parameters['line-prefix'];
$diff_lines = explode( "\n", $diff );
- $interest_lines = [];
+ $result = [];
foreach ( $diff_lines as $line ) {
if ( substr( $line, 0, 1 ) === $line_prefix ) {
- $interest_lines[] = substr( $line, strlen( $line_prefix ) );
+ $result[] = substr( $line, strlen( $line_prefix ) );
}
}
- $result = $interest_lines;
break;
case 'links-from-wikitext':
// This should ONLY be used when sharing a parse operation with the edit.
@@ -201,32 +234,42 @@ class AFComputedVariable {
if ( isset( $parameters['article'] ) ) {
$article = $parameters['article'];
} else {
- $article = self::articleFromTitle(
+ $article = $this->pageFromTitle(
$parameters['namespace'],
$parameters['title']
);
}
if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
+ // Shared with the edit, don't count it in profiling
+ $startTime = microtime( true );
$textVar = $parameters['text-var'];
$new_text = $vars->getVar( $textVar )->toString();
$content = ContentHandler::makeContent( $new_text, $article->getTitle() );
- $editInfo = $article->prepareContentForEdit( $content );
- $links = array_keys( $editInfo->output->getExternalLinks() );
+ try {
+ // @fixme TEMPORARY WORKAROUND FOR T187153
+ $editInfo = $article->prepareContentForEdit( $content );
+ $links = array_keys( $editInfo->output->getExternalLinks() );
+ } catch ( Error $e ) {
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->warning( 'Caught Error, case 1 - T187153' );
+ $links = [];
+ }
$result = $links;
+ self::$profilingExtraTime += ( microtime( true ) - $startTime );
break;
}
// Otherwise fall back to database
case 'links-from-wikitext-nonedit':
case 'links-from-wikitext-or-database':
- // TODO: use Content object instead, if available! In any case, use WikiPage, not Article.
- $article = self::articleFromTitle(
+ // TODO: use Content object instead, if available!
+ $article = $this->pageFromTitle(
$parameters['namespace'],
$parameters['title']
);
$logger = LoggerFactory::getInstance( 'AbuseFilter' );
- if ( $vars->getVar( 'context' )->toString() == 'filter' ) {
+ if ( $vars->forFilter ) {
$links = $this->getLinksFromDB( $article );
$logger->debug( 'Loading old links from DB' );
} elseif ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
@@ -234,7 +277,11 @@ class AFComputedVariable {
$textVar = $parameters['text-var'];
$wikitext = $vars->getVar( $textVar )->toString();
- $editInfo = $this->parseNonEditWikitext( $wikitext, $article );
+ $editInfo = $this->parseNonEditWikitext(
+ $wikitext,
+ $article,
+ $computeForUser
+ );
$links = array_keys( $editInfo->output->getExternalLinks() );
} else {
// TODO: Get links from Content object. But we don't have the content object.
@@ -256,10 +303,10 @@ class AFComputedVariable {
$oldLinks = explode( "\n", $oldLinks );
$newLinks = explode( "\n", $newLinks );
- if ( $this->mMethod == 'link-diff-added' ) {
+ if ( $this->mMethod === 'link-diff-added' ) {
$result = array_diff( $newLinks, $oldLinks );
}
- if ( $this->mMethod == 'link-diff-removed' ) {
+ if ( $this->mMethod === 'link-diff-removed' ) {
$result = array_diff( $oldLinks, $newLinks );
}
break;
@@ -268,31 +315,44 @@ class AFComputedVariable {
if ( isset( $parameters['article'] ) ) {
$article = $parameters['article'];
} else {
- $article = self::articleFromTitle(
+ $article = $this->pageFromTitle(
$parameters['namespace'],
$parameters['title']
);
}
if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
+ // Shared with the edit, don't count it in profiling
+ $startTime = microtime( true );
$textVar = $parameters['wikitext-var'];
$new_text = $vars->getVar( $textVar )->toString();
$content = ContentHandler::makeContent( $new_text, $article->getTitle() );
- $editInfo = $article->prepareContentForEdit( $content );
+ try {
+ // @fixme TEMPORARY WORKAROUND FOR T187153
+ $editInfo = $article->prepareContentForEdit( $content );
+ } catch ( Error $e ) {
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->warning( 'Caught Error, case 2 - T187153' );
+ $result = '';
+ break;
+ }
if ( isset( $parameters['pst'] ) && $parameters['pst'] ) {
$result = $editInfo->pstContent->serialize( $editInfo->format );
} else {
$newHTML = $editInfo->output->getText();
// Kill the PP limit comments. Ideally we'd just remove these by not setting the
// parser option, but then we can't share a parse operation with the edit, which is bad.
- $result = preg_replace( '/<!--\s*NewPP limit report[^>]*-->\s*$/si', '', $newHTML );
+ // @fixme No awfulness scale can measure how awful this hack is.
+ $re = '/<!--\s*NewPP limit [^>]*-->\s*(?:<!--\s*Transclusion [^>]+-->\s*)?(?:<\/div>\s*)?$/i';
+ $result = preg_replace( $re, '', $newHTML );
}
+ self::$profilingExtraTime += ( microtime( true ) - $startTime );
break;
}
// Otherwise fall back to database
case 'parse-wikitext-nonedit':
- // TODO: use Content object instead, if available! In any case, use WikiPage, not Article.
- $article = self::articleFromTitle( $parameters['namespace'], $parameters['title'] );
+ // TODO: use Content object instead, if available!
+ $article = $this->pageFromTitle( $parameters['namespace'], $parameters['title'] );
$textVar = $parameters['wikitext-var'];
if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
@@ -301,7 +361,11 @@ class AFComputedVariable {
$result = $vars->getVar( $textVar )->toString();
} else {
$text = $vars->getVar( $textVar )->toString();
- $editInfo = $this->parseNonEditWikitext( $text, $article );
+ $editInfo = $this->parseNonEditWikitext(
+ $text,
+ $article,
+ $computeForUser
+ );
$result = $editInfo->output->getText();
}
} else {
@@ -315,10 +379,14 @@ class AFComputedVariable {
case 'strip-html':
$htmlVar = $parameters['html-var'];
$html = $vars->getVar( $htmlVar )->toString();
- $result = StringUtils::delimiterReplace( '<', '>', '', $html );
+ $stripped = StringUtils::delimiterReplace( '<', '>', '', $html );
+ // We strip extra spaces to the right because the stripping above
+ // could leave a lot of whitespace.
+ // @fixme Find a better way to do this.
+ $result = TextContent::normalizeLineEndings( $stripped );
break;
case 'load-recent-authors':
- $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] );
+ $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] );
if ( !$title->exists() ) {
$result = '';
break;
@@ -327,11 +395,12 @@ class AFComputedVariable {
$result = self::getLastPageAuthors( $title );
break;
case 'load-first-author':
- $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] );
+ $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] );
- $revision = $title->getFirstRevision();
+ $revision = $services->getRevisionLookup()->getFirstRevision( $title );
if ( $revision ) {
- $result = $revision->getUserText();
+ $user = $revision->getUser();
+ $result = $user === null ? '' : $user->getName();
} else {
$result = '';
}
@@ -339,11 +408,9 @@ class AFComputedVariable {
break;
case 'get-page-restrictions':
$action = $parameters['action'];
- $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] );
+ $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] );
- $rights = $title->getRestrictions( $action );
- $rights = count( $rights ) ? $rights : [];
- $result = $rights;
+ $result = $title->getRestrictions( $action );
break;
case 'simple-user-accessor':
$user = $parameters['user'];
@@ -361,21 +428,31 @@ class AFComputedVariable {
$result = call_user_func( [ $obj, $method ] );
break;
+ case 'user-block':
+ // @todo Support partial blocks
+ $user = $parameters['user'];
+ $result = (bool)$user->getBlock();
+ break;
case 'user-age':
$user = $parameters['user'];
$asOf = $parameters['asof'];
$obj = self::getUserObject( $user );
- if ( $obj->getId() == 0 ) {
+ $registration = $obj->getRegistration();
+
+ if ( $obj->getId() === 0 ) {
$result = 0;
- break;
+ } else {
+ // HACK: If there's no registration date, assume 2008-01-15, Wikipedia Day
+ // in the year before the new user log was created. See T243469.
+ if ( $registration === null ) {
+ $registration = "20080115000000";
+ }
+ $result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $registration );
}
-
- $registration = $obj->getRegistration();
- $result = wfTimestamp( TS_UNIX, $asOf ) - wfTimestampOrNull( TS_UNIX, $registration );
break;
case 'page-age':
- $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] );
+ $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] );
$firstRevisionTime = $title->getEarliestRevTime();
if ( !$firstRevisionTime ) {
@@ -384,7 +461,7 @@ class AFComputedVariable {
}
$asOf = $parameters['asof'];
- $result = wfTimestamp( TS_UNIX, $asOf ) - wfTimestampOrNull( TS_UNIX, $firstRevisionTime );
+ $result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $firstRevisionTime );
break;
case 'user-groups':
// Deprecated but needed by old log entries
@@ -408,19 +485,37 @@ class AFComputedVariable {
$result = $v1 - $v2;
break;
case 'revision-text-by-id':
- $rev = Revision::newFromId( $parameters['revid'] );
- $result = AbuseFilter::revisionToString( $rev );
+ $revRec = $services
+ ->getRevisionLookup()
+ ->getRevisionById( $parameters['revid'] );
+ $result = AbuseFilter::revisionToString( $revRec, $computeForUser );
break;
case 'revision-text-by-timestamp':
$timestamp = $parameters['timestamp'];
- $title = Title::makeTitle( $parameters['namespace'], $parameters['title'] );
- $dbr = wfGetDB( DB_REPLICA );
- $rev = Revision::loadFromTimestamp( $dbr, $title, $timestamp );
- $result = AbuseFilter::revisionToString( $rev );
+ if ( $timestamp === null ) {
+ // Temporary BC for T246539#6388362
+ $result = '[Revision text not available]';
+ break;
+ }
+ $title = $this->buildTitle( $parameters['namespace'], $parameters['title'] );
+ $revRec = $services
+ ->getRevisionStore()
+ ->getRevisionByTimestamp( $title, $timestamp );
+ $result = AbuseFilter::revisionToString( $revRec, $computeForUser );
+ break;
+ case 'get-wiki-name':
+ $result = WikiMap::getCurrentWikiDbDomain()->getId();
+ break;
+ case 'get-wiki-language':
+ $result = $services->getContentLanguage()->getCode();
break;
default:
- if ( Hooks::run( 'AbuseFilter-computeVariable',
- [ $this->mMethod, $vars, $parameters, &$result ] ) ) {
+ if ( $hookRunner->onAbuseFilterComputeVariable(
+ $this->mMethod,
+ $vars,
+ $parameters,
+ $result
+ ) ) {
throw new AFPException( 'Unknown variable compute type ' . $this->mMethod );
}
}
@@ -448,14 +543,14 @@ class AFComputedVariable {
$dbr = wfGetDB( DB_REPLICA );
$setOpts += Database::getCacheSetOptions( $dbr );
// Get the last 100 edit authors with a trivial query (avoid T116557)
- $revQuery = Revision::getQueryInfo();
+ $revQuery = MediaWikiServices::getInstance()->getRevisionStore()->getQueryInfo();
$revAuthors = $dbr->selectFieldValues(
$revQuery['tables'],
$revQuery['fields']['rev_user_text'],
[ 'rev_page' => $title->getArticleID() ],
$fname,
// Some pages have < 10 authors but many revisions (e.g. bot pages)
- [ 'ORDER BY' => 'rev_timestamp DESC',
+ [ 'ORDER BY' => 'rev_timestamp DESC, rev_id DESC',
'LIMIT' => 100,
// Force index per T116557
'USE INDEX' => [ 'revision' => 'page_timestamp' ],
diff --git a/AbuseFilter/includes/AbuseFilter.php b/AbuseFilter/includes/AbuseFilter.php
index 2566c25e..f76b4df6 100644
--- a/AbuseFilter/includes/AbuseFilter.php
+++ b/AbuseFilter/includes/AbuseFilter.php
@@ -1,30 +1,68 @@
<?php
-use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
+use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
+use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator;
use MediaWiki\Logger\LoggerFactory;
-use MediaWiki\Session\SessionManager;
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
+use Wikimedia\Rdbms\DBError;
use Wikimedia\Rdbms\IDatabase;
/**
- * This class contains most of the business logic of AbuseFilter. It consists of mostly
- * static functions that handle activities such as parsing edits, applying filters,
- * logging actions, etc.
+ * This class contains most of the business logic of AbuseFilter. It consists of
+ * static functions for generic use (mostly utility functions).
*/
class AbuseFilter {
+ /**
+ * @var int How long to keep profiling data in cache (in seconds)
+ */
public static $statsStoragePeriod = 86400;
- public static $condLimitEnabled = true;
- /** @var array Map of (filter ID => stdClass) */
+ /**
+ * @var array [filter ID => stdClass|null] as retrieved from self::getFilter. ID could be either
+ * an integer or "<GLOBAL_FILTER_PREFIX><integer>"
+ */
private static $filterCache = [];
- public static $condCount = 0;
+ /** @var string The prefix to use for global filters */
+ public const GLOBAL_FILTER_PREFIX = 'global-';
- /** @var array Map of (action ID => string[]) */
- // FIXME: avoid global state here
+ /**
+ * @var array Map of (action ID => string[])
+ * @fixme avoid global state here
+ */
public static $tagsToSet = [];
- public static $history_mappings = [
+ /**
+ * @var array IDs of logged filters like [ page title => [ 'local' => [ids], 'global' => [ids] ] ].
+ * @fixme avoid global state
+ */
+ public static $logIds = [];
+
+ /**
+ * @var string[] The FULL list of fields in the abuse_filter table
+ * @internal
+ */
+ public const ALL_ABUSE_FILTER_FIELDS = [
+ 'af_id',
+ 'af_pattern',
+ 'af_user',
+ 'af_user_text',
+ 'af_timestamp',
+ 'af_enabled',
+ 'af_comments',
+ 'af_public_comments',
+ 'af_hidden',
+ 'af_hit_count',
+ 'af_throttled',
+ 'af_deleted',
+ 'af_actions',
+ 'af_global',
+ 'af_group'
+ ];
+
+ public const HISTORY_MAPPINGS = [
'af_pattern' => 'afh_pattern',
'af_user' => 'afh_user',
'af_user_text' => 'afh_user_text',
@@ -35,343 +73,41 @@ class AbuseFilter {
'af_id' => 'afh_filter',
'af_group' => 'afh_group',
];
- public static $builderValues = [
- 'op-arithmetic' => [
- '+' => 'addition',
- '-' => 'subtraction',
- '*' => 'multiplication',
- '/' => 'divide',
- '%' => 'modulo',
- '**' => 'pow'
- ],
- 'op-comparison' => [
- '==' => 'equal',
- '===' => 'equal-strict',
- '!=' => 'notequal',
- '!==' => 'notequal-strict',
- '<' => 'lt',
- '>' => 'gt',
- '<=' => 'lte',
- '>=' => 'gte'
- ],
- 'op-bool' => [
- '!' => 'not',
- '&' => 'and',
- '|' => 'or',
- '^' => 'xor'
- ],
- 'misc' => [
- 'in' => 'in',
- 'contains' => 'contains',
- 'like' => 'like',
- '""' => 'stringlit',
- 'rlike' => 'rlike',
- 'irlike' => 'irlike',
- 'cond ? iftrue : iffalse' => 'tern',
- 'if cond then iftrue elseiffalse end' => 'cond',
- ],
- 'funcs' => [
- 'length(string)' => 'length',
- 'lcase(string)' => 'lcase',
- 'ucase(string)' => 'ucase',
- 'ccnorm(string)' => 'ccnorm',
- 'ccnorm_contains_any(haystack,needle1,needle2,..)' => 'ccnorm-contains-any',
- 'ccnorm_contains_all(haystack,needle1,needle2,..)' => 'ccnorm-contains-all',
- 'rmdoubles(string)' => 'rmdoubles',
- 'specialratio(string)' => 'specialratio',
- 'norm(string)' => 'norm',
- 'count(needle,haystack)' => 'count',
- 'rcount(needle,haystack)' => 'rcount',
- 'get_matches(needle,haystack)' => 'get_matches',
- 'rmwhitespace(text)' => 'rmwhitespace',
- 'rmspecials(text)' => 'rmspecials',
- 'ip_in_range(ip, range)' => 'ip_in_range',
- 'contains_any(haystack,needle1,needle2,...)' => 'contains-any',
- 'contains_all(haystack,needle1,needle2,...)' => 'contains-all',
- 'equals_to_any(haystack,needle1,needle2,...)' => 'equals-to-any',
- 'substr(subject, offset, length)' => 'substr',
- 'strpos(haystack, needle)' => 'strpos',
- 'str_replace(subject, search, replace)' => 'str_replace',
- 'rescape(string)' => 'rescape',
- 'set_var(var,value)' => 'set_var',
- 'sanitize(string)' => 'sanitize',
- ],
- 'vars' => [
- 'timestamp' => 'timestamp',
- 'accountname' => 'accountname',
- 'action' => 'action',
- 'added_lines' => 'addedlines',
- 'edit_delta' => 'delta',
- 'edit_diff' => 'diff',
- 'new_size' => 'newsize',
- 'old_size' => 'oldsize',
- 'new_content_model' => 'new-content-model',
- 'old_content_model' => 'old-content-model',
- 'removed_lines' => 'removedlines',
- 'summary' => 'summary',
- 'page_id' => 'page-id',
- 'page_namespace' => 'page-ns',
- 'page_title' => 'page-title',
- 'page_prefixedtitle' => 'page-prefixedtitle',
- 'page_age' => 'page-age',
- 'moved_from_id' => 'movedfrom-id',
- 'moved_from_namespace' => 'movedfrom-ns',
- 'moved_from_title' => 'movedfrom-title',
- 'moved_from_prefixedtitle' => 'movedfrom-prefixedtitle',
- 'moved_from_age' => 'movedfrom-age',
- 'moved_to_id' => 'movedto-id',
- 'moved_to_namespace' => 'movedto-ns',
- 'moved_to_title' => 'movedto-title',
- 'moved_to_prefixedtitle' => 'movedto-prefixedtitle',
- 'moved_to_age' => 'movedto-age',
- 'user_editcount' => 'user-editcount',
- 'user_age' => 'user-age',
- 'user_name' => 'user-name',
- 'user_groups' => 'user-groups',
- 'user_rights' => 'user-rights',
- 'user_blocked' => 'user-blocked',
- 'user_emailconfirm' => 'user-emailconfirm',
- 'old_wikitext' => 'old-text',
- 'new_wikitext' => 'new-text',
- 'added_links' => 'added-links',
- 'removed_links' => 'removed-links',
- 'all_links' => 'all-links',
- 'new_pst' => 'new-pst',
- 'edit_diff_pst' => 'diff-pst',
- 'added_lines_pst' => 'addedlines-pst',
- 'new_text' => 'new-text-stripped',
- 'new_html' => 'new-html',
- 'page_restrictions_edit' => 'restrictions-edit',
- 'page_restrictions_move' => 'restrictions-move',
- 'page_restrictions_create' => 'restrictions-create',
- 'page_restrictions_upload' => 'restrictions-upload',
- 'page_recent_contributors' => 'recent-contributors',
- 'page_first_contributor' => 'first-contributor',
- 'moved_from_restrictions_edit' => 'movedfrom-restrictions-edit',
- 'moved_from_restrictions_move' => 'movedfrom-restrictions-move',
- 'moved_from_restrictions_create' => 'movedfrom-restrictions-create',
- 'moved_from_restrictions_upload' => 'movedfrom-restrictions-upload',
- 'moved_from_recent_contributors' => 'movedfrom-recent-contributors',
- 'moved_from_first_contributor' => 'movedfrom-first-contributor',
- 'moved_to_restrictions_edit' => 'movedto-restrictions-edit',
- 'moved_to_restrictions_move' => 'movedto-restrictions-move',
- 'moved_to_restrictions_create' => 'movedto-restrictions-create',
- 'moved_to_restrictions_upload' => 'movedto-restrictions-upload',
- 'moved_to_recent_contributors' => 'movedto-recent-contributors',
- 'moved_to_first_contributor' => 'movedto-first-contributor',
- 'old_links' => 'old-links',
- 'minor_edit' => 'minor-edit',
- 'file_sha1' => 'file-sha1',
- 'file_size' => 'file-size',
- 'file_mime' => 'file-mime',
- 'file_mediatype' => 'file-mediatype',
- 'file_width' => 'file-width',
- 'file_height' => 'file-height',
- 'file_bits_per_channel' => 'file-bits-per-channel',
- ],
- ];
-
- /** @var array Old vars which aren't in use anymore */
- public static $disabledVars = [
- 'old_text' => 'old-text-stripped',
- 'old_html' => 'old-html'
- ];
-
- public static $deprecatedVars = [
- 'article_text' => 'page_title',
- 'article_prefixedtext' => 'page_prefixedtitle',
- 'article_namespace' => 'page_namespace',
- 'article_articleid' => 'page_id',
- 'article_restrictions_edit' => 'page_restrictions_edit',
- 'article_restrictions_move' => 'page_restrictions_move',
- 'article_restrictions_create' => 'page_restrictions_create',
- 'article_restrictions_upload' => 'page_restrictions_upload',
- 'article_recent_contributors' => 'page_recent_contributors',
- 'article_first_contributor' => 'page_first_contributor',
- 'moved_from_text' => 'moved_from_title',
- 'moved_from_prefixedtext' => 'moved_from_prefixedtitle',
- 'moved_from_articleid' => 'moved_from_id',
- 'moved_to_text' => 'moved_to_title',
- 'moved_to_prefixedtext' => 'moved_to_prefixedtitle',
- 'moved_to_articleid' => 'moved_to_id',
- ];
-
- public static $editboxName = null;
-
- /**
- * @param IContextSource $context
- * @param string $pageType
- * @param LinkRenderer $linkRenderer
- */
- public static function addNavigationLinks(
- IContextSource $context,
- $pageType,
- LinkRenderer $linkRenderer
- ) {
- $linkDefs = [
- 'home' => 'Special:AbuseFilter',
- 'recentchanges' => 'Special:AbuseFilter/history',
- 'examine' => 'Special:AbuseFilter/examine',
- 'log' => 'Special:AbuseLog',
- ];
-
- if ( $context->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) {
- $linkDefs = array_merge( $linkDefs, [
- 'test' => 'Special:AbuseFilter/test',
- 'tools' => 'Special:AbuseFilter/tools'
- ] );
- }
-
- if ( $context->getUser()->isAllowed( 'abusefilter-modify' ) ) {
- $linkDefs = array_merge( $linkDefs, [
- 'import' => 'Special:AbuseFilter/import'
- ] );
- }
-
- // Re-use the message
- $msgOverrides = [
- 'recentchanges' => 'abusefilter-filter-log',
- ];
-
- $links = [];
-
- foreach ( $linkDefs as $name => $page ) {
- // Give grep a chance to find the usages:
- // abusefilter-topnav-home, abusefilter-topnav-test, abusefilter-topnav-examine
- // abusefilter-topnav-log, abusefilter-topnav-tools, abusefilter-topnav-import
- $msgName = "abusefilter-topnav-$name";
-
- if ( isset( $msgOverrides[$name] ) ) {
- $msgName = $msgOverrides[$name];
- }
-
- $msg = $context->msg( $msgName )->parse();
- $title = Title::newFromText( $page );
-
- if ( $name == $pageType ) {
- $links[] = Xml::tags( 'strong', null, $msg );
- } else {
- $links[] = $linkRenderer->makeLink( $title, new HtmlArmor( $msg ) );
- }
- }
-
- $linkStr = $context->msg( 'parentheses' )
- ->rawParams( $context->getLanguage()->pipeList( $links ) )
- ->text();
- $linkStr = $context->msg( 'abusefilter-topnav' )->parse() . " $linkStr";
-
- $linkStr = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-navigation' ], $linkStr );
-
- $context->getOutput()->setSubtitle( $linkStr );
- }
/**
+ * @deprecated Since 1.35 Use VariableGenerator::addUserVars()
* @param User $user
+ * @param RecentChange|null $entry
* @return AbuseFilterVariableHolder
*/
- public static function generateUserVars( $user ) {
- $vars = new AbuseFilterVariableHolder;
-
- $vars->setLazyLoadVar(
- 'user_editcount',
- 'simple-user-accessor',
- [ 'user' => $user, 'method' => 'getEditCount' ]
- );
-
- $vars->setVar( 'user_name', $user->getName() );
-
- $vars->setLazyLoadVar(
- 'user_emailconfirm',
- 'simple-user-accessor',
- [ 'user' => $user, 'method' => 'getEmailAuthenticationTimestamp' ]
- );
-
- $vars->setLazyLoadVar(
- 'user_age',
- 'user-age',
- [ 'user' => $user, 'asof' => wfTimestampNow() ]
- );
-
- $vars->setLazyLoadVar(
- 'user_groups',
- 'simple-user-accessor',
- [ 'user' => $user, 'method' => 'getEffectiveGroups' ]
- );
-
- $vars->setLazyLoadVar(
- 'user_rights',
- 'simple-user-accessor',
- [ 'user' => $user, 'method' => 'getRights' ]
- );
-
- $vars->setLazyLoadVar(
- 'user_blocked',
- 'simple-user-accessor',
- [ 'user' => $user, 'method' => 'isBlocked' ]
- );
-
- Hooks::run( 'AbuseFilter-generateUserVars', [ $vars, $user ] );
-
- return $vars;
- }
-
- /**
- * @return array
- */
- public static function getBuilderValues() {
- static $realValues = null;
-
- if ( $realValues ) {
- return $realValues;
- }
-
- $realValues = self::$builderValues;
-
- Hooks::run( 'AbuseFilter-builder', [ &$realValues ] );
-
- return $realValues;
- }
-
- /**
- * @return array
- */
- public static function getDeprecatedVariables() {
- static $deprecatedVars = null;
-
- if ( $deprecatedVars ) {
- return $deprecatedVars;
- }
-
- $deprecatedVars = self::$deprecatedVars;
-
- Hooks::run( 'AbuseFilter-deprecatedVariables', [ &$deprecatedVars ] );
-
- return $deprecatedVars;
+ public static function generateUserVars( User $user, RecentChange $entry = null ) {
+ wfDeprecated( __METHOD__, '1.35' );
+ $vars = new AbuseFilterVariableHolder();
+ $generator = new VariableGenerator( $vars );
+ return $generator->addUserVars( $user, $entry )->getVariableHolder();
}
/**
- * @param string $filter
+ * @param int $filterID The ID of the filter
+ * @param bool|int $global Whether the filter is global
* @return bool
*/
- public static function filterHidden( $filter ) {
- $globalIndex = self::decodeGlobalName( $filter );
- if ( $globalIndex ) {
- global $wgAbuseFilterCentralDB;
+ public static function filterHidden( $filterID, $global = false ) {
+ global $wgAbuseFilterCentralDB;
+
+ if ( $global ) {
if ( !$wgAbuseFilterCentralDB ) {
return false;
}
- $dbr = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB );
- $filter = $globalIndex;
+ $dbr = self::getCentralDB( DB_REPLICA );
} else {
$dbr = wfGetDB( DB_REPLICA );
}
- if ( $filter === 'new' ) {
- return false;
- }
+
$hidden = $dbr->selectField(
'abuse_filter',
'af_hidden',
- [ 'af_id' => $filter ],
+ [ 'af_id' => $filterID ],
__METHOD__
);
@@ -379,441 +115,150 @@ class AbuseFilter {
}
/**
- * @param int $val
- * @throws MWException
- */
- public static function triggerLimiter( $val = 1 ) {
- self::$condCount += $val;
-
- global $wgAbuseFilterConditionLimit;
-
- if ( self::$condLimitEnabled && self::$condCount > $wgAbuseFilterConditionLimit ) {
- throw new MWException( 'Condition limit reached.' );
- }
- }
-
- /**
- * For use in batch scripts and the like
- */
- public static function disableConditionLimit() {
- self::$condLimitEnabled = false;
- }
-
- /**
+ * @deprecated Since 1.35 Use VariableGenerator::addTitleVars
* @param Title|null $title
* @param string $prefix
+ * @param RecentChange|null $entry
* @return AbuseFilterVariableHolder
*/
- public static function generateTitleVars( $title, $prefix ) {
- $vars = new AbuseFilterVariableHolder;
-
- if ( !$title ) {
+ public static function generateTitleVars( $title, $prefix, RecentChange $entry = null ) {
+ wfDeprecated( __METHOD__, '1.35' );
+ $vars = new AbuseFilterVariableHolder();
+ if ( !( $title instanceof Title ) ) {
return $vars;
}
-
- $vars->setVar( $prefix . '_ID', $title->getArticleID() );
- $vars->setVar( $prefix . '_NAMESPACE', $title->getNamespace() );
- $vars->setVar( $prefix . '_TITLE', $title->getText() );
- $vars->setVar( $prefix . '_PREFIXEDTITLE', $title->getPrefixedText() );
-
- global $wgRestrictionTypes;
- foreach ( $wgRestrictionTypes as $action ) {
- $vars->setLazyLoadVar( "{$prefix}_restrictions_$action", 'get-page-restrictions',
- [ 'title' => $title->getText(),
- 'namespace' => $title->getNamespace(),
- 'action' => $action
- ]
- );
- }
-
- $vars->setLazyLoadVar( "{$prefix}_recent_contributors", 'load-recent-authors',
- [
- 'title' => $title->getText(),
- 'namespace' => $title->getNamespace()
- ] );
-
- $vars->setLazyLoadVar( "{$prefix}_age", 'page-age',
- [
- 'title' => $title->getText(),
- 'namespace' => $title->getNamespace(),
- 'asof' => wfTimestampNow()
- ] );
-
- $vars->setLazyLoadVar( "{$prefix}_first_contributor", 'load-first-author',
- [
- 'title' => $title->getText(),
- 'namespace' => $title->getNamespace()
- ] );
-
- Hooks::run( 'AbuseFilter-generateTitleVars', [ $vars, $title, $prefix ] );
-
- return $vars;
- }
-
- /**
- * @param string $filter
- * @return true|array True when successful, otherwise a two-element array with exception message
- * and character position of the syntax error
- */
- public static function checkSyntax( $filter ) {
- global $wgAbuseFilterParserClass;
-
- /** @var $parser AbuseFilterParser */
- $parser = new $wgAbuseFilterParserClass;
-
- return $parser->checkSyntax( $filter );
+ $generator = new VariableGenerator( $vars );
+ return $generator->addTitleVars( $title, $prefix, $entry )->getVariableHolder();
}
/**
- * @param string $expr
- * @return string
+ * @deprecated Since 1.35 Use VariableGenerator::addGenericVars
+ * @return AbuseFilterVariableHolder
*/
- public static function evaluateExpression( $expr ) {
- global $wgAbuseFilterParserClass;
-
- if ( self::checkSyntax( $expr ) !== true ) {
- return 'BADSYNTAX';
- }
-
- /** @var $parser AbuseFilterParser */
- $parser = new $wgAbuseFilterParserClass;
-
- return $parser->evaluateExpression( $expr );
- }
-
- /**
- * @param string $conds
- * @param AbuseFilterVariableHolder $vars
- * @param bool $ignoreError
- * @return bool
- * @throws Exception
- */
- public static function checkConditions(
- $conds, $vars, $ignoreError = true
- ) {
- global $wgAbuseFilterParserClass;
-
- static $parser, $lastVars;
-
- if ( is_null( $parser ) || $vars !== $lastVars ) {
- /** @var $parser AbuseFilterParser */
- $parser = new $wgAbuseFilterParserClass( $vars );
- $lastVars = $vars;
- }
-
- try {
- $result = $parser->parse( $conds, self::$condCount );
- } catch ( Exception $excep ) {
- $result = false;
-
- $logger = LoggerFactory::getInstance( 'AbuseFilter' );
- $logger->debug( 'AbuseFilter parser error: ' . $excep->getMessage() );
-
- if ( !$ignoreError ) {
- throw $excep;
- }
- }
-
- return $result;
+ public static function generateGenericVars() {
+ wfDeprecated( __METHOD__, '1.35' );
+ $vars = new AbuseFilterVariableHolder();
+ $generator = new VariableGenerator( $vars );
+ return $generator->addGenericVars()->getVariableHolder();
}
/**
* Returns an associative array of filters which were tripped
*
* @param AbuseFilterVariableHolder $vars
+ * @param Title $title
* @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
- * @param Title|null $title
* @param string $mode 'execute' for edits and logs, 'stash' for cached matches
- *
* @return bool[] Map of (integer filter ID => bool)
+ * @deprecated Since 1.34 See comment on AbuseFilterRunner::checkAllFilters
*/
public static function checkAllFilters(
- $vars,
+ AbuseFilterVariableHolder $vars,
+ Title $title,
$group = 'default',
- Title $title = null,
$mode = 'execute'
) {
- global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral;
- global $wgAbuseFilterConditionLimit;
-
- // Ensure that we start fresh, see T193374
- self::$condCount = 0;
-
- // Fetch filters to check from the database.
- $filter_matched = [];
-
- $dbr = wfGetDB( DB_REPLICA );
- $fields = [
- 'af_id',
- 'af_pattern',
- 'af_public_comments',
- 'af_timestamp'
- ];
- $res = $dbr->select(
- 'abuse_filter',
- $fields,
- [
- 'af_enabled' => 1,
- 'af_deleted' => 0,
- 'af_group' => $group,
- ],
- __METHOD__
- );
-
- foreach ( $res as $row ) {
- $filter_matched[$row->af_id] = self::checkFilter( $row, $vars, $title, '', $mode );
- }
-
- if ( $wgAbuseFilterCentralDB && !$wgAbuseFilterIsCentral ) {
- // Global filters
- $globalRulesKey = self::getGlobalRulesKey( $group );
+ $parser = self::getDefaultParser( $vars );
+ $user = RequestContext::getMain()->getUser();
- $fname = __METHOD__;
- $res = ObjectCache::getMainWANInstance()->getWithSetCallback(
- $globalRulesKey,
- WANObjectCache::TTL_INDEFINITE,
- function () use ( $group, $fname, $fields ) {
- global $wgAbuseFilterCentralDB;
-
- $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
- $fdb = $lbFactory->getMainLB( $wgAbuseFilterCentralDB )->getConnectionRef(
- DB_REPLICA, [], $wgAbuseFilterCentralDB
- );
-
- return iterator_to_array( $fdb->select(
- 'abuse_filter',
- $fields,
- [
- 'af_enabled' => 1,
- 'af_deleted' => 0,
- 'af_global' => 1,
- 'af_group' => $group,
- ],
- $fname
- ) );
- },
- [
- 'checkKeys' => [ $globalRulesKey ],
- 'lockTSE' => 300
- ]
- );
-
- foreach ( $res as $row ) {
- $filter_matched['global-' . $row->af_id] =
- self::checkFilter( $row, $vars, $title, 'global-', $mode );
- }
- }
-
- if ( $title instanceof Title && self::$condCount > $wgAbuseFilterConditionLimit ) {
- $actionID = implode( '-', [
- $title->getPrefixedText(),
- $vars->getVar( 'user_name' )->toString(),
- $vars->getVar( 'action' )->toString()
- ] );
- self::bufferTagsToSetByAction( [ $actionID => [ 'abusefilter-condition-limit' ] ] );
- }
-
- if ( $mode === 'execute' ) {
- // Update statistics, and disable filters which are over-blocking.
- self::recordStats( $filter_matched, $group );
- }
-
- return $filter_matched;
+ $runner = new AbuseFilterRunner( $user, $title, $vars, $group );
+ $runner->parser = $parser;
+ return $runner->checkAllFilters();
}
/**
- * @param stdClass $row
- * @param AbuseFilterVariableHolder $vars
- * @param Title|null $title
- * @param string $prefix
- * @param string $mode 'execute' for edits and logs, 'stash' for cached matches
- * @return bool
- */
- public static function checkFilter(
- $row,
- $vars,
- Title $title = null,
- $prefix = '',
- $mode = 'execute'
- ) {
- global $wgAbuseFilterProfile, $wgAbuseFilterRuntimeProfile,
- $wgAbuseFilterSlowFilterRuntimeLimit;
-
- $filterID = $prefix . $row->af_id;
-
- // Record data to be used if profiling is enabled and mode is 'execute'
- $startConds = self::$condCount;
- $startTime = microtime( true );
-
- // Store the row somewhere convenient
- self::$filterCache[$filterID] = $row;
-
- $pattern = trim( $row->af_pattern );
- if (
- self::checkConditions(
- $pattern,
- $vars,
- // Ignore errors
- true
- )
- ) {
- // Record match.
- $result = true;
- } else {
- // Record non-match.
- $result = false;
- }
-
- $timeTaken = microtime( true ) - $startTime;
- $condsUsed = self::$condCount - $startConds;
-
- if ( $wgAbuseFilterProfile && $mode === 'execute' ) {
- self::recordProfilingResult( $row->af_id, $timeTaken, $condsUsed );
- }
-
- $runtime = $timeTaken * 1000;
- if ( $mode === 'execute' && $wgAbuseFilterRuntimeProfile &&
- $runtime > $wgAbuseFilterSlowFilterRuntimeLimit ) {
- self::recordSlowFilter( $filterID, $runtime, $condsUsed, $result, $title );
- }
-
- return $result;
- }
-
- /**
- * Logs slow filter's runtime data for later analysis
- *
- * @param string $filterId
- * @param float $runtime
- * @param int $totalConditions
- * @param bool $matched
- * @param Title|null $title
- */
- private static function recordSlowFilter(
- $filterId, $runtime, $totalConditions, $matched, Title $title = null
- ) {
- $title = $title ? $title->getPrefixedText() : '';
-
- $logger = LoggerFactory::getInstance( 'AbuseFilterSlow' );
- $logger->info(
- 'Edit filter {filter_id} on {wiki} is taking longer than expected',
- [
- 'wiki' => wfWikiID(),
- 'filter_id' => $filterId,
- 'title' => $title,
- 'runtime' => $runtime,
- 'matched' => $matched,
- 'total_conditions' => $totalConditions
- ]
- );
- }
-
- /**
- * @param int $filter
+ * @param int|string $filter
+ * @internal
*/
public static function resetFilterProfile( $filter ) {
- $stash = ObjectCache::getMainStashInstance();
- $countKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'count' );
- $totalKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'total' );
- $condsKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'conds' );
-
- $stash->delete( $countKey );
- $stash->delete( $totalKey );
- $stash->delete( $condsKey );
- }
+ $stash = MediaWikiServices::getInstance()->getMainObjectStash();
+ $profileKey = self::filterProfileKey( $filter );
- /**
- * @param int $filter
- * @param float $time
- * @param int $conds
- */
- public static function recordProfilingResult( $filter, $time, $conds ) {
- // Defer updates to avoid massive (~1 second) edit time increases
- DeferredUpdates::addCallableUpdate( function () use ( $filter, $time, $conds ) {
- $stash = ObjectCache::getMainStashInstance();
- $countKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'count' );
- $totalKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'total' );
- $condsKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'conds' );
-
- $curCount = $stash->get( $countKey );
- $curTotal = $stash->get( $totalKey );
- $curConds = $stash->get( $condsKey );
-
- if ( $curCount ) {
- $stash->set( $condsKey, $curConds + $conds, 3600 );
- $stash->set( $totalKey, $curTotal + $time, 3600 );
- $stash->incr( $countKey );
- } else {
- $stash->set( $countKey, 1, 3600 );
- $stash->set( $totalKey, $time, 3600 );
- $stash->set( $condsKey, $conds, 3600 );
- }
- } );
+ $stash->delete( $profileKey );
}
/**
+ * Retrieve per-filter statistics.
+ *
* @param string $filter
* @return array
*/
public static function getFilterProfile( $filter ) {
- $stash = ObjectCache::getMainStashInstance();
- $countKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'count' );
- $totalKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'total' );
- $condsKey = wfMemcKey( 'abusefilter', 'profile', $filter, 'conds' );
-
- $curCount = $stash->get( $countKey );
- $curTotal = $stash->get( $totalKey );
- $curConds = $stash->get( $condsKey );
+ $stash = MediaWikiServices::getInstance()->getMainObjectStash();
+ $profile = $stash->get( self::filterProfileKey( $filter ) );
- if ( !$curCount ) {
- return [ 0, 0 ];
+ if ( $profile !== false ) {
+ $curCount = $profile['count'];
+ $curTotalTime = $profile['total-time'];
+ $curTotalConds = $profile['total-cond'];
+ } else {
+ return [ 0, 0, 0, 0 ];
}
- // 1000 ms in a sec
- $timeProfile = ( $curTotal / $curCount ) * 1000;
- // Return in ms, rounded to 2dp
- $timeProfile = round( $timeProfile, 2 );
+ // Return in milliseconds, rounded to 2dp
+ $avgTime = round( $curTotalTime / $curCount, 2 );
+ $avgCond = round( $curTotalConds / $curCount, 1 );
- $condProfile = ( $curConds / $curCount );
- $condProfile = round( $condProfile, 0 );
-
- return [ $timeProfile, $condProfile ];
+ return [ $curCount, $profile['matches'], $avgTime, $avgCond ];
}
/**
- * Utility function to decode global-$index to $index. Returns false if not global
- *
- * @param string $filter
+ * Utility function to split "<GLOBAL_FILTER_PREFIX>$index" to an array [ $id, $global ], where
+ * $id is $index casted to int, and $global is a boolean: true if the filter is global,
+ * false otherwise (i.e. if the $filter === $index). Note that the $index
+ * is always casted to int. Passing anything which isn't an integer-like value or a string
+ * in the shape "<GLOBAL_FILTER_PREFIX>integer" will throw.
+ * This reverses self::buildGlobalName
*
- * @return string|bool
- */
- public static function decodeGlobalName( $filter ) {
- if ( strpos( $filter, 'global-' ) === 0 ) {
- return substr( $filter, strlen( 'global-' ) );
+ * @param string|int $filter
+ * @return array
+ * @throws InvalidArgumentException
+ */
+ public static function splitGlobalName( $filter ) {
+ if ( preg_match( '/^' . self::GLOBAL_FILTER_PREFIX . '\d+$/', $filter ) === 1 ) {
+ $id = intval( substr( $filter, strlen( self::GLOBAL_FILTER_PREFIX ) ) );
+ return [ $id, true ];
+ } elseif ( is_numeric( $filter ) ) {
+ return [ (int)$filter, false ];
+ } else {
+ throw new InvalidArgumentException( "Invalid filter name: $filter" );
}
+ }
- return false;
+ /**
+ * Given a filter ID and a boolean indicating whether it's global, build a string like
+ * "<GLOBAL_FILTER_PREFIX>$ID". Note that, with global = false, $id is casted to string.
+ * This reverses self::splitGlobalName.
+ *
+ * @param int $id The filter ID
+ * @param bool $global Whether the filter is global
+ * @return string
+ * @todo Calling this method should be avoided wherever possible
+ */
+ public static function buildGlobalName( $id, $global = true ) {
+ $prefix = $global ? self::GLOBAL_FILTER_PREFIX : '';
+ return "$prefix$id";
}
/**
* @param string[] $filters
- * @return array[]
+ * @return (string|array)[][][]
+ * @phan-return array<string,array<string,array{action:string,parameters:string[]}>>
*/
public static function getConsequencesForFilters( $filters ) {
$globalFilters = [];
$localFilters = [];
foreach ( $filters as $filter ) {
- $globalIndex = self::decodeGlobalName( $filter );
+ list( $filterID, $global ) = self::splitGlobalName( $filter );
- if ( $globalIndex ) {
- $globalFilters[] = $globalIndex;
+ if ( $global ) {
+ $globalFilters[] = $filterID;
} else {
$localFilters[] = $filter;
}
}
- global $wgAbuseFilterCentralDB;
// Load local filter info
$dbr = wfGetDB( DB_REPLICA );
// Retrieve the consequences.
@@ -824,8 +269,11 @@ class AbuseFilter {
}
if ( count( $globalFilters ) ) {
- $fdb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB );
- $consequences = $consequences + self::loadConsequencesFromDB( $fdb, $globalFilters, 'global-' );
+ $consequences += self::loadConsequencesFromDB(
+ self::getCentralDB( DB_REPLICA ),
+ $globalFilters,
+ self::GLOBAL_FILTER_PREFIX
+ );
}
return $consequences;
@@ -835,9 +283,10 @@ class AbuseFilter {
* @param IDatabase $dbr
* @param string[] $filters
* @param string $prefix
- * @return array[]
+ * @return (string|array)[][][]
+ * @phan-return array<string,array<string,array{action:string,parameters:string[]}>>
*/
- public static function loadConsequencesFromDB( $dbr, $filters, $prefix = '' ) {
+ public static function loadConsequencesFromDB( IDatabase $dbr, $filters, $prefix = '' ) {
$actionsByFilter = [];
foreach ( $filters as $filter ) {
$actionsByFilter[$prefix . $filter] = [];
@@ -858,8 +307,16 @@ class AbuseFilter {
if ( $row->af_throttled
&& !empty( $wgAbuseFilterRestrictions[$row->afa_consequence] )
) {
- // Don't do the action
- } elseif ( $row->afa_filter != $row->af_id ) {
+ // Don't do the action, just log
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->info(
+ 'Filter {filter_id} is throttled, skipping action: {action}',
+ [
+ 'filter_id' => $row->af_id,
+ 'action' => $row->afa_consequence
+ ]
+ );
+ } elseif ( $row->afa_filter !== $row->af_id ) {
// We probably got a NULL, as it's a LEFT JOIN. Don't add it.
} else {
$actionsByFilter[$prefix . $row->afa_filter][$row->afa_consequence] = [
@@ -873,552 +330,82 @@ class AbuseFilter {
}
/**
- * Executes a set of actions.
- *
- * @param string[] $filters
- * @param Title $title
- * @param AbuseFilterVariableHolder $vars
- * @param User $user
- * @return Status returns the operation's status. $status->isOK() will return true if
- * there were no actions taken, false otherwise. $status->getValue() will return
- * an array listing the actions taken. $status->getErrors() etc. will provide
- * the errors and warnings to be shown to the user to explain the actions.
- */
- public static function executeFilterActions( $filters, $title, $vars, User $user ) {
- global $wgMainCacheType;
-
- $actionsByFilter = self::getConsequencesForFilters( $filters );
- $actionsTaken = array_fill_keys( $filters, [] );
-
- $messages = [];
- // Accumulator to track max block to issue
- $maxExpiry = -1;
-
- global $wgAbuseFilterDisallowGlobalLocalBlocks, $wgAbuseFilterRestrictions,
- $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration;
- foreach ( $actionsByFilter as $filter => $actions ) {
- // Special-case handling for warnings.
- $filter_public_comments = self::getFilter( $filter )->af_public_comments;
-
- $global_filter = self::decodeGlobalName( $filter ) !== false;
-
- // If the filter has "throttle" enabled and throttling is available via object
- // caching, check to see if the user has hit the throttle.
- if ( !empty( $actions['throttle'] ) && $wgMainCacheType !== CACHE_NONE ) {
- $parameters = $actions['throttle']['parameters'];
- $throttleId = array_shift( $parameters );
- list( $rateCount, $ratePeriod ) = explode( ',', array_shift( $parameters ) );
-
- $hitThrottle = false;
-
- // The rest are throttle-types.
- foreach ( $parameters as $throttleType ) {
- $hitThrottle = $hitThrottle || self::isThrottled(
- $throttleId, $throttleType, $title, $rateCount, $ratePeriod, $global_filter );
- }
-
- unset( $actions['throttle'] );
- if ( !$hitThrottle ) {
- $actionsTaken[$filter][] = 'throttle';
- continue;
- }
- }
-
- if ( $wgAbuseFilterDisallowGlobalLocalBlocks && $global_filter ) {
- $actions = array_diff_key( $actions, array_filter( $wgAbuseFilterRestrictions ) );
- }
-
- if ( !empty( $actions['warn'] ) ) {
- $parameters = $actions['warn']['parameters'];
- $action = $vars->getVar( 'action' )->toString();
- // Generate a unique key to determine whether the user has already been warned.
- // We'll warn again if one of these changes: session, page, triggered filter or action
- $warnKey = 'abusefilter-warned-' . md5( $title->getPrefixedText() ) .
- '-' . $filter . '-' . $action;
-
- // Make sure the session is started prior to using it
- $session = SessionManager::getGlobalSession();
- $session->persist();
-
- if ( !isset( $session[$warnKey] ) || !$session[$warnKey] ) {
- $session[$warnKey] = true;
-
- // Threaten them a little bit
- if ( isset( $parameters[0] ) ) {
- $msg = $parameters[0];
- } else {
- $msg = 'abusefilter-warning';
- }
- $messages[] = [ $msg, $filter_public_comments, $filter ];
-
- $actionsTaken[$filter][] = 'warn';
-
- // Don't do anything else.
- continue;
- } else {
- // We already warned them
- $session[$warnKey] = false;
- }
-
- unset( $actions['warn'] );
- }
-
- // Prevent double warnings
- if ( count( array_intersect_key( $actions, array_filter( $wgAbuseFilterRestrictions ) ) ) > 0 &&
- !empty( $actions['disallow'] )
- ) {
- unset( $actions['disallow'] );
- }
-
- // Find out the max expiry to issue the longest triggered block.
- // Need to check here since methods like user->getBlock() aren't available
- if ( !empty( $actions['block'] ) ) {
- $parameters = $actions['block']['parameters'];
-
- if ( count( $parameters ) === 3 ) {
- // New type of filters with custom block
- if ( $user->isAnon() ) {
- $expiry = $parameters[1];
- } else {
- $expiry = $parameters[2];
- }
- } else {
- // Old type with fixed expiry
- if ( $user->isAnon() && $wgAbuseFilterAnonBlockDuration !== null ) {
- // The user isn't logged in and the anon block duration
- // doesn't default to $wgAbuseFilterBlockDuration.
- $expiry = $wgAbuseFilterAnonBlockDuration;
- } else {
- $expiry = $wgAbuseFilterBlockDuration;
- }
- }
-
- $currentExpiry = SpecialBlock::parseExpiryInput( $expiry );
- if ( $currentExpiry > SpecialBlock::parseExpiryInput( $maxExpiry ) ) {
- // Save the parameters to issue the block with
- $maxExpiry = $expiry;
- $blockValues = [
- self::getFilter( $filter )->af_public_comments,
- $filter,
- is_array( $parameters ) && in_array( 'blocktalk', $parameters )
- ];
- }
- unset( $actions['block'] );
- }
-
- // Do the rest of the actions
- foreach ( $actions as $action => $info ) {
- $newMsg = self::takeConsequenceAction(
- $action,
- $info['parameters'],
- $title,
- $vars,
- self::getFilter( $filter )->af_public_comments,
- $filter,
- $user
- );
-
- if ( $newMsg !== null ) {
- $messages[] = $newMsg;
- }
- $actionsTaken[$filter][] = $action;
- }
- }
-
- // Since every filter has been analysed, we now know what the
- // longest block duration is, so we can issue the block if
- // maxExpiry has been changed.
- if ( $maxExpiry !== -1 ) {
- self::doAbuseFilterBlock(
- [
- 'desc' => $blockValues[0],
- 'number' => $blockValues[1]
- ],
- $user->getName(),
- $maxExpiry,
- true,
- $blockValues[2]
- );
- $message = [
- 'abusefilter-blocked-display',
- $blockValues[0],
- $blockValues[1]
- ];
- // Manually add the message. If we're here, there is one.
- $messages[] = $message;
- $actionsTaken[ $blockValues[1] ][] = 'block';
- }
-
- return self::buildStatus( $actionsTaken, $messages );
- }
-
- /**
- * Constructs a Status object as returned by executeFilterActions() from the list of
- * actions taken and the corresponding list of messages.
- *
- * @param array[] $actionsTaken associative array mapping each filter to the list if
- * actions taken because of that filter.
- * @param array[] $messages a list of arrays, where each array contains a message key
- * followed by any message parameters.
- *
- * @return Status
- */
- protected static function buildStatus( array $actionsTaken, array $messages ) {
- $status = Status::newGood( $actionsTaken );
-
- foreach ( $messages as $msg ) {
- $status->fatal( ...$msg );
- }
-
- return $status;
- }
-
- /**
* @param AbuseFilterVariableHolder $vars
* @param Title $title
* @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
* @param User $user The user performing the action
- * @param string $mode Use 'execute' to run filters and log or 'stash' to only cache matches
* @return Status
+ * @deprecated Since 1.34 Build an AbuseFilterRunner instance and call run() on that.
*/
public static function filterAction(
- AbuseFilterVariableHolder $vars, Title $title, $group, User $user, $mode = 'execute'
+ AbuseFilterVariableHolder $vars, Title $title, $group, User $user
) {
- global $wgRequest, $wgAbuseFilterRuntimeProfile, $wgAbuseFilterLogIP;
-
- $logger = LoggerFactory::getInstance( 'StashEdit' );
- $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
-
- // Add vars from extensions
- Hooks::run( 'AbuseFilter-filterAction', [ &$vars, $title ] );
- $vars->setVar( 'context', 'filter' );
- $vars->setVar( 'timestamp', time() );
-
- // Get the stash key based on the relevant "input" variables
- $cache = ObjectCache::getLocalClusterInstance();
- $stashKey = self::getStashKey( $cache, $vars, $group );
- $isForEdit = ( $vars->getVar( 'action' )->toString() === 'edit' );
-
- if ( $wgAbuseFilterRuntimeProfile ) {
- $startTime = microtime( true );
- }
-
- $filter_matched = false;
- if ( $mode === 'execute' && $isForEdit ) {
- // Check the filter edit stash results first
- $cacheData = $cache->get( $stashKey );
- if ( $cacheData ) {
- $filter_matched = $cacheData['matches'];
- // Merge in any tags to apply to recent changes entries
- self::bufferTagsToSetByAction( $cacheData['tags'] );
- }
- }
-
- if ( is_array( $filter_matched ) ) {
- if ( $isForEdit && $mode !== 'stash' ) {
- $logger->info( __METHOD__ . ": cache hit for '$title' (key $stashKey)." );
- $statsd->increment( 'abusefilter.check-stash.hit' );
- }
- } else {
- $filter_matched = self::checkAllFilters( $vars, $group, $title, $mode );
- if ( $isForEdit && $mode !== 'stash' ) {
- $logger->info( __METHOD__ . ": cache miss for '$title' (key $stashKey)." );
- $statsd->increment( 'abusefilter.check-stash.miss' );
- }
- }
-
- if ( $mode === 'stash' ) {
- // Save the filter stash result and do nothing further
- $cacheData = [ 'matches' => $filter_matched, 'tags' => self::$tagsToSet ];
-
- // Add runtime metrics in cache for later use
- if ( $wgAbuseFilterRuntimeProfile ) {
- $cacheData['condCount'] = self::$condCount;
- $cacheData['runtime'] = ( microtime( true ) - $startTime ) * 1000;
- }
-
- $cache->set( $stashKey, $cacheData, $cache::TTL_MINUTE );
- $logger->debug( __METHOD__ . ": cache store for '$title' (key $stashKey)." );
- $statsd->increment( 'abusefilter.check-stash.store' );
-
- return Status::newGood();
- }
-
- $matched_filters = array_keys( array_filter( $filter_matched ) );
-
- // Save runtime metrics only on edits
- if ( $wgAbuseFilterRuntimeProfile && $mode === 'execute' && $isForEdit ) {
- if ( $cacheData ) {
- $runtime = $cacheData['runtime'];
- $condCount = $cacheData['condCount'];
- } else {
- $runtime = ( microtime( true ) - $startTime ) * 1000;
- $condCount = self::$condCount;
- }
-
- self::recordRuntimeProfilingResult( count( $matched_filters ), $condCount, $runtime );
- }
-
- if ( count( $matched_filters ) == 0 ) {
- $status = Status::newGood();
- } else {
- $status = self::executeFilterActions( $matched_filters, $title, $vars, $user );
- $actions_taken = $status->getValue();
- $action = $vars->getVar( 'ACTION' )->toString();
-
- // If $user isn't safe to load (e.g. a failure during
- // AbortAutoAccount), create a dummy anonymous user instead.
- $user = $user->isSafeToLoad() ? $user : new User;
-
- // Create a template
- $log_template = [
- 'afl_user' => $user->getId(),
- 'afl_user_text' => $user->getName(),
- 'afl_timestamp' => wfGetDB( DB_REPLICA )->timestamp(),
- 'afl_namespace' => $title->getNamespace(),
- 'afl_title' => $title->getDBkey(),
- 'afl_action' => $action,
- // DB field is not null, so nothing
- 'afl_ip' => ( $wgAbuseFilterLogIP ) ? $wgRequest->getIP() : ""
- ];
-
- // Hack to avoid revealing IPs of people creating accounts
- if ( !$user->getId() && ( $action == 'createaccount' || $action == 'autocreateaccount' ) ) {
- $log_template['afl_user_text'] = $vars->getVar( 'accountname' )->toString();
- }
-
- self::addLogEntries( $actions_taken, $log_template, $vars, $group );
- }
-
- return $status;
+ $runner = new AbuseFilterRunner( $user, $title, $vars, $group );
+ return $runner->run();
}
/**
- * @param string $id Filter ID (integer or "global-<integer>")
- * @return stdClass|null DB row
+ * @param string $filter Filter ID (integer or "<GLOBAL_FILTER_PREFIX><integer>")
+ * @return stdClass|null DB row on success, null on failure
*/
- public static function getFilter( $id ) {
+ public static function getFilter( $filter ) {
global $wgAbuseFilterCentralDB;
- if ( !isset( self::$filterCache[$id] ) ) {
- $globalIndex = self::decodeGlobalName( $id );
- if ( $globalIndex ) {
+ if ( !isset( self::$filterCache[$filter] ) ) {
+ list( $filterID, $global ) = self::splitGlobalName( $filter );
+ if ( $global ) {
// Global wiki filter
if ( !$wgAbuseFilterCentralDB ) {
return null;
}
-
- $id = $globalIndex;
- $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
- $lb = $lbFactory->getMainLB( $wgAbuseFilterCentralDB );
- $dbr = $lb->getConnectionRef( DB_REPLICA, [], $wgAbuseFilterCentralDB );
+ $dbr = self::getCentralDB( DB_REPLICA );
} else {
// Local wiki filter
$dbr = wfGetDB( DB_REPLICA );
}
- $fields = [
- 'af_id',
- 'af_pattern',
- 'af_public_comments',
- 'af_timestamp'
- ];
-
$row = $dbr->selectRow(
'abuse_filter',
- $fields,
- [ 'af_id' => $id ],
+ self::ALL_ABUSE_FILTER_FIELDS,
+ [ 'af_id' => $filterID ],
__METHOD__
);
- self::$filterCache[$id] = $row ?: null;
+ self::$filterCache[$filter] = $row ?: null;
}
- return self::$filterCache[$id];
+ return self::$filterCache[$filter];
}
/**
- * @param BagOStuff $cache
- * @param AbuseFilterVariableHolder $vars
- * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
- *
- * @return string
+ * Checks whether the given object represents a full abuse_filter DB row
+ * @param stdClass $row
+ * @return bool
*/
- private static function getStashKey(
- BagOStuff $cache, AbuseFilterVariableHolder $vars, $group
- ) {
- $inputVars = $vars->exportNonLazyVars();
- // Exclude noisy fields that have superficial changes
- $excludedVars = [
- 'old_html' => true,
- 'new_html' => true,
- 'user_age' => true,
- 'timestamp' => true,
- 'page_age' => true,
- 'moved_from_age' => true,
- 'moved_to_age' => true
- ];
+ public static function isFullAbuseFilterRow( stdClass $row ) {
+ $actual = array_keys( get_object_vars( $row ) );
- $inputVars = array_diff_key( $inputVars, $excludedVars );
- ksort( $inputVars );
- $hash = md5( serialize( $inputVars ) );
-
- return $cache->makeKey(
- 'abusefilter',
- 'check-stash',
- $group,
- $hash,
- 'v1'
- );
+ if (
+ count( $actual ) !== count( self::ALL_ABUSE_FILTER_FIELDS )
+ || array_diff( self::ALL_ABUSE_FILTER_FIELDS, $actual )
+ ) {
+ return false;
+ }
+ return true;
}
/**
- * @param array[] $actions_taken
- * @param array $log_template
- * @param AbuseFilterVariableHolder $vars
- * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
+ * Saves an abuse_filter row in cache
+ * @param string $id Filter ID (integer or "<GLOBAL_FILTER_PREFIX><integer>")
+ * @param stdClass $row A full abuse_filter row to save
+ * @throws UnexpectedValueException if the row is not full
*/
- public static function addLogEntries( $actions_taken, $log_template, $vars, $group = 'default' ) {
- $dbw = wfGetDB( DB_MASTER );
-
- $central_log_template = [
- 'afl_wiki' => wfWikiID(),
- ];
-
- $log_rows = [];
- $central_log_rows = [];
- $logged_local_filters = [];
- $logged_global_filters = [];
-
- foreach ( $actions_taken as $filter => $actions ) {
- $globalIndex = self::decodeGlobalName( $filter );
- $thisLog = $log_template;
- $thisLog['afl_filter'] = $filter;
- $thisLog['afl_actions'] = implode( ',', $actions );
-
- // Don't log if we were only throttling.
- if ( $thisLog['afl_actions'] != 'throttle' ) {
- $log_rows[] = $thisLog;
- // Global logging
- if ( $globalIndex ) {
- $title = Title::makeTitle( $thisLog['afl_namespace'], $thisLog['afl_title'] );
- $centralLog = $thisLog + $central_log_template;
- $centralLog['afl_filter'] = $globalIndex;
- $centralLog['afl_title'] = $title->getPrefixedText();
- $centralLog['afl_namespace'] = 0;
-
- $central_log_rows[] = $centralLog;
- $logged_global_filters[] = $globalIndex;
- } else {
- $logged_local_filters[] = $filter;
- }
- }
+ public static function cacheFilter( $id, $row ) {
+ // Check that all fields have been passed, otherwise using self::getFilter for this
+ // row will return partial data.
+ if ( !self::isFullAbuseFilterRow( $row ) ) {
+ throw new UnexpectedValueException( 'The specified row must be a full abuse_filter row.' );
}
-
- if ( !count( $log_rows ) ) {
- return;
- }
-
- // Only store the var dump if we're actually going to add log rows.
- $var_dump = self::storeVarDump( $vars );
- // To distinguish from stuff stored directly
- $var_dump = "stored-text:$var_dump";
-
- $stash = ObjectCache::getMainStashInstance();
-
- // Increment trigger counter
- $stash->incr( self::filterMatchesKey() );
-
- $local_log_ids = [];
- global $wgAbuseFilterNotifications, $wgAbuseFilterNotificationsPrivate;
- foreach ( $log_rows as $data ) {
- $data['afl_var_dump'] = $var_dump;
- $data['afl_id'] = $dbw->nextSequenceValue( 'abuse_filter_log_afl_id_seq' );
- $dbw->insert( 'abuse_filter_log', $data, __METHOD__ );
- $local_log_ids[] = $data['afl_id'] = $dbw->insertId();
- // Give grep a chance to find the usages:
- // logentry-abusefilter-hit
- $entry = new ManualLogEntry( 'abusefilter', 'hit' );
- // Construct a user object
- $user = User::newFromId( $data['afl_user'] );
- $user->setName( $data['afl_user_text'] );
- $entry->setPerformer( $user );
- $entry->setTarget( Title::makeTitle( $data['afl_namespace'], $data['afl_title'] ) );
- // Additional info
- $entry->setParameters( [
- 'action' => $data['afl_action'],
- 'filter' => $data['afl_filter'],
- 'actions' => $data['afl_actions'],
- 'log' => $data['afl_id'],
- ] );
-
- // Send data to CheckUser if installed and we
- // aren't already sending a notification to recentchanges
- if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' )
- && strpos( $wgAbuseFilterNotifications, 'rc' ) === false
- ) {
- $rc = $entry->getRecentChange();
- CheckUserHooks::updateCheckUserData( $rc );
- }
-
- if ( $wgAbuseFilterNotifications !== false ) {
- if ( self::filterHidden( $data['afl_filter'] ) && !$wgAbuseFilterNotificationsPrivate ) {
- continue;
- }
- $entry->publish( 0, $wgAbuseFilterNotifications );
- }
- }
-
- $method = __METHOD__;
-
- if ( count( $logged_local_filters ) ) {
- // Update hit-counter.
- $dbw->onTransactionPreCommitOrIdle(
- function () use ( $dbw, $logged_local_filters, $method ) {
- $dbw->update( 'abuse_filter',
- [ 'af_hit_count=af_hit_count+1' ],
- [ 'af_id' => $logged_local_filters ],
- $method
- );
- }
- );
- }
-
- $global_log_ids = [];
-
- // Global stuff
- if ( count( $logged_global_filters ) ) {
- $vars->computeDBVars();
- $global_var_dump = self::storeVarDump( $vars, true );
- $global_var_dump = "stored-text:$global_var_dump";
- foreach ( $central_log_rows as $index => $data ) {
- $central_log_rows[$index]['afl_var_dump'] = $global_var_dump;
- }
-
- global $wgAbuseFilterCentralDB;
- $fdb = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB );
-
- foreach ( $central_log_rows as $row ) {
- $fdb->insert( 'abuse_filter_log', $row, __METHOD__ );
- $global_log_ids[] = $fdb->insertId();
- }
-
- $fdb->onTransactionPreCommitOrIdle(
- function () use ( $fdb, $logged_global_filters, $method ) {
- $fdb->update( 'abuse_filter',
- [ 'af_hit_count=af_hit_count+1' ],
- [ 'af_id' => $logged_global_filters ],
- $method
- );
- }
- );
- }
-
- $vars->setVar( 'global_log_ids', $global_log_ids );
- $vars->setVar( 'local_log_ids', $local_log_ids );
-
- // Check for emergency disabling.
- $total = $stash->get( self::filterUsedKey( $group ) );
- self::checkEmergencyDisable( $group, $logged_local_filters, $total );
+ self::$filterCache[$id] = $row;
}
/**
@@ -1428,9 +415,9 @@ class AbuseFilter {
* @param AbuseFilterVariableHolder $vars
* @param bool $global
*
- * @return int|null
+ * @return int The insert ID.
*/
- public static function storeVarDump( $vars, $global = false ) {
+ public static function storeVarDump( AbuseFilterVariableHolder $vars, $global = false ) {
global $wgCompressRevisions;
// Get all variables yet set and compute old and new wikitext if not yet done
@@ -1438,14 +425,12 @@ class AbuseFilter {
$vars = $vars->dumpAllVars( [ 'old_wikitext', 'new_wikitext' ] );
// Vars is an array with native PHP data types (non-objects) now
- $text = serialize( $vars );
- $flags = [ 'nativeDataArray' ];
+ $text = FormatJson::encode( $vars );
+ $flags = [ 'utf-8' ];
- if ( $wgCompressRevisions ) {
- if ( function_exists( 'gzdeflate' ) ) {
- $text = gzdeflate( $text );
- $flags[] = 'gzip';
- }
+ if ( $wgCompressRevisions && function_exists( 'gzdeflate' ) ) {
+ $text = gzdeflate( $text );
+ $flags[] = 'gzip';
}
// Store to ExternalStore if applicable
@@ -1456,24 +441,18 @@ class AbuseFilter {
} else {
$text = ExternalStore::insertToDefault( $text );
}
- $flags[] = 'external';
- if ( !$text ) {
- // Not mission-critical, just return nothing
- return null;
- }
+ $flags[] = 'external';
}
// Store to text table
if ( $global ) {
- $dbw = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB );
+ $dbw = self::getCentralDB( DB_MASTER );
} else {
$dbw = wfGetDB( DB_MASTER );
}
- $old_id = $dbw->nextSequenceValue( 'text_old_id_seq' );
$dbw->insert( 'text',
[
- 'old_id' => $old_id,
'old_text' => $text,
'old_flags' => implode( ',', $flags ),
], __METHOD__
@@ -1488,25 +467,27 @@ class AbuseFilter {
*
* @param string $stored_dump
*
- * @return array|object|AbuseFilterVariableHolder|bool
+ * @return AbuseFilterVariableHolder
*/
- public static function loadVarDump( $stored_dump ) {
- // Backward compatibility
- if ( substr( $stored_dump, 0, strlen( 'stored-text:' ) ) !== 'stored-text:' ) {
+ public static function loadVarDump( $stored_dump ) : AbuseFilterVariableHolder {
+ // Backward compatibility for (old) blobs stored in the abuse_filter_log table
+ if ( !is_numeric( $stored_dump ) &&
+ substr( $stored_dump, 0, strlen( 'stored-text:' ) ) !== 'stored-text:' &&
+ substr( $stored_dump, 0, strlen( 'tt:' ) ) !== 'tt:'
+ ) {
$data = unserialize( $stored_dump );
- if ( is_array( $data ) ) {
- $vh = new AbuseFilterVariableHolder;
- foreach ( $data as $name => $value ) {
- $vh->setVar( $name, $value );
- }
-
- return $vh;
- } else {
- return $data;
- }
+ return is_array( $data ) ? AbuseFilterVariableHolder::newFromArray( $data ) : $data;
}
- $text_id = substr( $stored_dump, strlen( 'stored-text:' ) );
+ if ( is_numeric( $stored_dump ) ) {
+ $text_id = (int)$stored_dump;
+ } elseif ( strpos( $stored_dump, 'stored-text:' ) !== false ) {
+ $text_id = (int)str_replace( 'stored-text:', '', $stored_dump );
+ } elseif ( strpos( $stored_dump, 'tt:' ) !== false ) {
+ $text_id = (int)str_replace( 'tt:', '', $stored_dump );
+ } else {
+ throw new LogicException( "Cannot understand format: $stored_dump" );
+ }
$dbr = wfGetDB( DB_REPLICA );
@@ -1518,10 +499,12 @@ class AbuseFilter {
);
if ( !$text_row ) {
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->warning( __METHOD__ . ": no text row found for input $stored_dump." );
return new AbuseFilterVariableHolder;
}
- $flags = explode( ',', $text_row->old_flags );
+ $flags = $text_row->old_flags === '' ? [] : explode( ',', $text_row->old_flags );
$text = $text_row->old_text;
if ( in_array( 'external', $flags ) ) {
@@ -1532,366 +515,60 @@ class AbuseFilter {
$text = gzinflate( $text );
}
- $obj = unserialize( $text );
+ $obj = FormatJson::decode( $text, true );
+ if ( $obj === null ) {
+ // Temporary code until all rows will be JSON-encoded
+ $obj = unserialize( $text );
+ }
- if ( in_array( 'nativeDataArray', $flags ) ) {
+ if ( in_array( 'nativeDataArray', $flags ) ||
+ // Temporary condition: we don't add the flag anymore, but the updateVarDump
+ // script could be still running and we cannot assume that this branch is the default.
+ ( is_array( $obj ) && array_key_exists( 'action', $obj ) )
+ ) {
$vars = $obj;
- $obj = new AbuseFilterVariableHolder();
- foreach ( $vars as $key => $value ) {
- $obj->setVar( $key, $value );
- }
- // If old variable names are used, make sure to keep them
- if ( count( array_intersect_key( self::getDeprecatedVariables(), $obj->mVars ) ) !== 0 ) {
- $obj->mVarsVersion = 1;
- }
+ $obj = AbuseFilterVariableHolder::newFromArray( $vars );
+ $obj->translateDeprecatedVars();
}
return $obj;
}
/**
- * @param string $action
- * @param array $parameters
- * @param Title $title
- * @param AbuseFilterVariableHolder $vars
- * @param string $rule_desc
- * @param int|string $rule_number
- * @param User $user
+ * Get an identifier for the given action to be used in self::$tagsToSet
*
- * @return array|null a message describing the action that was taken,
- * or null if no action was taken. The message is given as an array
- * containing the message key followed by any message parameters.
+ * @param string $action The name of the current action, as used by AbuseFilter (e.g. 'edit'
+ * or 'createaccount')
+ * @param Title $title The title where the current action is executed on. This is the user page
+ * for account creations.
+ * @param string $username Of the user executing the action (as returned by User::getName()).
+ * For account creation, this is the name of the new account.
+ * @return string
*/
- public static function takeConsequenceAction( $action, $parameters, $title,
- $vars, $rule_desc, $rule_number, User $user ) {
- global $wgAbuseFilterCustomActionsHandlers, $wgRequest;
-
- $message = null;
-
- switch ( $action ) {
- case 'disallow':
- if ( isset( $parameters[0] ) ) {
- $message = [ $parameters[0], $rule_desc, $rule_number ];
- } else {
- // Generic message.
- $message = [
- 'abusefilter-disallowed',
- $rule_desc,
- $rule_number
- ];
- }
- break;
- case 'rangeblock':
- global $wgAbuseFilterRangeBlockSize, $wgBlockCIDRLimit;
-
- $ip = $wgRequest->getIP();
- if ( IP::isIPv6( $ip ) ) {
- $CIDRsize = max( $wgAbuseFilterRangeBlockSize['IPv6'], $wgBlockCIDRLimit['IPv6'] );
- } else {
- $CIDRsize = max( $wgAbuseFilterRangeBlockSize['IPv4'], $wgBlockCIDRLimit['IPv4'] );
- }
- $blockCIDR = $ip . '/' . $CIDRsize;
- self::doAbuseFilterBlock(
- [
- 'desc' => $rule_desc,
- 'number' => $rule_number
- ],
- IP::sanitizeRange( $blockCIDR ),
- '1 week',
- false
- );
-
- $message = [
- 'abusefilter-blocked-display',
- $rule_desc,
- $rule_number
- ];
- break;
- case 'degroup':
- if ( !$user->isAnon() ) {
- // Remove all groups from the user.
- $groups = $user->getGroups();
- // Make sure that the stored var dump contains user groups, since we may
- // need them if reverting this degroup via Special:AbuseFilter/revert
- $vars->setVar( 'user_groups', $groups );
-
- foreach ( $groups as $group ) {
- $user->removeGroup( $group );
- }
-
- $message = [
- 'abusefilter-degrouped',
- $rule_desc,
- $rule_number
- ];
-
- // Don't log it if there aren't any groups being removed!
- if ( !count( $groups ) ) {
- break;
- }
-
- $logEntry = new ManualLogEntry( 'rights', 'rights' );
- $logEntry->setPerformer( self::getFilterUser() );
- $logEntry->setTarget( $user->getUserPage() );
- $logEntry->setComment(
- wfMessage(
- 'abusefilter-degroupreason',
- $rule_desc,
- $rule_number
- )->inContentLanguage()->text()
- );
- $logEntry->setParameters( [
- '4::oldgroups' => $groups,
- '5::newgroups' => []
- ] );
- $logEntry->publish( $logEntry->insert() );
- }
-
- break;
- case 'blockautopromote':
- if ( !$user->isAnon() ) {
- // Block for 3-7 days.
- $blockPeriod = (int)mt_rand( 3 * 86400, 7 * 86400 );
- ObjectCache::getMainStashInstance()->set(
- self::autoPromoteBlockKey( $user ), true, $blockPeriod
- );
-
- $message = [
- 'abusefilter-autopromote-blocked',
- $rule_desc,
- $rule_number
- ];
- }
- break;
-
- case 'block':
- // Do nothing, handled at the end of executeFilterActions. Here for completeness.
- break;
- case 'flag':
- // Do nothing. Here for completeness.
- break;
-
- case 'tag':
- // Mark with a tag on recentchanges.
- $actionID = implode( '-', [
- $title->getPrefixedText(), $user->getName(),
- $vars->getVar( 'ACTION' )->toString()
- ] );
-
- self::bufferTagsToSetByAction( [ $actionID => $parameters ] );
- break;
- default:
- if ( isset( $wgAbuseFilterCustomActionsHandlers[$action] ) ) {
- $custom_function = $wgAbuseFilterCustomActionsHandlers[$action];
- if ( is_callable( $custom_function ) ) {
- $msg = call_user_func(
- $custom_function,
- $action,
- $parameters,
- $title,
- $vars,
- $rule_desc,
- $rule_number
- );
- }
- if ( isset( $msg ) ) {
- $message = [ $msg ];
- }
- } else {
- $logger = LoggerFactory::getInstance( 'AbuseFilter' );
- $logger->debug( "Unrecognised action $action" );
- }
- }
-
- return $message;
+ public static function getTaggingActionId( $action, Title $title, $username ) {
+ return implode(
+ '-',
+ [
+ $title->getPrefixedText(),
+ $username,
+ $action
+ ]
+ );
}
/**
* @param array[] $tagsByAction Map of (integer => string[])
*/
- private static function bufferTagsToSetByAction( array $tagsByAction ) {
- global $wgAbuseFilterActions;
- if ( isset( $wgAbuseFilterActions['tag'] ) && $wgAbuseFilterActions['tag'] ) {
- foreach ( $tagsByAction as $actionID => $tags ) {
- if ( !isset( self::$tagsToSet[$actionID] ) ) {
- self::$tagsToSet[$actionID] = $tags;
- } else {
- self::$tagsToSet[$actionID] = array_merge( self::$tagsToSet[$actionID], $tags );
- }
- }
- }
- }
-
- /**
- * Perform a block by the AbuseFilter system user
- * @param array $rule should have 'desc' and 'number'
- * @param string $target
- * @param string $expiry
- * @param bool $isAutoBlock
- * @param bool $preventEditOwnUserTalk
- */
- protected static function doAbuseFilterBlock(
- array $rule,
- $target,
- $expiry,
- $isAutoBlock,
- $preventEditOwnUserTalk = false
- ) {
- $filterUser = self::getFilterUser();
- $reason = wfMessage(
- 'abusefilter-blockreason',
- $rule['desc'], $rule['number']
- )->inContentLanguage()->text();
-
- $block = new Block();
- $block->setTarget( $target );
- $block->setBlocker( $filterUser );
- $block->mReason = $reason;
- $block->isHardblock( false );
- $block->isAutoblocking( $isAutoBlock );
- $block->prevents( 'createaccount', true );
- $block->prevents( 'editownusertalk', $preventEditOwnUserTalk );
- $block->mExpiry = SpecialBlock::parseExpiryInput( $expiry );
-
- $success = $block->insert();
-
- if ( $success ) {
- // Log it only if the block was successful
- $logParams = [];
- $logParams['5::duration'] = ( $block->mExpiry === 'infinity' )
- ? 'indefinite'
- : $expiry;
- $flags = [ 'nocreate' ];
- if ( !$block->isAutoblocking() && !IP::isIPAddress( $target ) ) {
- // Conditionally added same as SpecialBlock
- $flags[] = 'noautoblock';
- }
- if ( $preventEditOwnUserTalk === true ) {
- $flags[] = 'nousertalk';
+ public static function bufferTagsToSetByAction( array $tagsByAction ) {
+ foreach ( $tagsByAction as $actionID => $tags ) {
+ if ( !isset( self::$tagsToSet[ $actionID ] ) ) {
+ self::$tagsToSet[ $actionID ] = $tags;
+ } else {
+ self::$tagsToSet[ $actionID ] = array_unique(
+ array_merge( self::$tagsToSet[ $actionID ], $tags )
+ );
}
- $logParams['6::flags'] = implode( ',', $flags );
-
- $logEntry = new ManualLogEntry( 'block', 'block' );
- $logEntry->setTarget( Title::makeTitle( NS_USER, $target ) );
- $logEntry->setComment( $reason );
- $logEntry->setPerformer( $filterUser );
- $logEntry->setParameters( $logParams );
- $blockIds = array_merge( [ $success['id'] ], $success['autoIds'] );
- $logEntry->setRelations( [ 'ipb_id' => $blockIds ] );
- $logEntry->publish( $logEntry->insert() );
- }
- }
-
- /**
- * @param string $throttleId
- * @param string $types
- * @param Title $title
- * @param string $rateCount
- * @param string $ratePeriod
- * @param bool $global
- * @return bool
- */
- public static function isThrottled( $throttleId, $types, $title, $rateCount,
- $ratePeriod, $global = false
- ) {
- $stash = ObjectCache::getMainStashInstance();
- $key = self::throttleKey( $throttleId, $types, $title, $global );
- $count = intval( $stash->get( $key ) );
-
- $logger = LoggerFactory::getInstance( 'AbuseFilter' );
- $logger->debug( "Got value $count for throttle key $key" );
-
- if ( $count > 0 ) {
- $stash->incr( $key );
- $count++;
- $logger->debug( "Incremented throttle key $key" );
- } else {
- $logger->debug( "Added throttle key $key with value 1" );
- $stash->add( $key, 1, $ratePeriod );
- $count = 1;
- }
-
- if ( $count > $rateCount ) {
- $logger->debug( "Throttle $key hit value $count -- maximum is $rateCount." );
-
- // THROTTLED
- return true;
- }
-
- $logger->debug( "Throttle $key not hit!" );
-
- // NOT THROTTLED
- return false;
- }
-
- /**
- * @param string $type
- * @param Title $title
- * @return int|string
- */
- public static function throttleIdentifier( $type, $title ) {
- global $wgUser, $wgRequest;
-
- switch ( $type ) {
- case 'ip':
- $identifier = $wgRequest->getIP();
- break;
- case 'user':
- $identifier = $wgUser->getId();
- break;
- case 'range':
- $identifier = substr( IP::toHex( $wgRequest->getIP() ), 0, 4 );
- break;
- case 'creationdate':
- $reg = $wgUser->getRegistration();
- $identifier = $reg - ( $reg % 86400 );
- break;
- case 'editcount':
- // Hack for detecting different single-purpose accounts.
- $identifier = $wgUser->getEditCount();
- break;
- case 'site':
- $identifier = 1;
- break;
- case 'page':
- $identifier = $title->getPrefixedText();
- break;
- default:
- $identifier = 0;
}
-
- return $identifier;
- }
-
- /**
- * @param string $throttleId
- * @param string $type
- * @param Title $title
- * @param bool $global
- * @return string
- */
- public static function throttleKey( $throttleId, $type, $title, $global = false ) {
- $types = explode( ',', $type );
-
- $identifiers = [];
-
- foreach ( $types as $subtype ) {
- $identifiers[] = self::throttleIdentifier( $subtype, $title );
- }
-
- $identifier = sha1( implode( ':', $identifiers ) );
-
- global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB;
-
- if ( $global && !$wgAbuseFilterIsCentral ) {
- list( $globalSite, $globalPrefix ) = wfSplitWikiID( $wgAbuseFilterCentralDB );
-
- return wfForeignMemcKey(
- $globalSite, $globalPrefix,
- 'abusefilter', 'throttle', $throttleId, $type, $identifier );
- }
-
- return wfMemcKey( 'abusefilter', 'throttle', $throttleId, $type, $identifier );
}
/**
@@ -1901,176 +578,159 @@ class AbuseFilter {
public static function getGlobalRulesKey( $group ) {
global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB;
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
if ( !$wgAbuseFilterIsCentral ) {
- list( $globalSite, $globalPrefix ) = wfSplitWikiID( $wgAbuseFilterCentralDB );
-
- return wfForeignMemcKey(
- $globalSite, $globalPrefix,
- 'abusefilter', 'rules', $group
- );
+ return $cache->makeGlobalKey( 'abusefilter', 'rules', $wgAbuseFilterCentralDB, $group );
}
- return wfMemcKey( 'abusefilter', 'rules', $group );
+ return $cache->makeKey( 'abusefilter', 'rules', $group );
}
/**
- * @param User $user
- * @return string
+ * Gets the autopromotion block status for the given user
+ *
+ * @param User $target
+ * @return int
*/
- public static function autoPromoteBlockKey( $user ) {
- return wfMemcKey( 'abusefilter', 'block-autopromote', $user->getId() );
+ public static function getAutoPromoteBlockStatus( User $target ) {
+ $store = ObjectCache::getInstance( 'db-replicated' );
+
+ return (int)$store->get( self::autoPromoteBlockKey( $store, $target ) );
}
/**
- * Update statistics, and disable filters which are over-blocking.
- * @param bool[] $filters
- * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
- */
- public static function recordStats( $filters, $group = 'default' ) {
- global $wgAbuseFilterConditionLimit, $wgAbuseFilterProfileActionsCap;
-
- $stash = ObjectCache::getMainStashInstance();
-
- // Figure out if we've triggered overflows and blocks.
- $overflow_triggered = ( self::$condCount > $wgAbuseFilterConditionLimit );
-
- $overflow_key = self::filterLimitReachedKey();
- $total_key = self::filterUsedKey( $group );
-
- $total = $stash->get( $total_key );
-
- $storage_period = self::$statsStoragePeriod;
-
- if ( !$total || $total > $wgAbuseFilterProfileActionsCap ) {
- // This is for if the total doesn't exist, or has gone past 10,000.
- // Recreate all the keys at the same time, so they expire together.
- $stash->set( $total_key, 0, $storage_period );
- $stash->set( $overflow_key, 0, $storage_period );
-
- foreach ( $filters as $filter => $matched ) {
- $stash->set( self::filterMatchesKey( $filter ), 0, $storage_period );
- }
- $stash->set( self::filterMatchesKey(), 0, $storage_period );
+ * Blocks autopromotion for the given user
+ *
+ * @param User $target
+ * @param string $msg The message to show in the log
+ * @param int $duration Duration for which autopromotion is blocked, in seconds
+ * @return bool True on success, false on failure
+ */
+ public static function blockAutoPromote( User $target, $msg, int $duration ) {
+ $store = ObjectCache::getInstance( 'db-replicated' );
+ if ( !$store->set(
+ self::autoPromoteBlockKey( $store, $target ),
+ 1,
+ $duration
+ ) ) {
+ // Failed to set key
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->warning(
+ "Failed to block autopromotion for $target. Error: " . $store->getLastError()
+ );
+ return false;
}
- $stash->incr( $total_key );
+ $logEntry = new ManualLogEntry( 'rights', 'blockautopromote' );
+ $performer = self::getFilterUser();
+ $logEntry->setPerformer( $performer );
+ $logEntry->setTarget( $target->getUserPage() );
- // Increment overflow counter, if our condition limit overflowed
- if ( $overflow_triggered ) {
- $stash->incr( $overflow_key );
- }
+ $logEntry->setParameters( [
+ '7::duration' => $duration,
+ // These parameters are unused in our message, but some parts of the code check for them
+ '4::oldgroups' => [],
+ '5::newgroups' => []
+ ] );
+ $logEntry->setComment( $msg );
+ $logEntry->publish( $logEntry->insert() );
+
+ return true;
}
/**
- * Record runtime profiling data
+ * Unblocks autopromotion for the given user
*
- * @param int $totalFilters
- * @param int $totalConditions
- * @param float $runtime
- */
- private static function recordRuntimeProfilingResult( $totalFilters, $totalConditions, $runtime ) {
- $keyPrefix = 'abusefilter.runtime-profile.' . wfWikiID() . '.';
+ * @param User $target
+ * @param User $performer
+ * @param string $msg The message to show in the log
+ * @return bool True on success, false on failure
+ */
+ public static function unblockAutopromote( User $target, User $performer, $msg ) {
+ // Immediately expire (delete) the key, failing if it does not exist
+ $store = ObjectCache::getInstance( 'db-replicated' );
+ $expireAt = time() - $store::TTL_HOUR;
+ if ( !$store->changeTTL( self::autoPromoteBlockKey( $store, $target ), $expireAt ) ) {
+ // Key did not exist to begin with; nothing to do
+ return false;
+ }
- $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
- $statsd->timing( $keyPrefix . 'runtime', $runtime );
- $statsd->timing( $keyPrefix . 'total_filters', $totalFilters );
- $statsd->timing( $keyPrefix . 'total_conditions', $totalConditions );
+ $logEntry = new ManualLogEntry( 'rights', 'restoreautopromote' );
+ $logEntry->setTarget( Title::makeTitle( NS_USER, $target->getName() ) );
+ $logEntry->setComment( $msg );
+ // These parameters are unused in our message, but some parts of the code check for them
+ $logEntry->setParameters( [
+ '4::oldgroups' => [],
+ '5::newgroups' => []
+ ] );
+ $logEntry->setPerformer( $performer );
+ $logEntry->publish( $logEntry->insert() );
+
+ return true;
}
/**
- * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
- * @param string[] $filters
- * @param int $total
+ * @param BagOStuff $store
+ * @param User $target
+ * @return string
+ * @internal
*/
- public static function checkEmergencyDisable( $group, $filters, $total ) {
- global $wgAbuseFilterEmergencyDisableThreshold, $wgAbuseFilterEmergencyDisableCount,
- $wgAbuseFilterEmergencyDisableAge;
-
- $stash = ObjectCache::getMainStashInstance();
- foreach ( $filters as $filter ) {
- // Determine emergency disable values for this action
- $emergencyDisableThreshold =
- self::getEmergencyValue( $wgAbuseFilterEmergencyDisableThreshold, $group );
- $filterEmergencyDisableCount =
- self::getEmergencyValue( $wgAbuseFilterEmergencyDisableCount, $group );
- $emergencyDisableAge =
- self::getEmergencyValue( $wgAbuseFilterEmergencyDisableAge, $group );
-
- // Increment counter
- $matchCount = $stash->get( self::filterMatchesKey( $filter ) );
-
- // Handle missing keys...
- if ( !$matchCount ) {
- $stash->set( self::filterMatchesKey( $filter ), 1, self::$statsStoragePeriod );
- } else {
- $stash->incr( self::filterMatchesKey( $filter ) );
- }
- $matchCount++;
-
- // Figure out if the filter is subject to being deleted.
- $filter_age = wfTimestamp( TS_UNIX, self::getFilter( $filter )->af_timestamp );
- $throttle_exempt_time = $filter_age + $emergencyDisableAge;
-
- if ( $total && $throttle_exempt_time > time()
- && $matchCount > $filterEmergencyDisableCount
- && ( $matchCount / $total ) > $emergencyDisableThreshold
- ) {
- // More than $wgAbuseFilterEmergencyDisableCount matches,
- // constituting more than $emergencyDisableThreshold
- // (a fraction) of last few edits. Disable it.
- DeferredUpdates::addUpdate(
- new AutoCommitUpdate(
- wfGetDB( DB_MASTER ),
- __METHOD__,
- function ( IDatabase $dbw, $fname ) use ( $filter ) {
- $dbw->update( 'abuse_filter',
- [ 'af_throttled' => 1 ],
- [ 'af_id' => $filter ],
- $fname
- );
- }
- )
- );
- }
- }
+ public static function autoPromoteBlockKey( BagOStuff $store, User $target ) {
+ return $store->makeKey( 'abusefilter', 'block-autopromote', $target->getId() );
}
/**
- * @param array $emergencyValue
+ * @param string $type The value to get, either "threshold", "count" or "age"
* @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
* @return mixed
*/
- public static function getEmergencyValue( array $emergencyValue, $group ) {
- return $emergencyValue[$group] ?? $emergencyValue['default'];
- }
+ public static function getEmergencyValue( $type, $group ) {
+ switch ( $type ) {
+ case 'threshold':
+ global $wgAbuseFilterEmergencyDisableThreshold;
+ $value = $wgAbuseFilterEmergencyDisableThreshold;
+ break;
+ case 'count':
+ global $wgAbuseFilterEmergencyDisableCount;
+ $value = $wgAbuseFilterEmergencyDisableCount;
+ break;
+ case 'age':
+ global $wgAbuseFilterEmergencyDisableAge;
+ $value = $wgAbuseFilterEmergencyDisableAge;
+ break;
+ default:
+ throw new InvalidArgumentException( '$type must be either "threshold", "count" or "age"' );
+ }
- /**
- * @return string
- */
- public static function filterLimitReachedKey() {
- return wfMemcKey( 'abusefilter', 'stats', 'overflow' );
+ return $value[$group] ?? $value['default'];
}
/**
- * @param string|null $group The filter's group (as defined in $wgAbuseFilterValidGroups)
+ * Get the memcache access key used to store per-filter profiling data.
+ *
+ * @param string|int $filter
* @return string
*/
- public static function filterUsedKey( $group = null ) {
- return wfMemcKey( 'abusefilter', 'stats', 'total', $group );
+ public static function filterProfileKey( $filter ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ return $cache->makeKey( 'abusefilter-profile', 'v3', $filter );
}
/**
- * @param string|null $filter
+ * Memcache access key used to store overall profiling data for rule groups
+ *
+ * @param string $group
* @return string
*/
- public static function filterMatchesKey( $filter = null ) {
- return wfMemcKey( 'abusefilter', 'stats', 'matches', $filter );
+ public static function filterProfileGroupKey( $group ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ return $cache->makeKey( 'abusefilter-profile', 'group', $group );
}
/**
* @return User
*/
- public static function getFilterUser() {
+ public static function getFilterUser() : User {
$username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text();
$user = User::newSystemUser( $username, [ 'steal' => true ] );
@@ -2086,6 +746,7 @@ class AbuseFilter {
$defaultName = wfMessage( 'abusefilter-blocker' )->inLanguage( 'en' )->text();
$user = User::newSystemUser( $defaultName, [ 'steal' => true ] );
}
+ '@phan-var User $user';
// Promote user to 'sysop' so it doesn't look
// like an unprivileged account is blocking users
@@ -2102,21 +763,22 @@ class AbuseFilter {
* @param bool $canEdit
* @return array
*/
- public static function getAceConfig( $canEdit ) {
- $values = self::getBuilderValues();
- $deprecatedVars = self::getDeprecatedVariables();
+ public static function getAceConfig( bool $canEdit ): array {
+ $keywordsManager = AbuseFilterServices::getKeywordsManager();
+ $values = $keywordsManager->getBuilderValues();
+ $deprecatedVars = $keywordsManager->getDeprecatedVariables();
$builderVariables = implode( '|', array_keys( $values['vars'] ) );
- $builderFunctions = implode( '|', array_keys( AbuseFilterParser::$mFunctions ) );
- // AbuseFilterTokenizer::$keywords also includes constants (true, false and null),
+ $builderFunctions = implode( '|', array_keys( AbuseFilterParser::FUNCTIONS ) );
+ // AbuseFilterTokenizer::KEYWORDS also includes constants (true, false and null),
// but Ace redefines these constants afterwards so this will not be an issue
- $builderKeywords = implode( '|', AbuseFilterTokenizer::$keywords );
+ $builderKeywords = implode( '|', AbuseFilterTokenizer::KEYWORDS );
// Extract operators from tokenizer like we do in AbuseFilterParserTest
$operators = implode( '|', array_map( function ( $op ) {
return preg_quote( $op, '/' );
- }, AbuseFilterTokenizer::$operators ) );
+ }, AbuseFilterTokenizer::OPERATORS ) );
$deprecatedVariables = implode( '|', array_keys( $deprecatedVars ) );
- $disabledVariables = implode( '|', array_keys( self::$disabledVars ) );
+ $disabledVariables = implode( '|', array_keys( $keywordsManager->getDisabledVariables() ) );
return [
'variables' => $builderVariables,
@@ -2130,45 +792,6 @@ class AbuseFilter {
}
/**
- * Build input and button for loading a filter
- *
- * @return string
- */
- public static function buildFilterLoader() {
- $loadText =
- new OOUI\TextInputWidget(
- [
- 'type' => 'number',
- 'name' => 'wpInsertFilter',
- 'id' => 'mw-abusefilter-load-filter'
- ]
- );
- $loadButton =
- new OOUI\ButtonWidget(
- [
- 'label' => wfMessage( 'abusefilter-test-load' )->text(),
- 'id' => 'mw-abusefilter-load'
- ]
- );
- $loadGroup =
- new OOUI\ActionFieldLayout(
- $loadText,
- $loadButton,
- [
- 'label' => wfMessage( 'abusefilter-test-load-filter' )->text()
- ]
- );
- // CSS class for reducing default input field width
- $loadDiv =
- Xml::tags(
- 'div',
- [ 'class' => 'mw-abusefilter-load-filter-id' ],
- $loadGroup
- );
- return $loadDiv;
- }
-
- /**
* Check whether a filter is allowed to use a tag
*
* @param string $tag Tag name
@@ -2220,9 +843,7 @@ class AbuseFilter {
* @return null|string Null on success, a string with the error message on failure
*/
public static function checkThrottleParameters( $params ) {
- $throttleRate = explode( ',', $params[1] );
- $throttleCount = $throttleRate[0];
- $throttlePeriod = $throttleRate[1];
+ list( $throttleCount, $throttlePeriod ) = explode( ',', $params[1], 2 );
$throttleGroups = array_slice( $params, 2 );
$validGroups = [
'ip',
@@ -2284,20 +905,33 @@ class AbuseFilter {
}
/**
- * Checks whether user input for the filter editing form is valid and if so saves the filter
+ * Checks whether user input for the filter editing form is valid and if so saves the filter.
+ * Returns a Status object which can be:
+ * - Good with [ new_filter_id, history_id ] as value if the filter was successfully saved
+ * - Good with value = false if everything went fine but the filter is unchanged
+ * - OK with errors if a validation error occurred
+ * - Fatal in case of a permission-related error
*
- * @param AbuseFilterViewEdit $page
+ * @param ContextSource $context
* @param int|string $filter
- * @param WebRequest $request
* @param stdClass $newRow
* @param array $actions
+ * @param IDatabase $dbw DB_MASTER Where the filter should be saved
* @return Status
*/
- public static function saveFilter( $page, $filter, $request, $newRow, $actions ) {
+ public static function saveFilter(
+ ContextSource $context,
+ $filter,
+ $newRow,
+ $actions,
+ IDatabase $dbw
+ ) {
$validationStatus = Status::newGood();
+ $request = $context->getRequest();
+ $user = $context->getUser();
// Check the syntax
- $syntaxerr = self::checkSyntax( $request->getVal( 'wpFilterRules' ) );
+ $syntaxerr = self::getDefaultParser()->checkSyntax( $request->getVal( 'wpFilterRules' ) );
if ( $syntaxerr !== true ) {
$validationStatus->error( 'abusefilter-edit-badsyntax', $syntaxerr[0] );
return $validationStatus;
@@ -2306,27 +940,30 @@ class AbuseFilter {
$missing = [];
if ( !$request->getVal( 'wpFilterRules' ) ||
trim( $request->getVal( 'wpFilterRules' ) ) === '' ) {
- $missing[] = wfMessage( 'abusefilter-edit-field-conditions' )->escaped();
+ $missing[] = $context->msg( 'abusefilter-edit-field-conditions' )->escaped();
}
if ( !$request->getVal( 'wpFilterDescription' ) ) {
- $missing[] = wfMessage( 'abusefilter-edit-field-description' )->escaped();
+ $missing[] = $context->msg( 'abusefilter-edit-field-description' )->escaped();
}
if ( count( $missing ) !== 0 ) {
- $missing = $page->getLanguage()->commaList( $missing );
+ $missing = $context->getLanguage()->commaList( $missing );
$validationStatus->error( 'abusefilter-edit-missingfields', $missing );
return $validationStatus;
}
// Don't allow setting as deleted an active filter
- if ( $request->getCheck( 'wpFilterEnabled' ) == true &&
- $request->getCheck( 'wpFilterDeleted' ) == true ) {
+ if ( $request->getCheck( 'wpFilterEnabled' ) && $request->getCheck( 'wpFilterDeleted' ) ) {
$validationStatus->error( 'abusefilter-edit-deleting-enabled' );
return $validationStatus;
}
// If we've activated the 'tag' option, check the arguments for validity.
- if ( !empty( $actions['tag'] ) ) {
- foreach ( $actions['tag']['parameters'] as $tag ) {
+ if ( isset( $actions['tag'] ) ) {
+ if ( count( $actions['tag'] ) === 0 ) {
+ $validationStatus->error( 'tags-create-no-name' );
+ return $validationStatus;
+ }
+ foreach ( $actions['tag'] as $tag ) {
$status = self::isAllowedTag( $tag );
if ( !$status->isGood() ) {
@@ -2338,23 +975,39 @@ class AbuseFilter {
}
}
+ // Warning and disallow message cannot be empty
+ if ( isset( $actions['warn'] ) && $actions['warn'][0] === '' ) {
+ $validationStatus->error( 'abusefilter-edit-invalid-warn-message' );
+ return $validationStatus;
+ } elseif ( isset( $actions['disallow'] ) && $actions['disallow'][0] === '' ) {
+ $validationStatus->error( 'abusefilter-edit-invalid-disallow-message' );
+ return $validationStatus;
+ }
+
// If 'throttle' is selected, check its parameters
- if ( !empty( $actions['throttle'] ) ) {
- $throttleCheck = self::checkThrottleParameters( $actions['throttle']['parameters'] );
+ if ( isset( $actions['throttle'] ) ) {
+ $throttleCheck = self::checkThrottleParameters( $actions['throttle'] );
if ( $throttleCheck !== null ) {
$validationStatus->error( $throttleCheck );
return $validationStatus;
}
}
+ $availableActions = array_keys(
+ array_filter( $context->getConfig()->get( 'AbuseFilterActions' ) )
+ );
$differences = self::compareVersions(
[ $newRow, $actions ],
- [ $newRow->mOriginalRow, $newRow->mOriginalActions ]
+ [ $newRow->mOriginalRow, $newRow->mOriginalActions ],
+ $availableActions
);
// Don't allow adding a new global rule, or updating a
// rule that is currently global, without permissions.
- if ( !$page->canEditFilter( $newRow ) || !$page->canEditFilter( $newRow->mOriginalRow ) ) {
+ if (
+ !self::canEditFilter( $user, $newRow ) ||
+ !self::canEditFilter( $user, $newRow->mOriginalRow )
+ ) {
$validationStatus->fatal( 'abusefilter-edit-notallowed-global' );
return $validationStatus;
}
@@ -2381,15 +1034,13 @@ class AbuseFilter {
}
// Check for restricted actions
- $restrictions = $page->getConfig()->get( 'AbuseFilterRestrictions' );
+ $restrictions = $context->getConfig()->get( 'AbuseFilterRestrictions' );
if ( count( array_intersect_key(
array_filter( $restrictions ),
- array_merge(
- array_filter( $actions ),
- array_filter( $origActions )
- )
+ array_merge( $actions, $origActions )
) )
- && !$page->getUser()->isAllowed( 'abusefilter-modify-restricted' )
+ && !MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $user, 'abusefilter-modify-restricted' )
) {
$validationStatus->error( 'abusefilter-edit-restricted' );
return $validationStatus;
@@ -2397,7 +1048,7 @@ class AbuseFilter {
// Everything went fine, so let's save the filter
list( $new_id, $history_id ) =
- self::doSaveFilter( $newRow, $differences, $filter, $actions, $wasGlobal, $page );
+ self::doSaveFilter( $newRow, $differences, $filter, $actions, $wasGlobal, $context, $dbw );
$validationStatus->setResult( true, [ $new_id, $history_id ] );
return $validationStatus;
}
@@ -2406,11 +1057,12 @@ class AbuseFilter {
* Saves new filter's info to DB
*
* @param stdClass $newRow
- * @param int|string $filter
* @param array $differences
+ * @param int|string $filter
* @param array $actions
* @param bool $wasGlobal
- * @param AbuseFilterViewEdit $page
+ * @param ContextSource $context
+ * @param IDatabase $dbw DB_MASTER where the filter will be saved
* @return int[] first element is new ID, second is history ID
*/
private static function doSaveFilter(
@@ -2419,10 +1071,10 @@ class AbuseFilter {
$filter,
$actions,
$wasGlobal,
- $page
+ ContextSource $context,
+ IDatabase $dbw
) {
- $user = $page->getUser();
- $dbw = wfGetDB( DB_MASTER );
+ $user = $context->getUser();
// Convert from object to array
$newRow = get_object_vars( $newRow );
@@ -2435,8 +1087,8 @@ class AbuseFilter {
$dbw->startAtomic( __METHOD__ );
// Insert MAIN row.
- if ( $filter == 'new' ) {
- $new_id = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' );
+ if ( $filter === 'new' ) {
+ $new_id = null;
$is_new = true;
} else {
$new_id = $filter;
@@ -2454,21 +1106,21 @@ class AbuseFilter {
$newRow['af_deleted'] = (int)$newRow['af_deleted'];
$newRow['af_global'] = (int)$newRow['af_global'];
- $dbw->replace( 'abuse_filter', [ 'af_id' ], $newRow, __METHOD__ );
+ $dbw->replace( 'abuse_filter', 'af_id', $newRow, __METHOD__ );
if ( $is_new ) {
$new_id = $dbw->insertId();
}
+ '@phan-var int $new_id';
- // Actions
- $availableActions = $page->getConfig()->get( 'AbuseFilterActions' );
+ $availableActions = $context->getConfig()->get( 'AbuseFilterActions' );
$actionsRows = [];
foreach ( array_filter( $availableActions ) as $action => $_ ) {
// Check if it's set
- $enabled = isset( $actions[$action] ) && (bool)$actions[$action];
+ $enabled = isset( $actions[$action] );
if ( $enabled ) {
- $parameters = $actions[$action]['parameters'];
+ $parameters = $actions[$action];
if ( $action === 'throttle' && $parameters[0] === 'new' ) {
// FIXME: Do we really need to keep the filter ID inside throttle parameters?
// We'd save space, keep things simpler and avoid this hack. Note: if removing
@@ -2488,20 +1140,14 @@ class AbuseFilter {
// Create a history row
$afh_row = [];
- foreach ( self::$history_mappings as $af_col => $afh_col ) {
+ foreach ( self::HISTORY_MAPPINGS as $af_col => $afh_col ) {
$afh_row[$afh_col] = $newRow[$af_col];
}
- // Actions
- $displayActions = [];
- foreach ( $actions as $action ) {
- $displayActions[$action['action']] = $action['parameters'];
- }
- $afh_row['afh_actions'] = serialize( $displayActions );
+ $afh_row['afh_actions'] = serialize( $actions );
$afh_row['afh_changed_fields'] = implode( ',', $differences );
- // Flags
$flags = [];
if ( $newRow['af_hidden'] ) {
$flags[] = 'hidden';
@@ -2519,12 +1165,11 @@ class AbuseFilter {
$afh_row['afh_flags'] = implode( ',', $flags );
$afh_row['afh_filter'] = $new_id;
- $afh_row['afh_id'] = $dbw->nextSequenceValue( 'abuse_filter_af_id_seq' );
// Do the update
$dbw->insert( 'abuse_filter_history', $afh_row, __METHOD__ );
$history_id = $dbw->insertId();
- if ( $filter != 'new' ) {
+ if ( $filter !== 'new' ) {
$dbw->delete(
'abuse_filter_action',
[ 'afa_filter' => $filter ],
@@ -2538,24 +1183,26 @@ class AbuseFilter {
// Invalidate cache if this was a global rule
if ( $wasGlobal || $newRow['af_global'] ) {
$group = 'default';
- if ( isset( $newRow['af_group'] ) && $newRow['af_group'] != '' ) {
+ if ( isset( $newRow['af_group'] ) && $newRow['af_group'] !== '' ) {
$group = $newRow['af_group'];
}
$globalRulesKey = self::getGlobalRulesKey( $group );
- ObjectCache::getMainWANInstance()->touchCheckKey( $globalRulesKey );
+ MediaWikiServices::getInstance()->getMainWANObjectCache()->touchCheckKey( $globalRulesKey );
}
// Logging
$subtype = $filter === 'new' ? 'create' : 'modify';
$logEntry = new ManualLogEntry( 'abusefilter', $subtype );
$logEntry->setPerformer( $user );
- $logEntry->setTarget( $page->getTitle( $new_id ) );
+ $logEntry->setTarget(
+ SpecialPage::getTitleFor( SpecialAbuseFilter::PAGE_NAME, (string)$new_id )
+ );
$logEntry->setParameters( [
'historyId' => $history_id,
'newId' => $new_id
] );
- $logid = $logEntry->insert();
+ $logid = $logEntry->insert( $dbw );
$logEntry->publish( $logid );
// Purge the tag list cache so the fetchAllTags hook applies tag changes
@@ -2573,10 +1220,15 @@ class AbuseFilter {
*
* @param array $version_1
* @param array $version_2
+ * @param string[] $availableActions All actions enabled in the AF config
*
* @return array
*/
- public static function compareVersions( $version_1, $version_2 ) {
+ public static function compareVersions(
+ array $version_1,
+ array $version_2,
+ array $availableActions
+ ) {
$compareFields = [
'af_public_comments',
'af_pattern',
@@ -2598,16 +1250,13 @@ class AbuseFilter {
}
}
- global $wgAbuseFilterActions;
- foreach ( array_filter( $wgAbuseFilterActions ) as $action => $_ ) {
+ foreach ( $availableActions as $action ) {
if ( !isset( $actions1[$action] ) && !isset( $actions2[$action] ) ) {
// They're both unset
} elseif ( isset( $actions1[$action] ) && isset( $actions2[$action] ) ) {
// They're both set. Double check needed, e.g. per T180194
- if ( array_diff( $actions1[$action]['parameters'],
- $actions2[$action]['parameters'] ) ||
- array_diff( $actions2[$action]['parameters'],
- $actions1[$action]['parameters'] ) ) {
+ if ( array_diff( $actions1[$action], $actions2[$action] ) ||
+ array_diff( $actions2[$action], $actions1[$action] ) ) {
// Different parameters
$differences[] = 'actions';
}
@@ -2628,7 +1277,7 @@ class AbuseFilter {
// Manually translate into an abuse_filter row.
$af_row = new stdClass;
- foreach ( self::$history_mappings as $af_col => $afh_col ) {
+ foreach ( self::HISTORY_MAPPINGS as $af_col => $afh_col ) {
$af_row->$af_col = $row->$afh_col;
}
@@ -2646,31 +1295,25 @@ class AbuseFilter {
}
// Process actions
- $actions_raw = unserialize( $row->afh_actions );
- $actions_output = [];
- if ( is_array( $actions_raw ) ) {
- foreach ( $actions_raw as $action => $parameters ) {
- $actions_output[$action] = [
- 'action' => $action,
- 'parameters' => $parameters
- ];
- }
- }
+ $actionsRaw = unserialize( $row->afh_actions );
+ $actionsOutput = is_array( $actionsRaw ) ? $actionsRaw : [];
- return [ $af_row, $actions_output ];
+ return [ $af_row, $actionsOutput ];
}
/**
* @param string $action
- * @return string
+ * @param MessageLocalizer|null $localizer
+ * @return string HTML
*/
- public static function getActionDisplay( $action ) {
+ public static function getActionDisplay( $action, MessageLocalizer $localizer = null ) {
+ $msgCallback = $localizer != null ? [ $localizer, 'msg' ] : 'wfMessage';
// Give grep a chance to find the usages:
// abusefilter-action-tag, abusefilter-action-throttle, abusefilter-action-warn,
// abusefilter-action-blockautopromote, abusefilter-action-block, abusefilter-action-degroup,
// abusefilter-action-rangeblock, abusefilter-action-disallow
- $display = wfMessage( "abusefilter-action-$action" )->escaped();
- $display = wfMessage( "abusefilter-action-$action", $display )->isDisabled()
+ $display = $msgCallback( "abusefilter-action-$action" )->escaped();
+ $display = $msgCallback( "abusefilter-action-$action" )->rawParams( $display )->isDisabled()
? htmlspecialchars( $action )
: $display;
@@ -2678,216 +1321,32 @@ class AbuseFilter {
}
/**
- * @param stdClass $row
- * @return AbuseFilterVariableHolder|null
- */
- public static function getVarsFromRCRow( $row ) {
- if ( $row->rc_log_type == 'move' ) {
- $vars = self::getMoveVarsFromRCRow( $row );
- } elseif ( $row->rc_log_type == 'newusers' ) {
- $vars = self::getCreateVarsFromRCRow( $row );
- } elseif ( $row->rc_log_type == 'delete' ) {
- $vars = self::getDeleteVarsFromRCRow( $row );
- } elseif ( $row->rc_this_oldid ) {
- // It's an edit.
- $vars = self::getEditVarsFromRCRow( $row );
- } else {
- return null;
- }
- if ( $vars ) {
- $vars->setVar( 'context', 'generated' );
- $vars->setVar( 'timestamp', wfTimestamp( TS_UNIX, $row->rc_timestamp ) );
- }
-
- return $vars;
- }
-
- /**
- * @param stdClass $row
- * @return AbuseFilterVariableHolder
- */
- public static function getCreateVarsFromRCRow( $row ) {
- $vars = new AbuseFilterVariableHolder;
-
- $vars->setVar( 'ACTION', ( $row->rc_log_action == 'autocreate' ) ?
- 'autocreateaccount' :
- 'createaccount' );
-
- $name = Title::makeTitle( $row->rc_namespace, $row->rc_title )->getText();
- // Add user data if the account was created by a registered user
- if ( $row->rc_user && $name != $row->rc_user_text ) {
- $user = User::newFromName( $row->rc_user_text );
- $vars->addHolders( self::generateUserVars( $user ) );
- }
-
- $vars->setVar( 'accountname', $name );
-
- return $vars;
- }
-
- /**
- * @param stdClass $row
- * @return AbuseFilterVariableHolder
- */
- public static function getDeleteVarsFromRCRow( $row ) {
- $vars = new AbuseFilterVariableHolder;
- $title = Title::makeTitle( $row->rc_namespace, $row->rc_title );
-
- if ( $row->rc_user ) {
- $user = User::newFromName( $row->rc_user_text );
- } else {
- $user = new User;
- $user->setName( $row->rc_user_text );
- }
-
- $vars->addHolders(
- self::generateUserVars( $user ),
- self::generateTitleVars( $title, 'PAGE' )
- );
-
- $vars->setVar( 'ACTION', 'delete' );
- $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text );
-
- return $vars;
- }
-
- /**
- * @param stdClass $row
- * @return AbuseFilterVariableHolder
- */
- public static function getEditVarsFromRCRow( $row ) {
- $vars = new AbuseFilterVariableHolder;
- $title = Title::makeTitle( $row->rc_namespace, $row->rc_title );
-
- if ( $row->rc_user ) {
- $user = User::newFromName( $row->rc_user_text );
- } else {
- $user = new User;
- $user->setName( $row->rc_user_text );
- }
-
- $vars->addHolders(
- self::generateUserVars( $user ),
- self::generateTitleVars( $title, 'PAGE' )
- );
-
- $vars->setVar( 'ACTION', 'edit' );
- $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text );
-
- $vars->setLazyLoadVar( 'new_wikitext', 'revision-text-by-id',
- [ 'revid' => $row->rc_this_oldid ] );
-
- if ( $row->rc_last_oldid ) {
- $vars->setLazyLoadVar( 'old_wikitext', 'revision-text-by-id',
- [ 'revid' => $row->rc_last_oldid ] );
- } else {
- $vars->setVar( 'old_wikitext', '' );
- }
-
- $vars->addHolders( self::getEditVars( $title ) );
-
- return $vars;
- }
-
- /**
- * @param stdClass $row
- * @return AbuseFilterVariableHolder
- */
- public static function getMoveVarsFromRCRow( $row ) {
- if ( $row->rc_user ) {
- $user = User::newFromId( $row->rc_user );
- } else {
- $user = new User;
- $user->setName( $row->rc_user_text );
- }
-
- $params = array_values( DatabaseLogEntry::newFromRow( $row )->getParameters() );
-
- $oldTitle = Title::makeTitle( $row->rc_namespace, $row->rc_title );
- $newTitle = Title::newFromText( $params[0] );
-
- $vars = AbuseFilterVariableHolder::merge(
- self::generateUserVars( $user ),
- self::generateTitleVars( $oldTitle, 'MOVED_FROM' ),
- self::generateTitleVars( $newTitle, 'MOVED_TO' )
- );
-
- $vars->setVar( 'SUMMARY', CommentStore::getStore()->getComment( 'rc_comment', $row )->text );
- $vars->setVar( 'ACTION', 'move' );
-
- return $vars;
- }
-
- /**
- * @param Title|null $title
- * @param Page|null $page
- * @return AbuseFilterVariableHolder
+ * @param mixed $var
+ * @param string $indent
+ * @return string
*/
- public static function getEditVars( $title, Page $page = null ) {
- $vars = new AbuseFilterVariableHolder;
-
- // NOTE: $page may end up remaining null, e.g. if $title points to a special page.
- if ( !$page && $title instanceof Title && $title->canExist() ) {
- $page = WikiPage::factory( $title );
- }
-
- $vars->setLazyLoadVar( 'edit_diff', 'diff-array',
- [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_wikitext' ] );
- $vars->setLazyLoadVar( 'edit_diff_pst', 'diff-array',
- [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_pst' ] );
- $vars->setLazyLoadVar( 'new_size', 'length', [ 'length-var' => 'new_wikitext' ] );
- $vars->setLazyLoadVar( 'old_size', 'length', [ 'length-var' => 'old_wikitext' ] );
- $vars->setLazyLoadVar( 'edit_delta', 'subtract-int',
- [ 'val1-var' => 'new_size', 'val2-var' => 'old_size' ] );
-
- // Some more specific/useful details about the changes.
- $vars->setLazyLoadVar( 'added_lines', 'diff-split',
- [ 'diff-var' => 'edit_diff', 'line-prefix' => '+' ] );
- $vars->setLazyLoadVar( 'removed_lines', 'diff-split',
- [ 'diff-var' => 'edit_diff', 'line-prefix' => '-' ] );
- $vars->setLazyLoadVar( 'added_lines_pst', 'diff-split',
- [ 'diff-var' => 'edit_diff_pst', 'line-prefix' => '+' ] );
-
- // Links
- $vars->setLazyLoadVar( 'added_links', 'link-diff-added',
- [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] );
- $vars->setLazyLoadVar( 'removed_links', 'link-diff-removed',
- [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] );
- $vars->setLazyLoadVar( 'new_text', 'strip-html',
- [ 'html-var' => 'new_html' ] );
-
- if ( $title instanceof Title ) {
- $vars->setLazyLoadVar( 'all_links', 'links-from-wikitext',
- [
- 'namespace' => $title->getNamespace(),
- 'title' => $title->getText(),
- 'text-var' => 'new_wikitext',
- 'article' => $page
- ] );
- $vars->setLazyLoadVar( 'old_links', 'links-from-wikitext-or-database',
- [
- 'namespace' => $title->getNamespace(),
- 'title' => $title->getText(),
- 'text-var' => 'old_wikitext'
- ] );
- $vars->setLazyLoadVar( 'new_pst', 'parse-wikitext',
- [
- 'namespace' => $title->getNamespace(),
- 'title' => $title->getText(),
- 'wikitext-var' => 'new_wikitext',
- 'article' => $page,
- 'pst' => true,
- ] );
- $vars->setLazyLoadVar( 'new_html', 'parse-wikitext',
- [
- 'namespace' => $title->getNamespace(),
- 'title' => $title->getText(),
- 'wikitext-var' => 'new_wikitext',
- 'article' => $page
- ] );
+ public static function formatVar( $var, string $indent = '' ) {
+ if ( $var === [] ) {
+ return '[]';
+ } elseif ( is_array( $var ) ) {
+ $ret = '[';
+ $indent .= "\t";
+ foreach ( $var as $key => $val ) {
+ $ret .= "\n$indent" . self::formatVar( $key, $indent ) .
+ ' => ' . self::formatVar( $val, $indent ) . ',';
+ }
+ // Strip trailing commas
+ return substr( $ret, 0, -1 ) . "\n" . substr( $indent, 0, -1 ) . ']';
+ } elseif ( is_string( $var ) ) {
+ // Don't escape the string (specifically backslashes) to avoid displaying wrong stuff
+ return "'$var'";
+ } elseif ( $var === null ) {
+ return 'null';
+ } elseif ( is_float( $var ) ) {
+ // Don't let float precision produce weirdness
+ return (string)$var;
}
-
- return $vars;
+ return var_export( $var, true );
}
/**
@@ -2903,12 +1362,6 @@ class AbuseFilter {
$output = '';
- // I don't want to change the names of the pre-existing messages
- // describing the variables, nor do I want to rewrite them, so I'm just
- // mapping the variable names to builder messages with a pre-existing array.
- $variableMessageMappings = self::getBuilderValues();
- $variableMessageMappings = $variableMessageMappings['vars'];
-
$output .=
Xml::openElement( 'table', [ 'class' => 'mw-abuselog-details' ] ) .
Xml::openElement( 'tbody' ) .
@@ -2925,30 +1378,27 @@ class AbuseFilter {
return $output;
}
+ $keywordsManager = AbuseFilterServices::getKeywordsManager();
// Now, build the body of the table.
- $deprecatedVars = self::getDeprecatedVariables();
foreach ( $vars as $key => $value ) {
$key = strtolower( $key );
- if ( array_key_exists( $key, $deprecatedVars ) ) {
- $key = $deprecatedVars[$key];
- }
- if ( !empty( $variableMessageMappings[$key] ) ) {
- $mapping = $variableMessageMappings[$key];
- $keyDisplay = $context->msg( "abusefilter-edit-builder-vars-$mapping" )->parse() .
- ' ' . Xml::element( 'code', null, $context->msg( 'parentheses' )->rawParams( $key )->text() );
- } elseif ( !empty( self::$disabledVars[$key] ) ) {
- $mapping = self::$disabledVars[$key];
- $keyDisplay = $context->msg( "abusefilter-edit-builder-vars-$mapping" )->parse() .
- ' ' . Xml::element( 'code', null, $context->msg( 'parentheses' )->rawParams( $key )->text() );
+ $varMsgKey = $keywordsManager->getMessageKeyForVar( $key );
+ if ( $varMsgKey ) {
+ $keyDisplay = $context->msg( $varMsgKey )->parse() .
+ ' ' . Html::element( 'code', [], $context->msg( 'parentheses' )->rawParams( $key )->text() );
} else {
- $keyDisplay = Xml::element( 'code', null, $key );
+ $keyDisplay = Html::element( 'code', [], $key );
}
- if ( is_null( $value ) ) {
+ if ( $value === null ) {
$value = '';
}
- $value = Xml::element( 'div', [ 'class' => 'mw-abuselog-var-value' ], $value, false );
+ $value = Html::element(
+ 'div',
+ [ 'class' => 'mw-abuselog-var-value' ],
+ self::formatVar( $value )
+ );
$trow =
Xml::tags( 'td', [ 'class' => 'mw-abuselog-var' ], $keyDisplay ) .
@@ -2967,11 +1417,10 @@ class AbuseFilter {
/**
* @param string $action
* @param string[] $parameters
+ * @param Language $lang
* @return string
*/
- public static function formatAction( $action, $parameters ) {
- /** @var $wgLang Language */
- global $wgLang;
+ public static function formatAction( $action, $parameters, $lang ) {
if ( count( $parameters ) === 0 ||
( $action === 'block' && count( $parameters ) !== 3 ) ) {
$displayAction = self::getActionDisplay( $action );
@@ -2981,57 +1430,49 @@ class AbuseFilter {
$messages = [
wfMessage( 'abusefilter-block-anon' )->escaped() .
wfMessage( 'colon-separator' )->escaped() .
- $wgLang->translateBlockExpiry( $parameters[1] ),
+ $lang->translateBlockExpiry( $parameters[1] ),
wfMessage( 'abusefilter-block-user' )->escaped() .
wfMessage( 'colon-separator' )->escaped() .
- $wgLang->translateBlockExpiry( $parameters[2] )
+ $lang->translateBlockExpiry( $parameters[2] )
];
if ( $parameters[0] === 'blocktalk' ) {
$messages[] = wfMessage( 'abusefilter-block-talk' )->escaped();
}
- $displayAction = $wgLang->commaList( $messages );
+ $displayAction = $lang->commaList( $messages );
} elseif ( $action === 'throttle' ) {
array_shift( $parameters );
list( $actions, $time ) = explode( ',', array_shift( $parameters ) );
- if ( $parameters === [ '' ] ) {
- // Having empty groups won't happen for new filters due to validation upon saving,
- // but old entries may have it. We'd better not show a broken message. Also,
- // the array has an empty string inside because we haven't been passing an empty array
- // as the default when retrieving wpFilterThrottleGroups with getArray (when it was
- // a CheckboxMultiselect).
- $groups = '';
- } else {
- // Join comma-separated groups in a commaList with a final "and", and convert to messages.
- // Messages used here: abusefilter-throttle-ip, abusefilter-throttle-user,
- // abusefilter-throttle-site, abusefilter-throttle-creationdate, abusefilter-throttle-editcount
- // abusefilter-throttle-range, abusefilter-throttle-page
- foreach ( $parameters as &$val ) {
- if ( strpos( $val, ',' ) !== false ) {
- $subGroups = explode( ',', $val );
- foreach ( $subGroups as &$group ) {
- $msg = wfMessage( "abusefilter-throttle-$group" );
- // We previously accepted literally everything in this field, so old entries
- // may have weird stuff.
- $group = $msg->exists() ? $msg->text() : $group;
- }
- unset( $group );
- $val = $wgLang->listToText( $subGroups );
- } else {
- $msg = wfMessage( "abusefilter-throttle-$val" );
- $val = $msg->exists() ? $msg->text() : $val;
+ // Join comma-separated groups in a commaList with a final "and", and convert to messages.
+ // Messages used here: abusefilter-throttle-ip, abusefilter-throttle-user,
+ // abusefilter-throttle-site, abusefilter-throttle-creationdate, abusefilter-throttle-editcount
+ // abusefilter-throttle-range, abusefilter-throttle-page, abusefilter-throttle-none
+ foreach ( $parameters as &$val ) {
+ if ( strpos( $val, ',' ) !== false ) {
+ $subGroups = explode( ',', $val );
+ foreach ( $subGroups as &$group ) {
+ $msg = wfMessage( "abusefilter-throttle-$group" );
+ // We previously accepted literally everything in this field, so old entries
+ // may have weird stuff.
+ $group = $msg->exists() ? $msg->text() : $group;
}
+ unset( $group );
+ $val = $lang->listToText( $subGroups );
+ } else {
+ $msg = wfMessage( "abusefilter-throttle-$val" );
+ $val = $msg->exists() ? $msg->text() : $val;
}
- unset( $val );
- $groups = $wgLang->semicolonList( $parameters );
}
+ unset( $val );
+ $groups = $lang->semicolonList( $parameters );
+
$displayAction = self::getActionDisplay( $action ) .
wfMessage( 'colon-separator' )->escaped() .
wfMessage( 'abusefilter-throttle-details' )->params( $actions, $time, $groups )->escaped();
} else {
$displayAction = self::getActionDisplay( $action ) .
wfMessage( 'colon-separator' )->escaped() .
- $wgLang->semicolonList( array_map( 'htmlspecialchars', $parameters ) );
+ $lang->semicolonList( array_map( 'htmlspecialchars', $parameters ) );
}
}
@@ -3040,22 +1481,21 @@ class AbuseFilter {
/**
* @param string $value
+ * @param Language $lang
* @return string
*/
- public static function formatFlags( $value ) {
- /** @var $wgLang Language */
- global $wgLang;
+ public static function formatFlags( $value, $lang ) {
$flags = array_filter( explode( ',', $value ) );
$flags_display = [];
foreach ( $flags as $flag ) {
$flags_display[] = wfMessage( "abusefilter-history-$flag" )->escaped();
}
- return $wgLang->commaList( $flags_display );
+ return $lang->commaList( $flags_display );
}
/**
- * @param string $filterID
+ * @param int $filterID
* @return string
*/
public static function getGlobalFilterDescription( $filterID ) {
@@ -3070,7 +1510,7 @@ class AbuseFilter {
return $cache[$filterID];
}
- $fdb = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB );
+ $fdb = self::getCentralDB( DB_REPLICA );
$cache[$filterID] = $fdb->selectField(
'abuse_filter',
@@ -3105,25 +1545,34 @@ class AbuseFilter {
* Note also that if the revision for any reason is not an Revision
* the function returns with an empty string.
*
- * @param Revision|null $revision a valid revision
- * @param int $audience one of:
- * Revision::FOR_PUBLIC to be displayed to all users
- * Revision::FOR_THIS_USER to be displayed to the given user
- * Revision::RAW get the text regardless of permissions
- * @return string|null the content of the revision as some kind of string,
+ * For now, this returns all the revision's slots, concatenated together.
+ * In future, this will be replaced by a better solution. See T208769 for
+ * discussion.
+ *
+ * @internal
+ * @todo Move elsewhere. VariableGenerator is a good candidate
+ *
+ * @param RevisionRecord|null $revision a valid revision
+ * @param User $user the user instance to check for privileged access
+ * @return string the content of the revision as some kind of string,
* or an empty string if it can not be found
*/
- public static function revisionToString( $revision, $audience = Revision::FOR_THIS_USER ) {
- if ( !$revision instanceof Revision ) {
+ public static function revisionToString( ?RevisionRecord $revision, User $user ) {
+ if ( !$revision ) {
return '';
}
- $content = $revision->getContent( $audience );
- if ( $content === null ) {
- return '';
+ $strings = [];
+
+ foreach ( $revision->getSlotRoles() as $role ) {
+ $content = $revision->getContent( $role, RevisionRecord::FOR_THIS_USER, $user );
+ if ( $content === null ) {
+ continue;
+ }
+ $strings[$role] = self::contentToString( $content );
}
- $result = self::contentToString( $content );
+ $result = implode( "\n\n", $strings );
return $result;
}
@@ -3136,6 +1585,9 @@ class AbuseFilter {
* The hook 'AbuseFilter::contentToString' can be used to override this
* behavior.
*
+ * @internal
+ * @todo Move elsewhere. VariableGenerator is a good candidate
+ *
* @param Content $content
*
* @return string a suitable string representation of the content.
@@ -3143,9 +1595,13 @@ class AbuseFilter {
public static function contentToString( Content $content ) {
$text = null;
- if ( Hooks::run( 'AbuseFilter-contentToString', [ $content, &$text ] ) ) {
+ $hookRunner = AbuseFilterHookRunner::getRunner();
+ if ( $hookRunner->onAbuseFilterContentToString(
+ $content,
+ $text
+ ) ) {
$text = $content instanceof TextContent
- ? $content->getNativeData()
+ ? $content->getText()
: $content->getTextForSearchIndex();
}
@@ -3158,14 +1614,14 @@ class AbuseFilter {
* Get the history ID of the first change to a given filter
*
* @param int $filterID Filter id
- * @return int
+ * @return string
*/
public static function getFirstFilterChange( $filterID ) {
static $firstChanges = [];
if ( !isset( $firstChanges[$filterID] ) ) {
$dbr = wfGetDB( DB_REPLICA );
- $row = $dbr->selectRow(
+ $historyID = $dbr->selectField(
'abuse_filter_history',
'afh_id',
[
@@ -3174,9 +1630,120 @@ class AbuseFilter {
__METHOD__,
[ 'ORDER BY' => 'afh_timestamp ASC' ]
);
- $firstChanges[$filterID] = $row->afh_id;
+ $firstChanges[$filterID] = $historyID;
}
return $firstChanges[$filterID];
}
+
+ /**
+ * @param int $index DB_MASTER/DB_REPLICA
+ * @return IDatabase
+ * @throws DBerror
+ * @throws RuntimeException
+ */
+ public static function getCentralDB( $index ) {
+ global $wgAbuseFilterCentralDB;
+
+ if ( !is_string( $wgAbuseFilterCentralDB ) ) {
+ throw new RuntimeException( '$wgAbuseFilterCentralDB is not configured' );
+ }
+
+ return MediaWikiServices::getInstance()
+ ->getDBLoadBalancerFactory()
+ ->getMainLB( $wgAbuseFilterCentralDB )
+ ->getConnectionRef( $index, [], $wgAbuseFilterCentralDB );
+ }
+
+ /**
+ * @param User $user
+ * @return bool
+ */
+ public static function canEdit( User $user ) {
+ $block = $user->getBlock();
+ $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
+ return (
+ !( $block && $block->isSitewide() ) &&
+ $permissionManager->userHasRight( $user, 'abusefilter-modify' )
+ );
+ }
+
+ /**
+ * @param User $user
+ * @return bool
+ */
+ public static function canEditGlobal( User $user ) {
+ return MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $user, 'abusefilter-modify-global' );
+ }
+
+ /**
+ * Whether the user can edit the given filter.
+ *
+ * @param User $user
+ * @param object $row Filter row
+ * @return bool
+ */
+ public static function canEditFilter( User $user, $row ) {
+ return (
+ self::canEdit( $user ) &&
+ !( isset( $row->af_global ) && $row->af_global == 1 && !self::canEditGlobal( $user ) )
+ );
+ }
+
+ /**
+ * @param User $user
+ * @return bool
+ */
+ public static function canViewPrivate( User $user ) {
+ return MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasAnyRight( $user, 'abusefilter-modify', 'abusefilter-view-private' );
+ }
+
+ /**
+ * Get a parser instance using default options. This should mostly be intended as a wrapper
+ * around $wgAbuseFilterParserClass and for choosing the right type of cache. It also has the
+ * benefit of typehinting the return value, thus making IDEs and static analysis tools happier.
+ *
+ * @param AbuseFilterVariableHolder|null $vars
+ * @return AbuseFilterParser
+ * @throws InvalidArgumentException if $wgAbuseFilterParserClass is not valid
+ */
+ public static function getDefaultParser(
+ AbuseFilterVariableHolder $vars = null
+ ) : AbuseFilterParser {
+ global $wgAbuseFilterParserClass;
+
+ $allowedValues = [ AbuseFilterParser::class, AbuseFilterCachingParser::class ];
+ if ( !in_array( $wgAbuseFilterParserClass, $allowedValues ) ) {
+ throw new InvalidArgumentException(
+ "Invalid value $wgAbuseFilterParserClass for \$wgAbuseFilterParserClass."
+ );
+ }
+
+ $contLang = MediaWikiServices::getInstance()->getContentLanguage();
+ $cache = ObjectCache::getLocalServerInstance( 'hash' );
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $keywordsManager = AbuseFilterServices::getKeywordsManager();
+ return new $wgAbuseFilterParserClass( $contLang, $cache, $logger, $keywordsManager, $vars );
+ }
+
+ /**
+ * Shortcut for checking whether $user can view the given revision, with mask
+ * SUPPRESSED_ALL.
+ *
+ * @note This assumes that a revision with the given ID exists
+ *
+ * @param RevisionRecord $revRec
+ * @param User $user
+ * @return bool
+ */
+ public static function userCanViewRev( RevisionRecord $revRec, User $user ) : bool {
+ return $revRec->audienceCan(
+ RevisionRecord::SUPPRESSED_ALL,
+ RevisionRecord::FOR_THIS_USER,
+ $user
+ );
+ }
}
diff --git a/AbuseFilter/includes/AbuseFilterChangesList.php b/AbuseFilter/includes/AbuseFilterChangesList.php
index 94855f34..ba84d51b 100644
--- a/AbuseFilter/includes/AbuseFilterChangesList.php
+++ b/AbuseFilter/includes/AbuseFilterChangesList.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\Revision\RevisionRecord;
+
class AbuseFilterChangesList extends OldChangesList {
/**
@@ -25,17 +27,20 @@ class AbuseFilterChangesList extends OldChangesList {
public function insertExtra( &$s, &$rc, &$classes ) {
if ( (int)$rc->getAttribute( 'rc_deleted' ) !== 0 ) {
$s .= ' ' . $this->msg( 'abusefilter-log-hidden-implicit' )->parse();
- if ( !$this->userCan( $rc, Revision::SUPPRESSED_ALL ) ) {
+ if ( !$this->userCan( $rc, RevisionRecord::SUPPRESSED_ALL ) ) {
return;
}
}
$examineParams = [];
- if ( $this->testFilter ) {
+ if ( $this->testFilter && strlen( $this->testFilter ) < 2000 ) {
+ // Since this is GETed, don't send it if it's too long to prevent broken URLs 2000 is taken from
+ // https://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-
+ // in-different-browsers/417184#417184
$examineParams['testfilter'] = $this->testFilter;
}
- $title = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/' . $rc->mAttribs['rc_id'] );
+ $title = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/' . $rc->getAttribute( 'rc_id' ) );
$examineLink = $this->linkRenderer->makeLink(
$title,
new HtmlArmor( $this->msg( 'abusefilter-changeslist-examine' )->parse() ),
@@ -63,12 +68,12 @@ class AbuseFilterChangesList extends OldChangesList {
* @param RecentChange &$rc
*/
public function insertUserRelatedLinks( &$s, &$rc ) {
- $links = $this->getLanguage()->getDirMark() . Linker::userLink( $rc->mAttribs['rc_user'],
- $rc->mAttribs['rc_user_text'] ) .
- Linker::userToolLinks( $rc->mAttribs['rc_user'], $rc->mAttribs['rc_user_text'] );
+ $links = $this->getLanguage()->getDirMark() . Linker::userLink( $rc->getAttribute( 'rc_user' ),
+ $rc->getAttribute( 'rc_user_text' ) ) .
+ Linker::userToolLinks( $rc->getAttribute( 'rc_user' ), $rc->getAttribute( 'rc_user_text' ) );
- if ( $this->isDeleted( $rc, Revision::DELETED_USER ) ) {
- if ( $this->userCan( $rc, Revision::DELETED_USER ) ) {
+ if ( $this->isDeleted( $rc, RevisionRecord::DELETED_USER ) ) {
+ if ( $this->userCan( $rc, RevisionRecord::DELETED_USER ) ) {
$s .= ' <span class="history-deleted">' . $links . '</span>';
} else {
$s .= ' <span class="history-deleted">' .
@@ -85,16 +90,16 @@ class AbuseFilterChangesList extends OldChangesList {
* @return string
*/
public function insertComment( $rc ) {
- if ( $this->isDeleted( $rc, Revision::DELETED_COMMENT ) ) {
- if ( $this->userCan( $rc, Revision::DELETED_COMMENT ) ) {
+ if ( $this->isDeleted( $rc, RevisionRecord::DELETED_COMMENT ) ) {
+ if ( $this->userCan( $rc, RevisionRecord::DELETED_COMMENT ) ) {
return ' <span class="history-deleted">' .
- Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() ) . '</span>';
+ Linker::commentBlock( $rc->getAttribute( 'rc_comment' ), $rc->getTitle() ) . '</span>';
} else {
return ' <span class="history-deleted">' .
$this->msg( 'rev-deleted-comment' )->escaped() . '</span>';
}
} else {
- return Linker::commentBlock( $rc->mAttribs['rc_comment'], $rc->getTitle() );
+ return Linker::commentBlock( $rc->getAttribute( 'rc_comment' ), $rc->getTitle() );
}
}
@@ -105,7 +110,7 @@ class AbuseFilterChangesList extends OldChangesList {
* @return string
*/
public function insertLogEntry( $rc ) {
- $formatter = LogFormatter::newFromRow( $rc->mAttribs );
+ $formatter = LogFormatter::newFromRow( $rc->getAttributes() );
$formatter->setContext( $this->getContext() );
$formatter->setAudience( LogFormatter::FOR_THIS_USER );
$formatter->setShowUserToolLinks( true );
diff --git a/AbuseFilter/includes/AbuseFilterHooks.php b/AbuseFilter/includes/AbuseFilterHooks.php
index 20c07e4a..0b81bc31 100644
--- a/AbuseFilter/includes/AbuseFilterHooks.php
+++ b/AbuseFilter/includes/AbuseFilterHooks.php
@@ -1,30 +1,72 @@
<?php
+use MediaWiki\Extension\AbuseFilter\VariableGenerator\RunVariableGenerator;
use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\User\UserIdentity;
+use Wikimedia\IPUtils;
use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IMaintainableDatabase;
class AbuseFilterHooks {
- const FETCH_ALL_TAGS_KEY = 'abusefilter-fetch-all-tags';
+ private const FETCH_ALL_TAGS_KEY = 'abusefilter-fetch-all-tags';
- /** @var AbuseFilterVariableHolder|bool */
- public static $successful_action_vars = false;
- /** @var WikiPage|Article|bool|null Make sure edit filter & edit save hooks match */
- public static $last_edit_page = false;
- // So far, all of the error message out-params for these hooks accept HTML.
+ /** @var WikiPage|null Make sure edit filter & edit save hooks match */
+ private static $lastEditPage = null;
/**
* Called right after configuration has been loaded.
*/
public static function onRegistration() {
- global $wgAbuseFilterAvailableActions, $wgAbuseFilterRestrictedActions,
- $wgAuthManagerAutoConfig, $wgActionFilteredLogs;
-
- if ( isset( $wgAbuseFilterAvailableActions ) || isset( $wgAbuseFilterRestrictedActions ) ) {
- wfWarn( '$wgAbuseFilterAvailableActions and $wgAbuseFilterRestrictedActions have been '
- . 'removed. Please use $wgAbuseFilterActions and $wgAbuseFilterRestrictions '
- . 'instead. The format is the same except the action names are the keys of the '
- . 'array and the values are booleans.' );
+ global $wgAuthManagerAutoConfig, $wgActionFilteredLogs, $wgAbuseFilterProfile,
+ $wgAbuseFilterProfiling, $wgAbuseFilterPrivateLog, $wgAbuseFilterForceSummary,
+ $wgGroupPermissions;
+
+ // @todo Remove this in a future release (added in 1.33)
+ if ( isset( $wgAbuseFilterProfile ) || isset( $wgAbuseFilterProfiling ) ) {
+ wfWarn( '$wgAbuseFilterProfile and $wgAbuseFilterProfiling have been removed and ' .
+ 'profiling is now enabled by default.' );
+ }
+
+ if ( isset( $wgAbuseFilterPrivateLog ) ) {
+ global $wgAbuseFilterLogPrivateDetailsAccess;
+ $wgAbuseFilterLogPrivateDetailsAccess = $wgAbuseFilterPrivateLog;
+ wfWarn( '$wgAbuseFilterPrivateLog has been renamed to $wgAbuseFilterLogPrivateDetailsAccess. ' .
+ 'Please make the change in your settings; the format is identical.'
+ );
+ }
+ if ( isset( $wgAbuseFilterForceSummary ) ) {
+ global $wgAbuseFilterPrivateDetailsForceReason;
+ $wgAbuseFilterPrivateDetailsForceReason = $wgAbuseFilterForceSummary;
+ wfWarn( '$wgAbuseFilterForceSummary has been renamed to ' .
+ '$wgAbuseFilterPrivateDetailsForceReason. Please make the change in your settings; ' .
+ 'the format is identical.'
+ );
+ }
+
+ $found = false;
+ foreach ( $wgGroupPermissions as &$perms ) {
+ if ( array_key_exists( 'abusefilter-private', $perms ) ) {
+ $perms['abusefilter-privatedetails'] = $perms[ 'abusefilter-private' ];
+ unset( $perms[ 'abusefilter-private' ] );
+ $found = true;
+ }
+ if ( array_key_exists( 'abusefilter-private-log', $perms ) ) {
+ $perms['abusefilter-privatedetails-log'] = $perms[ 'abusefilter-private-log' ];
+ unset( $perms[ 'abusefilter-private-log' ] );
+ $found = true;
+ }
+ }
+ unset( $perms );
+
+ if ( $found ) {
+ wfWarn( 'The group permissions "abusefilter-private-log" and "abusefilter-private" have ' .
+ 'been renamed, respectively, to "abusefilter-privatedetails-log" and ' .
+ '"abusefilter-privatedetails". Please update the names in your settings.'
+ );
}
$wgAuthManagerAutoConfig['preauth'][AbuseFilterPreAuthenticationProvider::class] = [
@@ -38,6 +80,15 @@ class AbuseFilterHooks {
// Message: log-action-filter-suppress-abuselog
[ 'abuselog' => [ 'hide-afl', 'unhide-afl' ] ]
);
+ $wgActionFilteredLogs['rights'] = array_merge(
+ $wgActionFilteredLogs['rights'],
+ // Messages: log-action-filter-rights-blockautopromote,
+ // log-action-filter-rights-restoreautopromote
+ [
+ 'blockautopromote' => [ 'blockautopromote' ],
+ 'restoreautopromote' => [ 'restoreautopromote' ]
+ ]
+ );
}
/**
@@ -49,265 +100,178 @@ class AbuseFilterHooks {
* @param string $summary Edit summary for page
* @param User $user the user performing the edit
* @param bool $minoredit whether this is a minor edit according to the user.
+ * @param string $slot slot role for the content
*/
public static function onEditFilterMergedContent( IContextSource $context, Content $content,
- Status $status, $summary, User $user, $minoredit
+ Status $status, $summary, User $user, $minoredit, $slot = SlotRecord::MAIN
) {
- $text = AbuseFilter::contentToString( $content );
+ $startTime = microtime( true );
- $filterStatus = self::filterEdit( $context, $content, $text, $status, $summary, $minoredit );
+ $filterResult = self::filterEdit( $context, $user, $content, $summary, $slot );
- if ( !$filterStatus->isOK() ) {
+ if ( !$filterResult->isOK() ) {
// Produce a useful error message for API edits
- $status->apiHookResult = self::getApiResult( $filterStatus );
+ $filterResultApi = self::getApiStatus( $filterResult );
+ $status->merge( $filterResultApi );
}
+ MediaWikiServices::getInstance()->getStatsdDataFactory()
+ ->timing( 'timing.editAbuseFilter', microtime( true ) - $startTime );
}
/**
* Implementation for EditFilterMergedContent hook.
*
* @param IContextSource $context the context of the edit
+ * @param User $user
* @param Content $content the new Content generated by the edit
- * @param string $text new page content (subject of filtering)
- * @param Status $status Error message to return
* @param string $summary Edit summary for page
- * @param bool $minoredit whether this is a minor edit according to the user.
+ * @param string $slot slot role for the content
* @return Status
*/
- public static function filterEdit( IContextSource $context, $content, $text,
- Status $status, $summary, $minoredit
- ) {
- $title = $context->getTitle();
-
- self::$successful_action_vars = false;
- self::$last_edit_page = false;
+ public static function filterEdit(
+ IContextSource $context,
+ User $user,
+ Content $content,
+ $summary, $slot = SlotRecord::MAIN
+ ) : Status {
+ self::$lastEditPage = null;
- $user = $context->getUser();
+ // @todo is there any real point in passing this in?
+ $text = AbuseFilter::contentToString( $content );
- $oldcontent = null;
+ $title = $context->getTitle();
+ if ( $title === null ) {
+ // T144265: This *should* never happen.
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->warning( __METHOD__ . ' received a null title.' );
+ return Status::newGood();
+ }
- if ( ( $title instanceof Title ) && $title->canExist() && $title->exists() ) {
+ if ( $title->canExist() && $title->exists() ) {
// Make sure we load the latest text saved in database (bug 31656)
$page = $context->getWikiPage();
- $revision = $page->getRevision();
- if ( !$revision ) {
- return Status::newGood();
- }
-
- $oldcontent = $revision->getContent( Revision::RAW );
- $oldtext = AbuseFilter::contentToString( $oldcontent );
-
- // Cache article object so we can share a parse operation
- $articleCacheKey = $title->getNamespace() . ':' . $title->getText();
- AFComputedVariable::$articleCache[$articleCacheKey] = $page;
-
- // Don't trigger for null edits.
- if ( $content && $oldcontent ) {
- // Compare Content objects if available
- if ( $content->equals( $oldcontent ) ) {
- return Status::newGood();
- }
- } elseif ( strcmp( $oldtext, $text ) == 0 ) {
- // Otherwise, compare strings
- return Status::newGood();
- }
} else {
$page = null;
}
- // Load vars for filters to check
- $vars = self::newVariableHolderForEdit(
- $user, $title, $page, $summary, $content, $text, $oldcontent
- );
-
- $filter_result = AbuseFilter::filterAction( $vars, $title, 'default', $user );
- if ( !$filter_result->isOK() ) {
- $status->merge( $filter_result );
-
- return $filter_result;
+ $vars = new AbuseFilterVariableHolder();
+ $builder = new RunVariableGenerator( $vars, $user, $title );
+ $vars = $builder->getEditVars( $content, $text, $summary, $slot, $page );
+ if ( $vars === null ) {
+ // We don't have to filter the edit
+ return Status::newGood();
+ }
+ $runner = new AbuseFilterRunner( $user, $title, $vars, 'default' );
+ $filterResult = $runner->run();
+ if ( !$filterResult->isOK() ) {
+ return $filterResult;
}
- self::$successful_action_vars = $vars;
- self::$last_edit_page = $page;
+ self::$lastEditPage = $page;
return Status::newGood();
}
/**
- * @param User $user
- * @param Title $title
- * @param WikiPage|null $page
- * @param string $summary
- * @param Content $newcontent
- * @param string $text
- * @param Content|null $oldcontent
- * @return AbuseFilterVariableHolder
- * @throws MWException
- */
- private static function newVariableHolderForEdit(
- User $user, Title $title, $page, $summary, Content $newcontent,
- $text, $oldcontent = null
- ) {
- $vars = new AbuseFilterVariableHolder();
- $vars->addHolders(
- AbuseFilter::generateUserVars( $user ),
- AbuseFilter::generateTitleVars( $title, 'PAGE' )
- );
- $vars->setVar( 'action', 'edit' );
- $vars->setVar( 'summary', $summary );
- if ( $oldcontent instanceof Content ) {
- $oldmodel = $oldcontent->getModel();
- $oldtext = AbuseFilter::contentToString( $oldcontent );
- } else {
- $oldmodel = '';
- $oldtext = '';
- }
- $vars->setVar( 'old_content_model', $oldmodel );
- $vars->setVar( 'new_content_model', $newcontent->getModel() );
- $vars->setVar( 'old_wikitext', $oldtext );
- $vars->setVar( 'new_wikitext', $text );
- // TODO: set old_content and new_content vars, use them
- $vars->addHolders( AbuseFilter::getEditVars( $title, $page ) );
-
- return $vars;
- }
-
- /**
* @param Status $status Error message details
- * @return array API result
+ * @return Status Status containing the same error messages with extra data for the API
*/
- private static function getApiResult( Status $status ) {
- global $wgFullyInitialised;
-
- $params = $status->getErrorsArray()[0];
- $key = array_shift( $params );
+ private static function getApiStatus( Status $status ) {
+ $allActionsTaken = $status->getValue();
+ $statusForApi = Status::newGood();
+
+ foreach ( $status->getErrors() as $error ) {
+ list( $filterDescription, $filter ) = $error['params'];
+ $actionsTaken = $allActionsTaken[ $filter ];
+
+ $code = ( $actionsTaken === [ 'warn' ] ) ? 'abusefilter-warning' : 'abusefilter-disallowed';
+ $data = [
+ 'abusefilter' => [
+ 'id' => $filter,
+ 'description' => $filterDescription,
+ 'actions' => $actionsTaken,
+ ],
+ ];
- $warning = wfMessage( $key )->params( $params );
- if ( !$wgFullyInitialised ) {
- // This could happen for account autocreation checks
- $warning = $warning->inContentLanguage();
+ $message = ApiMessage::create( $error, $code, $data );
+ $statusForApi->fatal( $message );
}
- $filterDescription = $params[0];
- $filter = $params[1];
-
- // The value is a nested structure keyed by filter id, which doesn't make sense when we only
- // return the result from one filter. Flatten it to a plain array of actions.
- $actionsTaken = array_values( array_unique(
- array_merge( ...array_values( $status->getValue() ) )
- ) );
- $code = ( $actionsTaken === [ 'warn' ] ) ? 'abusefilter-warning' : 'abusefilter-disallowed';
-
- ApiResult::setIndexedTagName( $params, 'param' );
- return [
- 'code' => $code,
- 'message' => [
- 'key' => $key,
- 'params' => $params,
- ],
- 'abusefilter' => [
- 'id' => $filter,
- 'description' => $filterDescription,
- 'actions' => $actionsTaken,
- ],
- // For backwards-compatibility
- 'info' => 'Hit AbuseFilter: ' . $filterDescription,
- 'warning' => $warning->parse(),
- ];
+ return $statusForApi;
}
/**
* @param WikiPage $wikiPage
- * @param User $user
- * @param string $content Content
+ * @param UserIdentity $userIdentity
* @param string $summary
- * @param bool $minoredit
- * @param bool $watchthis
- * @param string $sectionanchor
* @param int $flags
- * @param Revision $revision
- * @param Status $status
- * @param int $baseRevId
+ * @param RevisionRecord $revisionRecord
*/
- public static function onPageContentSaveComplete(
- WikiPage $wikiPage, $user, $content, $summary, $minoredit, $watchthis, $sectionanchor,
- $flags, $revision, $status, $baseRevId
+ public static function onPageSaveComplete(
+ WikiPage $wikiPage,
+ UserIdentity $userIdentity,
+ string $summary,
+ int $flags,
+ RevisionRecord $revisionRecord
) {
- if ( !self::$successful_action_vars || !$revision ) {
- self::$successful_action_vars = false;
- return;
- }
-
- /** @var AbuseFilterVariableHolder|bool $vars */
- $vars = self::$successful_action_vars;
-
- if ( $vars->getVar( 'page_prefixedtitle' )->toString() !==
- $wikiPage->getTitle()->getPrefixedText()
+ $curTitle = $wikiPage->getTitle()->getPrefixedText();
+ if ( !isset( AbuseFilter::$logIds[ $curTitle ] ) ||
+ $wikiPage !== self::$lastEditPage
) {
+ // This isn't the edit AbuseFilter::$logIds was set for
+ AbuseFilter::$logIds = [];
return;
}
- if ( !self::identicalPageObjects( $wikiPage, self::$last_edit_page ) ) {
- // This isn't the edit $successful_action_vars was set for
- return;
+ // Ignore null edit.
+ $parentRevId = $revisionRecord->getParentId();
+ if ( $parentRevId !== null ) {
+ $parentRev = MediaWikiServices::getInstance()
+ ->getRevisionLookup()
+ ->getRevisionById( $parentRevId );
+ if ( $parentRev && $revisionRecord->hasSameContent( $parentRev ) ) {
+ AbuseFilter::$logIds = [];
+ return;
+ }
}
- self::$last_edit_page = false;
- if ( $vars->getVar( 'local_log_ids' ) ) {
+ self::$lastEditPage = null;
+
+ $logs = AbuseFilter::$logIds[ $curTitle ];
+ if ( $logs[ 'local' ] ) {
// Now actually do our storage
- $log_ids = $vars->getVar( 'local_log_ids' )->toNative();
$dbw = wfGetDB( DB_MASTER );
- if ( $log_ids !== null && count( $log_ids ) ) {
- $dbw->update( 'abuse_filter_log',
- [ 'afl_rev_id' => $revision->getId() ],
- [ 'afl_id' => $log_ids ],
- __METHOD__
- );
- }
+ $dbw->update( 'abuse_filter_log',
+ [ 'afl_rev_id' => $revisionRecord->getId() ],
+ [ 'afl_id' => $logs['local'] ],
+ __METHOD__
+ );
}
- if ( $vars->getVar( 'global_log_ids' ) ) {
- $log_ids = $vars->getVar( 'global_log_ids' )->toNative();
-
- if ( $log_ids !== null && count( $log_ids ) ) {
- global $wgAbuseFilterCentralDB;
- $fdb = wfGetDB( DB_MASTER, [], $wgAbuseFilterCentralDB );
-
- $fdb->update( 'abuse_filter_log',
- [ 'afl_rev_id' => $revision->getId() ],
- [ 'afl_id' => $log_ids, 'afl_wiki' => wfWikiID() ],
- __METHOD__
- );
- }
+ if ( $logs[ 'global' ] ) {
+ $fdb = AbuseFilter::getCentralDB( DB_MASTER );
+ $fdb->update( 'abuse_filter_log',
+ [ 'afl_rev_id' => $revisionRecord->getId() ],
+ [ 'afl_id' => $logs['global'], 'afl_wiki' => WikiMap::getCurrentWikiDbDomain()->getId() ],
+ __METHOD__
+ );
}
}
/**
- * Check if two article objects are identical or have an identical WikiPage
- * @param Article|WikiPage $page1
- * @param Article|WikiPage $page2
- * @return bool
- */
- protected static function identicalPageObjects( $page1, $page2 ) {
- $wpage1 = ( $page1 instanceof Article ) ? $page1->getPage() : $page1;
- $wpage2 = ( $page2 instanceof Article ) ? $page2->getPage() : $page2;
-
- return $wpage1 === $wpage2;
- }
-
- /**
* @param User $user
* @param array &$promote
*/
- public static function onGetAutoPromoteGroups( $user, &$promote ) {
+ public static function onGetAutoPromoteGroups( User $user, &$promote ) {
if ( $promote ) {
- $key = AbuseFilter::autoPromoteBlockKey( $user );
- $blocked = (bool)ObjectCache::getInstance( 'hash' )->getWithSetCallback(
+ $cache = ObjectCache::getInstance( 'hash' );
+ $key = AbuseFilter::autoPromoteBlockKey( $cache, $user );
+ $blocked = (bool)$cache->getWithSetCallback(
$key,
- 30,
- function () use ( $key ) {
- return (int)ObjectCache::getMainStashInstance()->get( $key );
+ $cache::TTL_PROC_LONG,
+ function () use ( $user ) {
+ return AbuseFilter::getAutoPromoteBlockStatus( $user );
}
);
@@ -322,25 +286,21 @@ class AbuseFilterHooks {
* @param Title $newTitle
* @param User $user
* @param string $reason
- * @param Status $status
- * @return bool
+ * @param Status &$status
*/
- public static function onMovePageCheckPermissions( Title $oldTitle, Title $newTitle,
- User $user, $reason, Status $status
+ public static function onTitleMove(
+ Title $oldTitle,
+ Title $newTitle,
+ User $user,
+ $reason,
+ Status &$status
) {
- $vars = new AbuseFilterVariableHolder;
- $vars->addHolders(
- AbuseFilter::generateUserVars( $user ),
- AbuseFilter::generateTitleVars( $oldTitle, 'MOVED_FROM' ),
- AbuseFilter::generateTitleVars( $newTitle, 'MOVED_TO' )
- );
- $vars->setVar( 'SUMMARY', $reason );
- $vars->setVar( 'ACTION', 'move' );
-
- $result = AbuseFilter::filterAction( $vars, $oldTitle, 'default', $user );
+ $vars = new AbuseFilterVariableHolder();
+ $builder = new RunVariableGenerator( $vars, $user, $oldTitle );
+ $vars = $builder->getMoveVars( $newTitle, $reason );
+ $runner = new AbuseFilterRunner( $user, $oldTitle, $vars, 'default' );
+ $result = $runner->run();
$status->merge( $result );
-
- return $result->isOK();
}
/**
@@ -351,41 +311,46 @@ class AbuseFilterHooks {
* @param Status $status
* @return bool
*/
- public static function onArticleDelete( $article, $user, $reason, &$error, $status ) {
- $vars = new AbuseFilterVariableHolder;
-
- $vars->addHolders(
- AbuseFilter::generateUserVars( $user ),
- AbuseFilter::generateTitleVars( $article->getTitle(), 'PAGE' )
- );
-
- $vars->setVar( 'SUMMARY', $reason );
- $vars->setVar( 'ACTION', 'delete' );
-
- $filter_result = AbuseFilter::filterAction( $vars, $article->getTitle(), 'default', $user );
+ public static function onArticleDelete( WikiPage $article, User $user, $reason, &$error,
+ Status $status ) {
+ $vars = new AbuseFilterVariableHolder();
+ $builder = new RunVariableGenerator( $vars, $user, $article->getTitle() );
+ $vars = $builder->getDeleteVars( $reason );
+ $runner = new AbuseFilterRunner( $user, $article->getTitle(), $vars, 'default' );
+ $filterResult = $runner->run();
- $status->merge( $filter_result );
- $error = $filter_result->isOK() ? '' : $filter_result->getHTML();
+ $status->merge( $filterResult );
+ $error = $filterResult->isOK() ? '' : $filterResult->getHTML();
- return $filter_result->isOK();
+ return $filterResult->isOK();
}
/**
* @param RecentChange $recentChange
*/
- public static function onRecentChangeSave( $recentChange ) {
+ public static function onRecentChangeSave( RecentChange $recentChange ) {
$title = Title::makeTitle(
$recentChange->getAttribute( 'rc_namespace' ),
$recentChange->getAttribute( 'rc_title' )
);
- $action = $recentChange->mAttribs['rc_log_type'] ?
- $recentChange->mAttribs['rc_log_type'] : 'edit';
- $actionID = implode( '-', [
- $title->getPrefixedText(), $recentChange->getAttribute( 'rc_user_text' ), $action
- ] );
+
+ $logType = $recentChange->getAttribute( 'rc_log_type' ) ?: 'edit';
+ if ( $logType === 'newusers' ) {
+ $action = $recentChange->getAttribute( 'rc_log_action' ) === 'autocreate' ?
+ 'autocreateaccount' :
+ 'createaccount';
+ } else {
+ $action = $logType;
+ }
+ $actionID = AbuseFilter::getTaggingActionId(
+ $action,
+ $title,
+ $recentChange->getAttribute( 'rc_user_text' )
+ );
if ( isset( AbuseFilter::$tagsToSet[$actionID] ) ) {
$recentChange->addTags( AbuseFilter::$tagsToSet[$actionID] );
+ unset( AbuseFilter::$tagsToSet[$actionID] );
}
}
@@ -408,7 +373,7 @@ class AbuseFilterHooks {
}
/**
- * @param array $tags
+ * @param array &$tags
* @param bool $enabled
*/
private static function fetchAllTags( array &$tags, $enabled ) {
@@ -419,10 +384,8 @@ class AbuseFilterHooks {
$tags = $cache->getWithSetCallback(
// Key to store the cached value under
$cache->makeKey( self::FETCH_ALL_TAGS_KEY, (int)$enabled ),
-
// Time-to-live (in seconds)
$cache::TTL_MINUTE,
-
// Function that derives the new key value
function ( $oldValue, &$ttl, array &$setOpts ) use ( $enabled, $tags, $fname ) {
global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral;
@@ -453,12 +416,11 @@ class AbuseFilterHooks {
}
if ( $wgAbuseFilterCentralDB && !$wgAbuseFilterIsCentral ) {
- $dbr = wfGetDB( DB_REPLICA, [], $wgAbuseFilterCentralDB );
- $where['af_global'] = 1;
+ $dbr = AbuseFilter::getCentralDB( DB_REPLICA );
$res = $dbr->select(
[ 'abuse_filter_action', 'abuse_filter' ],
'afa_parameters',
- $where,
+ [ 'af_global' => 1 ] + $where,
$fname,
[],
[ 'abuse_filter' => [ 'INNER JOIN', 'afa_filter=af_id' ] ]
@@ -499,18 +461,17 @@ class AbuseFilterHooks {
public static function onLoadExtensionSchemaUpdates( DatabaseUpdater $updater ) {
$dir = dirname( __DIR__ );
- if ( $updater->getDB()->getType() == 'mysql' || $updater->getDB()->getType() == 'sqlite' ) {
- if ( $updater->getDB()->getType() == 'mysql' ) {
+ if ( $updater->getDB()->getType() === 'mysql' || $updater->getDB()->getType() === 'sqlite' ) {
+ if ( $updater->getDB()->getType() === 'mysql' ) {
$updater->addExtensionUpdate( [ 'addTable', 'abuse_filter',
"$dir/abusefilter.tables.sql", true ] );
- $updater->addExtensionUpdate( [ 'addTable', 'abuse_filter_history',
- "$dir/db_patches/patch-abuse_filter_history.sql", true ] );
} else {
$updater->addExtensionUpdate( [ 'addTable', 'abuse_filter',
"$dir/abusefilter.tables.sqlite.sql", true ] );
- $updater->addExtensionUpdate( [ 'addTable', 'abuse_filter_history',
- "$dir/db_patches/patch-abuse_filter_history.sqlite.sql", true ] );
}
+ $updater->addExtensionTable( 'abuse_filter_history',
+ "$dir/db_patches/patch-abuse_filter_history.sql" );
+
$updater->addExtensionUpdate( [
'addField', 'abuse_filter_history', 'afh_changed_fields',
"$dir/db_patches/patch-afh_changed_fields.sql", true
@@ -523,7 +484,7 @@ class AbuseFilterHooks {
"$dir/db_patches/patch-global_filters.sql", true ] );
$updater->addExtensionUpdate( [ 'addField', 'abuse_filter_log', 'afl_rev_id',
"$dir/db_patches/patch-afl_action_id.sql", true ] );
- if ( $updater->getDB()->getType() == 'mysql' ) {
+ if ( $updater->getDB()->getType() === 'mysql' ) {
$updater->addExtensionUpdate( [ 'addIndex', 'abuse_filter_log',
'filter_timestamp', "$dir/db_patches/patch-fix-indexes.sql", true ] );
} else {
@@ -536,42 +497,42 @@ class AbuseFilterHooks {
$updater->addExtensionUpdate( [ 'addField', 'abuse_filter',
'af_group', "$dir/db_patches/patch-af_group.sql", true ] );
- if ( $updater->getDB()->getType() == 'mysql' ) {
+ $updater->addExtensionIndex(
+ 'abuse_filter_log', 'afl_wiki_timestamp',
+ "$dir/db_patches/patch-global_logging_wiki-index.sql"
+ );
+
+ if ( $updater->getDB()->getType() === 'mysql' ) {
$updater->addExtensionUpdate( [
- 'addIndex', 'abuse_filter_log', 'wiki_timestamp',
- "$dir/db_patches/patch-global_logging_wiki-index.sql", true
+ 'modifyField', 'abuse_filter_log', 'afl_namespace',
+ "$dir/db_patches/patch-afl-namespace_int.sql", true
] );
} else {
$updater->addExtensionUpdate( [
- 'addIndex', 'abuse_filter_log', 'afl_wiki_timestamp',
- "$dir/db_patches/patch-global_logging_wiki-index.sqlite.sql", true
+ 'modifyField', 'abuse_filter_log', 'afl_namespace',
+ "$dir/db_patches/patch-afl-namespace_int.sqlite.sql", true
] );
}
+ if ( $updater->getDB()->getType() === 'mysql' ) {
+ $updater->addExtensionUpdate( [ 'dropField', 'abuse_filter_log',
+ 'afl_log_id', "$dir/db_patches/patch-drop_afl_log_id.sql", true ] );
+ } else {
+ $updater->addExtensionUpdate( [ 'dropField', 'abuse_filter_log',
+ 'afl_log_id', "$dir/db_patches/patch-drop_afl_log_id.sqlite.sql", true ] );
+ }
if ( $updater->getDB()->getType() == 'mysql' ) {
$updater->addExtensionUpdate( [
- 'modifyField', 'abuse_filter_log', 'afl_namespace',
- "$dir/db_patches/patch-afl-namespace_int.sql", true
+ 'addIndex', 'abuse_filter_log', 'filter_timestamp_full',
+ "$dir/db_patches/patch-split-afl_filter.sql", true
] );
} else {
- /*
- $updater->addExtensionUpdate( array(
- 'modifyField',
- 'abuse_filter_log',
- 'afl_namespace',
- "$dir/db_patches/patch-afl-namespace_int.sqlite.sql",
- true
- ) );
- */
- /* @todo Modify a column in sqlite, which do not support such
- * things create backup, drop, create with new schema, copy,
- * drop backup or simply see
- * https://www.mediawiki.org/wiki/Manual:SQLite#About_SQLite :
- * Several extensions are known to have database update or
- * installation issues with SQLite: AbuseFilter, ...
- */
+ $updater->addExtensionUpdate( [
+ 'addIndex', 'abuse_filter_log', 'filter_timestamp_full',
+ "$dir/db_patches/patch-split-afl_filter.sqlite.sql", true
+ ] );
}
- } elseif ( $updater->getDB()->getType() == 'postgres' ) {
+ } elseif ( $updater->getDB()->getType() === 'postgres' ) {
$updater->addExtensionUpdate( [
'addTable', 'abuse_filter', "$dir/abusefilter.tables.pg.sql", true ] );
$updater->addExtensionUpdate( [
@@ -605,8 +566,6 @@ class AbuseFilterHooks {
$updater->addExtensionUpdate( [
'addPgField', 'abuse_filter_log', 'afl_rev_id', 'INTEGER' ] );
$updater->addExtensionUpdate( [
- 'addPgField', 'abuse_filter_log', 'afl_log_id', 'INTEGER' ] );
- $updater->addExtensionUpdate( [
'changeField', 'abuse_filter_log', 'afl_filter', 'TEXT', '' ] );
$updater->addExtensionUpdate( [
'changeField', 'abuse_filter_log', 'afl_namespace', "INTEGER", '' ] );
@@ -643,23 +602,34 @@ class AbuseFilterHooks {
'(afl_rev_id)'
] );
$updater->addExtensionUpdate( [
- 'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_log_id',
- '(afl_log_id)'
- ] );
- $updater->addExtensionUpdate( [
'addPgExtIndex', 'abuse_filter_log', 'abuse_filter_log_wiki_timestamp',
'(afl_wiki,afl_timestamp)'
] );
+ $updater->addExtensionUpdate( [
+ 'dropPgField', 'abuse_filter_log', 'afl_log_id' ] );
+ $updater->addExtensionUpdate( [
+ 'setDefault', 'abuse_filter_log', 'afl_filter', ''
+ ] );
+ $updater->addExtensionUpdate( [
+ 'addPgField', 'abuse_filter_log', 'afl_global', 'SMALLINT NOT NULL DEFAULT 0' ] );
+ $updater->addExtensionUpdate( [
+ 'addPgField', 'abuse_filter_log', 'afl_filter_id', 'INTEGER NOT NULL DEFAULT 0' ] );
+ $updater->addExtensionUpdate( [
+ 'addPgIndex', 'abuse_filter_log', 'abuse_filter_log_filter_timestamp_full',
+ '(afl_global, afl_filter_id, afl_timestamp)' ] );
}
$updater->addExtensionUpdate( [ [ __CLASS__, 'createAbuseFilterUser' ] ] );
+ $updater->addPostDatabaseUpdateMaintenance( 'NormalizeThrottleParameters' );
+ $updater->addPostDatabaseUpdateMaintenance( 'FixOldLogEntries' );
+ $updater->addPostDatabaseUpdateMaintenance( 'UpdateVarDumps' );
}
/**
* Updater callback to create the AbuseFilter user after the user tables have been updated.
* @param DatabaseUpdater $updater
*/
- public static function createAbuseFilterUser( $updater ) {
+ public static function createAbuseFilterUser( DatabaseUpdater $updater ) {
$username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text();
$user = User::newFromName( $username );
@@ -677,9 +647,12 @@ class AbuseFilterHooks {
* @param array &$tools
* @param SpecialPage $sp for context
*/
- public static function onContributionsToolLinks( $id, $nt, array &$tools, SpecialPage $sp ) {
+ public static function onContributionsToolLinks( $id, Title $nt, array &$tools, SpecialPage $sp ) {
$username = $nt->getText();
- if ( $sp->getUser()->isAllowed( 'abusefilter-log' ) && !IP::isValidRange( $username ) ) {
+ if ( MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $sp->getUser(), 'abusefilter-log' )
+ && !IPUtils::isValidRange( $username )
+ ) {
$linkRenderer = $sp->getLinkRenderer();
$tools['abuselog'] = $linkRenderer->makeLink(
SpecialPage::getTitleFor( 'AbuseLog' ),
@@ -702,7 +675,9 @@ class AbuseFilterHooks {
array &$links
) {
$user = $context->getUser();
- if ( $user->isAllowed( 'abusefilter-log' ) ) {
+ if ( MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $user, 'abusefilter-log' )
+ ) {
$links[] = $linkRenderer->makeLink(
SpecialPage::getTitleFor( 'AbuseLog' ),
$context->msg( 'abusefilter-log-linkonhistory' )->text(),
@@ -713,18 +688,43 @@ class AbuseFilterHooks {
}
/**
+ * @param IContextSource $context
+ * @param LinkRenderer $linkRenderer
+ * @param string[] &$links
+ */
+ public static function onUndeletePageToolLinks(
+ IContextSource $context,
+ LinkRenderer $linkRenderer,
+ array &$links
+ ) {
+ $pm = MediaWikiServices::getInstance()->getPermissionManager();
+ $show = $pm->userHasRight( $context->getUser(), 'abusefilter-log' );
+ $action = $context->getRequest()->getVal( 'action', 'view' );
+
+ // For 'history action', the link would be added by HistoryPageToolLinks hook.
+ if ( $show && $action !== 'history' ) {
+ $links[] = $linkRenderer->makeLink(
+ SpecialPage::getTitleFor( 'AbuseLog' ),
+ $context->msg( 'abusefilter-log-linkonundelete' )->text(),
+ [ 'title' => $context->msg( 'abusefilter-log-linkonundelete-text' )->text() ],
+ [ 'wpSearchTitle' => $context->getTitle()->getPrefixedText() ]
+ );
+ }
+ }
+
+ /**
* Filter an upload.
*
* @param UploadBase $upload
* @param User $user
- * @param array $props
+ * @param array|null $props
* @param string $comment
* @param string $pageText
* @param array|ApiMessage &$error
* @return bool
*/
public static function onUploadVerifyUpload( UploadBase $upload, User $user,
- array $props, $comment, $pageText, &$error
+ $props, $comment, $pageText, &$error
) {
return self::filterUpload( 'upload', $upload, $user, $props, $comment, $pageText, $error );
}
@@ -752,105 +752,61 @@ class AbuseFilterHooks {
* @param string $action 'upload' or 'stashupload'
* @param UploadBase $upload
* @param User $user User performing the action
- * @param array $props File properties, as returned by FSFile::getPropsFromPath()
+ * @param array|null $props File properties, as returned by MWFileProps::getPropsFromPath().
* @param string|null $summary Upload log comment (also used as edit summary)
* @param string|null $text File description page text (only used for new uploads)
* @param array|ApiMessage &$error
* @return bool
*/
public static function filterUpload( $action, UploadBase $upload, User $user,
- array $props, $summary, $text, &$error
+ $props, $summary, $text, &$error
) {
$title = $upload->getTitle();
-
- $vars = new AbuseFilterVariableHolder;
- $vars->addHolders(
- AbuseFilter::generateUserVars( $user ),
- AbuseFilter::generateTitleVars( $title, 'PAGE' )
- );
- $vars->setVar( 'ACTION', $action );
-
- // We use the hexadecimal version of the file sha1.
- // Use UploadBase::getTempFileSha1Base36 so that we don't have to calculate the sha1 sum again
- $sha1 = Wikimedia\base_convert( $upload->getTempFileSha1Base36(), 36, 16, 40 );
-
- $vars->setVar( 'file_sha1', $sha1 );
- $vars->setVar( 'file_size', $upload->getFileSize() );
-
- $vars->setVar( 'file_mime', $props['mime'] );
- $vars->setVar(
- 'file_mediatype',
- MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer()
- ->getMediaType( null, $props['mime'] )
- );
- $vars->setVar( 'file_width', $props['width'] );
- $vars->setVar( 'file_height', $props['height'] );
- $vars->setVar( 'file_bits_per_channel', $props['bits'] );
-
- // We only have the upload comment and page text when using the UploadVerifyUpload hook
- if ( $summary !== null && $text !== null ) {
- // This block is adapted from self::filterEdit()
- if ( $title->exists() ) {
- $page = WikiPage::factory( $title );
- $revision = $page->getRevision();
- if ( !$revision ) {
- return true;
- }
-
- $oldcontent = $revision->getContent( Revision::RAW );
- $oldtext = AbuseFilter::contentToString( $oldcontent );
-
- // Cache article object so we can share a parse operation
- $articleCacheKey = $title->getNamespace() . ':' . $title->getText();
- AFComputedVariable::$articleCache[$articleCacheKey] = $page;
-
- // Page text is ignored for uploads when the page already exists
- $text = $oldtext;
- } else {
- $page = null;
- $oldtext = '';
- }
-
- // Load vars for filters to check
- $vars->setVar( 'summary', $summary );
- $vars->setVar( 'minor_edit', false );
- $vars->setVar( 'old_wikitext', $oldtext );
- $vars->setVar( 'new_wikitext', $text );
- // TODO: set old_content and new_content vars, use them
- $vars->addHolders( AbuseFilter::getEditVars( $title, $page ) );
+ if ( $title === null ) {
+ // T144265: This could happen for 'stashupload' if the specified title is invalid.
+ // Let UploadBase warn the user about that, and we'll filter later.
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->warning( __METHOD__ . " received a null title. Action: $action." );
+ return true;
}
- $filter_result = AbuseFilter::filterAction( $vars, $title, 'default', $user );
+ $vars = new AbuseFilterVariableHolder();
+ $builder = new RunVariableGenerator( $vars, $user, $title );
+ $vars = $builder->getUploadVars( $action, $upload, $summary, $text, $props );
+ if ( $vars === null ) {
+ return true;
+ }
+ $runner = new AbuseFilterRunner( $user, $title, $vars, 'default' );
+ $filterResult = $runner->run();
- if ( !$filter_result->isOK() ) {
- $messageAndParams = $filter_result->getErrorsArray()[0];
- $apiResult = self::getApiResult( $filter_result );
- $error = ApiMessage::create(
- $messageAndParams,
- $apiResult['code'],
- $apiResult
- );
+ if ( !$filterResult->isOK() ) {
+ // Produce a useful error message for API edits
+ $filterResultApi = self::getApiStatus( $filterResult );
+ // @todo Return all errors instead of only the first one
+ $error = $filterResultApi->getErrors()[0]['message'];
}
- return $filter_result->isOK();
+ return $filterResult->isOK();
}
/**
- * Adds global variables to the Javascript as needed
+ * For integration with the Renameuser extension.
*
- * @param array &$vars
+ * @param RenameuserSQL $renameUserSQL
*/
- public static function onMakeGlobalVariablesScript( array &$vars ) {
- if ( isset( AbuseFilter::$editboxName ) && AbuseFilter::$editboxName !== null ) {
- $vars['abuseFilterBoxName'] = AbuseFilter::$editboxName;
- }
-
- if ( AbuseFilterViewExamine::$examineType !== null ) {
- $vars['abuseFilterExamine'] = [
- 'type' => AbuseFilterViewExamine::$examineType,
- 'id' => AbuseFilterViewExamine::$examineId,
- ];
- }
+ public static function onRenameUserSQL( RenameuserSQL $renameUserSQL ) {
+ $renameUserSQL->tablesJob['abuse_filter'] = [
+ RenameuserSQL::NAME_COL => 'af_user_text',
+ RenameuserSQL::UID_COL => 'af_user',
+ RenameuserSQL::TIME_COL => 'af_timestamp',
+ 'uniqueKey' => 'af_id'
+ ];
+ $renameUserSQL->tablesJob['abuse_filter_history'] = [
+ RenameuserSQL::NAME_COL => 'afh_user_text',
+ RenameuserSQL::UID_COL => 'afh_user',
+ RenameuserSQL::TIME_COL => 'afh_timestamp',
+ 'uniqueKey' => 'afh_id'
+ ];
}
/**
@@ -871,30 +827,79 @@ class AbuseFilterHooks {
* @param Content $content
* @param ParserOutput $output
* @param string $summary
- * @param User|null $user
+ * @param User $user
*/
public static function onParserOutputStashForEdit(
- WikiPage $page, Content $content, ParserOutput $output, $summary = '', $user = null
+ WikiPage $page, Content $content, ParserOutput $output, string $summary, User $user
) {
- $revision = $page->getRevision();
- if ( !$revision ) {
- return;
- }
-
- $text = AbuseFilter::contentToString( $content );
- $oldcontent = $revision->getContent( Revision::RAW );
- $user = $user ?: RequestContext::getMain()->getUser();
+ // XXX: This makes the assumption that this method is only ever called for the main slot.
+ // Which right now holds true, but any more fancy MCR stuff will likely break here...
+ $slot = SlotRecord::MAIN;
// Cache any resulting filter matches.
// Do this outside the synchronous stash lock to avoid any chance of slowdown.
DeferredUpdates::addCallableUpdate(
- function () use ( $user, $page, $summary, $content, $text, $oldcontent ) {
- $vars = self::newVariableHolderForEdit(
- $user, $page->getTitle(), $page, $summary, $content, $text, $oldcontent
- );
- AbuseFilter::filterAction( $vars, $page->getTitle(), 'default', $user, 'stash' );
+ function () use (
+ $user,
+ $page,
+ $summary,
+ $content,
+ $slot
+ ) {
+ $startTime = microtime( true );
+ $vars = new AbuseFilterVariableHolder();
+ $generator = new RunVariableGenerator( $vars, $user, $page->getTitle() );
+ $vars = $generator->getStashEditVars( $content, $summary, $slot, $page );
+ if ( !$vars ) {
+ return;
+ }
+ $runner = new AbuseFilterRunner( $user, $page->getTitle(), $vars, 'default' );
+ $runner->runForStash();
+ $totalTime = microtime( true ) - $startTime;
+ MediaWikiServices::getInstance()->getStatsdDataFactory()
+ ->timing( 'timing.stashAbuseFilter', $totalTime );
},
DeferredUpdates::PRESEND
);
}
+
+ /**
+ * Setup tables to emulate global filters, used in AbuseFilterConsequencesTest.
+ *
+ * @param IMaintainableDatabase $db
+ * @param string $prefix The prefix used in unit tests
+ * @suppress PhanUndeclaredClassConstant AbuseFilterConsequencesTest is in AutoloadClasses
+ * @suppress PhanUndeclaredClassStaticProperty AbuseFilterConsequencesTest is in AutoloadClasses
+ */
+ public static function onUnitTestsAfterDatabaseSetup( IMaintainableDatabase $db, $prefix ) {
+ $externalPrefix = AbuseFilterConsequencesTest::DB_EXTERNAL_PREFIX;
+ if ( $db->tableExists( $externalPrefix . AbuseFilterConsequencesTest::$externalTables[0], __METHOD__ ) ) {
+ // Check a random table to avoid unnecessary table creations. See T155147.
+ return;
+ }
+
+ foreach ( AbuseFilterConsequencesTest::$externalTables as $table ) {
+ // Don't create them as temporary, as we'll access the DB via another connection
+ $db->duplicateTableStructure(
+ "$prefix$table",
+ "$prefix$externalPrefix$table",
+ false,
+ __METHOD__
+ );
+ }
+ }
+
+ /**
+ * Drop tables used for global filters in AbuseFilterConsequencesTest.
+ * Note: this has the same problem as T201290.
+ *
+ * @suppress PhanUndeclaredClassConstant AbuseFilterConsequencesTest is in AutoloadClasses
+ * @suppress PhanUndeclaredClassStaticProperty AbuseFilterConsequencesTest is in AutoloadClasses
+ */
+ public static function onUnitTestsBeforeDatabaseTeardown() {
+ $db = wfGetDB( DB_MASTER );
+ foreach ( AbuseFilterConsequencesTest::$externalTables as $table ) {
+ $db->dropTable( AbuseFilterConsequencesTest::DB_EXTERNAL_PREFIX . $table );
+ }
+ }
}
diff --git a/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php b/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php
index 769c27d3..8defb8b5 100644
--- a/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php
+++ b/AbuseFilter/includes/AbuseFilterModifyLogFormatter.php
@@ -15,6 +15,7 @@ class AbuseFilterModifyLogFormatter extends LogFormatter {
/**
* @return array
+ * @suppress SecurityCheck-DoubleEscaped taint-check false positives
*/
protected function extractParameters() {
$parameters = $this->entry->getParameters();
diff --git a/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php b/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php
index ed72c5a4..343f5478 100644
--- a/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php
+++ b/AbuseFilter/includes/AbuseFilterPreAuthenticationProvider.php
@@ -2,6 +2,8 @@
use MediaWiki\Auth\AbstractPreAuthenticationProvider;
use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Extension\AbuseFilter\VariableGenerator\RunVariableGenerator;
+use MediaWiki\MediaWikiServices;
class AbuseFilterPreAuthenticationProvider extends AbstractPreAuthenticationProvider {
/**
@@ -35,23 +37,22 @@ class AbuseFilterPreAuthenticationProvider extends AbstractPreAuthenticationProv
* @return StatusValue
*/
protected function testUser( $user, $creator, $autocreate ) {
- if ( $user->getName() == wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text() ) {
+ $startTime = microtime( true );
+ if ( $user->getName() === wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text() ) {
return StatusValue::newFatal( 'abusefilter-accountreserved' );
}
- $vars = new AbuseFilterVariableHolder;
-
- // generateUserVars records $creator->getName() which would be the IP for unregistered users
- if ( $creator->isLoggedIn() ) {
- $vars->addHolders( AbuseFilter::generateUserVars( $creator ) );
- }
-
- $vars->setVar( 'ACTION', $autocreate ? 'autocreateaccount' : 'createaccount' );
- $vars->setVar( 'ACCOUNTNAME', $user->getName() );
+ $title = SpecialPage::getTitleFor( 'Userlogin' );
+ $vars = new AbuseFilterVariableHolder();
+ $builder = new RunVariableGenerator( $vars, $creator, $title );
+ $vars = $builder->getAccountCreationVars( $user, $autocreate );
// pass creator in explicitly to prevent recording the current user on autocreation - T135360
- $status = AbuseFilter::filterAction( $vars, SpecialPage::getTitleFor( 'Userlogin' ),
- 'default', $creator );
+ $runner = new AbuseFilterRunner( $creator, $title, $vars, 'default' );
+ $status = $runner->run();
+
+ MediaWikiServices::getInstance()->getStatsdDataFactory()
+ ->timing( 'timing.createaccountAbuseFilter', microtime( true ) - $startTime );
return $status->getStatusValue();
}
diff --git a/AbuseFilter/includes/AbuseFilterRightsLogFormatter.php b/AbuseFilter/includes/AbuseFilterRightsLogFormatter.php
new file mode 100644
index 00000000..37255d2f
--- /dev/null
+++ b/AbuseFilter/includes/AbuseFilterRightsLogFormatter.php
@@ -0,0 +1,42 @@
+<?php
+
+class AbuseFilterRightsLogFormatter extends LogFormatter {
+
+ /**
+ * This method is identical to the parent, but it's redeclared to give grep a chance
+ * to find the messages.
+ * @inheritDoc
+ */
+ protected function getMessageKey() {
+ $subtype = $this->entry->getSubtype();
+ // Messages that can be used here:
+ // * logentry-rights-blockautopromote
+ // * logentry-rights-restoreautopromote
+ return "logentry-rights-$subtype";
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function extractParameters() {
+ $ret = [];
+ $ret[3] = $this->entry->getTarget()->getText();
+ if ( $this->entry->getSubType() === 'blockautopromote' ) {
+ $parameters = $this->entry->getParameters();
+ $duration = $parameters['7::duration'];
+ $ret[4] = $this->context->getLanguage()->formatDuration( $duration );
+ }
+ return $ret;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getMessageParameters() {
+ $params = parent::getMessageParameters();
+ // remove "User:" prefix
+ $params[2] = $this->formatParameterValue( 'user-link', $this->entry->getTarget()->getText() );
+ return $params;
+ }
+
+}
diff --git a/AbuseFilter/includes/AbuseFilterRunner.php b/AbuseFilter/includes/AbuseFilterRunner.php
new file mode 100644
index 00000000..eca20bae
--- /dev/null
+++ b/AbuseFilter/includes/AbuseFilterRunner.php
@@ -0,0 +1,1434 @@
+<?php
+
+use MediaWiki\Block\DatabaseBlock;
+use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
+use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Session\SessionManager;
+use Wikimedia\IPUtils;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * This class contains the logic for executing abuse filters and their actions. The entry points are
+ * run() and runForStash(). Note that run() can only be executed once on a given instance.
+ * @todo In a perfect world, every time this class gets constructed we should have a context
+ * source at hand. Unfortunately, this currently isn't true, as the hooks used for filtering
+ * don't pass a full context. If they did, this class would just extend ContextSource and use
+ * that to retrieve user, title, globals etc.
+ */
+class AbuseFilterRunner {
+ /**
+ * @var User The user who performed the action being filtered
+ */
+ protected $user;
+ /**
+ * @var Title The title where the action being filtered was performed
+ */
+ protected $title;
+ /**
+ * @var AbuseFilterVariableHolder The variables for the current action
+ */
+ protected $vars;
+ /**
+ * @var string The group of filters to check (as defined in $wgAbuseFilterValidGroups)
+ */
+ protected $group;
+ /**
+ * @var string The action we're filtering
+ */
+ protected $action;
+
+ /**
+ * @var array Data from per-filter profiling. Shape:
+ * [ filterName => [ 'time' => float, 'conds' => int, 'result' => bool ] ]
+ * @phan-var array<string,array{time:float,conds:int,result:bool}>
+ *
+ * Where 'timeTaken' is in seconds, 'result' is a boolean indicating whether the filter matched
+ * the action, and 'filterID' is "{prefix}-{ID}" ; Prefix should be empty for local
+ * filters. In stash mode this member is saved in cache, while in execute mode it's used to
+ * update profiling after checking all filters.
+ */
+ protected $profilingData;
+
+ /**
+ * @var AbuseFilterParser The parser instance to use to check all filters
+ * @protected Public for back-compat only, will be made protected. self::init already handles
+ * building a parser object.
+ */
+ public $parser;
+ /**
+ * @var bool Whether a run() was already performed. Used to avoid multiple executions with the
+ * same members.
+ */
+ private $executed = false;
+
+ /** @var AbuseFilterHookRunner */
+ private $hookRunner;
+
+ /**
+ * @param User $user The user who performed the action being filtered
+ * @param Title $title The title where the action being filtered was performed
+ * @param AbuseFilterVariableHolder $vars The variables for the current action
+ * @param string $group The group of filters to check. It must be defined as so in
+ * $wgAbuseFilterValidGroups, or this will throw.
+ * @throws InvalidArgumentException
+ */
+ public function __construct( User $user, Title $title, AbuseFilterVariableHolder $vars, $group ) {
+ global $wgAbuseFilterValidGroups;
+ if ( !in_array( $group, $wgAbuseFilterValidGroups ) ) {
+ throw new InvalidArgumentException( '$group must be defined in $wgAbuseFilterValidGroups' );
+ }
+ if ( !$vars->varIsSet( 'action' ) ) {
+ throw new InvalidArgumentException( "The 'action' variable is not set." );
+ }
+ $this->user = $user;
+ $this->title = $title;
+ $this->vars = $vars;
+ $this->vars->setLogger( LoggerFactory::getInstance( 'AbuseFilter' ) );
+ $this->group = $group;
+ $this->action = $vars->getVar( 'action' )->toString();
+ $this->hookRunner = AbuseFilterHookRunner::getRunner();
+ }
+
+ /**
+ * Inits variables and parser right before running
+ */
+ private function init() {
+ // Add vars from extensions
+ $this->hookRunner->onAbuseFilterFilterAction(
+ $this->vars,
+ $this->title
+ );
+ $this->hookRunner->onAbuseFilterAlterVariables(
+ $this->vars,
+ $this->title,
+ $this->user
+ );
+ $generator = new VariableGenerator( $this->vars );
+ $this->vars = $generator->addGenericVars()->getVariableHolder();
+
+ $this->vars->forFilter = true;
+ $this->vars->setVar( 'timestamp', (int)wfTimestamp( TS_UNIX ) );
+ $this->parser = $this->getParser();
+ $this->parser->setStatsd( MediaWikiServices::getInstance()->getStatsdDataFactory() );
+ $this->profilingData = [];
+ }
+
+ /**
+ * Shortcut method, so that it can be overridden in mocks.
+ * @return AbuseFilterParser
+ */
+ protected function getParser() : AbuseFilterParser {
+ return AbuseFilter::getDefaultParser( $this->vars );
+ }
+
+ /**
+ * The main entry point of this class. This method runs all filters and takes their consequences.
+ *
+ * @param bool $allowStash Whether we are allowed to check the cache to see if there's a cached
+ * result of a previous execution for the same edit.
+ * @throws BadMethodCallException If run() was already called on this instance
+ * @return Status Good if no action has been taken, a fatal otherwise.
+ */
+ public function run( $allowStash = true ) : Status {
+ global $wgAbuseFilterActions;
+ if ( $this->executed ) {
+ throw new BadMethodCallException( 'run() was already called on this instance.' );
+ }
+ $this->executed = true;
+ $this->init();
+
+ $skipReasons = [];
+ $shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
+ $this->vars, $this->title, $this->user, $skipReasons
+ );
+ if ( !$shouldFilter ) {
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->info(
+ 'Skipping action {action}. Reasons provided: {reasons}',
+ [ 'action' => $this->action, 'reasons' => implode( ', ', $skipReasons ) ]
+ );
+ return Status::newGood();
+ }
+
+ $useStash = $allowStash && $this->action === 'edit';
+
+ $fromCache = false;
+ $result = [];
+ if ( $useStash ) {
+ $cacheData = $this->seekCache();
+ if ( $cacheData !== false ) {
+ if ( isset( $wgAbuseFilterActions['tag'] ) && $wgAbuseFilterActions['tag'] ) {
+ // Merge in any tags to apply to recent changes entries
+ AbuseFilter::bufferTagsToSetByAction( $cacheData['tags'] );
+ }
+ // Use cached vars (T176291) and profiling data (T191430)
+ $this->vars = AbuseFilterVariableHolder::newFromArray( $cacheData['vars'] );
+ $result = [
+ 'matches' => $cacheData['matches'],
+ 'runtime' => $cacheData['runtime'],
+ 'condCount' => $cacheData['condCount'],
+ 'profiling' => $cacheData['profiling']
+ ];
+ $fromCache = true;
+ }
+ }
+
+ if ( !$fromCache ) {
+ $startTime = microtime( true );
+ // Ensure there's no extra time leftover
+ AFComputedVariable::$profilingExtraTime = 0;
+
+ // This also updates $this->profilingData and $this->parser->mCondCount used later
+ $matches = $this->checkAllFilters();
+ $timeTaken = ( microtime( true ) - $startTime - AFComputedVariable::$profilingExtraTime ) * 1000;
+ $result = [
+ 'matches' => $matches,
+ 'runtime' => $timeTaken,
+ 'condCount' => $this->parser->getCondCount(),
+ 'profiling' => $this->profilingData
+ ];
+ }
+ '@phan-var array{matches:array,runtime:int,condCount:int,profiling:array} $result';
+
+ $matchedFilters = array_keys( array_filter( $result['matches'] ) );
+ $allFilters = array_keys( $result['matches'] );
+
+ $this->profileExecution( $result, $matchedFilters, $allFilters );
+
+ if ( count( $matchedFilters ) === 0 ) {
+ return Status::newGood();
+ }
+
+ $status = $this->executeFilterActions( $matchedFilters );
+ $actionsTaken = $status->getValue();
+
+ $this->addLogEntries( $actionsTaken );
+
+ return $status;
+ }
+
+ /**
+ * Similar to run(), but runs in "stash" mode, which means filters are executed, no actions are
+ * taken, and the result is saved in cache to be later reused. This can only be used for edits,
+ * and not doing so will throw.
+ *
+ * @throws InvalidArgumentException
+ * @return Status Always a good status, since we're only saving data.
+ */
+ public function runForStash() : Status {
+ if ( $this->action !== 'edit' ) {
+ throw new InvalidArgumentException(
+ __METHOD__ . " can only be called for edits, called for action {$this->action}."
+ );
+ }
+
+ $this->init();
+
+ $skipReasons = [];
+ $shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
+ $this->vars, $this->title, $this->user, $skipReasons
+ );
+ if ( !$shouldFilter ) {
+ // Don't log it yet
+ return Status::newGood();
+ }
+
+ $cache = ObjectCache::getLocalClusterInstance();
+ $stashKey = $this->getStashKey( $cache );
+
+ $startTime = microtime( true );
+ // Ensure there's no extra time leftover
+ AFComputedVariable::$profilingExtraTime = 0;
+
+ $matchedFilters = $this->checkAllFilters();
+ // Save the filter stash result and do nothing further
+ $cacheData = [
+ 'matches' => $matchedFilters,
+ 'tags' => AbuseFilter::$tagsToSet,
+ 'condCount' => $this->parser->getCondCount(),
+ 'runtime' => ( microtime( true ) - $startTime - AFComputedVariable::$profilingExtraTime ) * 1000,
+ 'vars' => $this->vars->dumpAllVars(),
+ 'profiling' => $this->profilingData
+ ];
+
+ $cache->set( $stashKey, $cacheData, $cache::TTL_MINUTE );
+ $this->logCache( 'store', $stashKey );
+
+ return Status::newGood();
+ }
+
+ /**
+ * Search the cache to find data for a previous execution done for the current edit.
+ *
+ * @return false|array False on failure, the array with data otherwise
+ */
+ protected function seekCache() {
+ $cache = ObjectCache::getLocalClusterInstance();
+ $stashKey = $this->getStashKey( $cache );
+
+ $ret = $cache->get( $stashKey );
+ $status = $ret !== false ? 'hit' : 'miss';
+ $this->logCache( $status, $stashKey );
+
+ return $ret;
+ }
+
+ /**
+ * Get the stash key for the current variables
+ *
+ * @param BagOStuff $cache
+ * @return string
+ */
+ protected function getStashKey( BagOStuff $cache ) {
+ $inputVars = $this->vars->exportNonLazyVars();
+ // Exclude noisy fields that have superficial changes
+ $excludedVars = [
+ 'old_html' => true,
+ 'new_html' => true,
+ 'user_age' => true,
+ 'timestamp' => true,
+ 'page_age' => true,
+ 'moved_from_age' => true,
+ 'moved_to_age' => true
+ ];
+
+ $inputVars = array_diff_key( $inputVars, $excludedVars );
+ ksort( $inputVars );
+ $hash = md5( serialize( $inputVars ) );
+
+ return $cache->makeKey(
+ 'abusefilter',
+ 'check-stash',
+ $this->group,
+ $hash,
+ 'v2'
+ );
+ }
+
+ /**
+ * Log cache operations related to stashed edits, i.e. store, hit and miss
+ *
+ * @param string $type Either 'store', 'hit' or 'miss'
+ * @param string $key The cache key used
+ * @throws InvalidArgumentException
+ */
+ protected function logCache( $type, $key ) {
+ if ( !in_array( $type, [ 'store', 'hit', 'miss' ] ) ) {
+ throw new InvalidArgumentException( '$type must be either "store", "hit" or "miss"' );
+ }
+ $logger = LoggerFactory::getInstance( 'StashEdit' );
+ // Bots do not use edit stashing, so avoid distorting the stats
+ $statsd = $this->user->isBot()
+ ? new NullStatsdDataFactory()
+ : MediaWikiServices::getInstance()->getStatsdDataFactory();
+
+ $logger->debug( __METHOD__ . ": cache $type for '{$this->title}' (key $key)." );
+ $statsd->increment( "abusefilter.check-stash.$type" );
+ }
+
+ /**
+ * Returns an associative array of filters which were tripped
+ *
+ * @protected Public for back compat only; this will actually be made protected in the future.
+ * You should either rely on $this->run() or subclass this class.
+ * @todo This method should simply return an array with IDs of matched filters as values,
+ * since we always end up filtering it after calling this method.
+ * @return bool[] Map of (integer filter ID => bool)
+ */
+ public function checkAllFilters() : array {
+ global $wgAbuseFilterCentralDB, $wgAbuseFilterIsCentral, $wgAbuseFilterConditionLimit;
+
+ // Ensure that we start fresh, see T193374
+ $this->parser->resetCondCount();
+
+ $matchedFilters = [];
+
+ foreach ( $this->getLocalFilters() as $row ) {
+ $matchedFilters[$row->af_id] = $this->checkFilter( $row );
+ }
+
+ if ( $wgAbuseFilterCentralDB && !$wgAbuseFilterIsCentral ) {
+ foreach ( $this->getGlobalFilters() as $row ) {
+ $matchedFilters[ AbuseFilter::buildGlobalName( $row->af_id ) ] =
+ $this->checkFilter( $row, true );
+ }
+ }
+
+ // Tag the action if the condition limit was hit
+ if ( $this->parser->getCondCount() > $wgAbuseFilterConditionLimit ) {
+ $actionID = $this->getTaggingID();
+ AbuseFilter::bufferTagsToSetByAction( [ $actionID => [ 'abusefilter-condition-limit' ] ] );
+ }
+
+ return $matchedFilters;
+ }
+
+ /**
+ * @return array abuse_filter DB rows
+ */
+ protected function getLocalFilters() : array {
+ return iterator_to_array( wfGetDB( DB_REPLICA )->select(
+ 'abuse_filter',
+ AbuseFilter::ALL_ABUSE_FILTER_FIELDS,
+ [
+ 'af_enabled' => 1,
+ 'af_deleted' => 0,
+ 'af_group' => $this->group,
+ ],
+ __METHOD__
+ ) );
+ }
+
+ /**
+ * @return array abuse_filter rows from the foreign DB
+ */
+ protected function getGlobalFilters() : array {
+ $globalRulesKey = AbuseFilter::getGlobalRulesKey( $this->group );
+ $fname = __METHOD__;
+
+ return MediaWikiServices::getInstance()->getMainWANObjectCache()->getWithSetCallback(
+ $globalRulesKey,
+ WANObjectCache::TTL_WEEK,
+ function () use ( $fname ) {
+ $fdb = AbuseFilter::getCentralDB( DB_REPLICA );
+
+ return iterator_to_array( $fdb->select(
+ 'abuse_filter',
+ AbuseFilter::ALL_ABUSE_FILTER_FIELDS,
+ [
+ 'af_enabled' => 1,
+ 'af_deleted' => 0,
+ 'af_global' => 1,
+ 'af_group' => $this->group,
+ ],
+ $fname
+ ) );
+ },
+ [
+ 'checkKeys' => [ $globalRulesKey ],
+ 'lockTSE' => 300,
+ 'version' => 1
+ ]
+ );
+ }
+
+ /**
+ * Check the conditions of a single filter, and profile it if $this->executeMode is true
+ *
+ * @param stdClass $row
+ * @param bool $global
+ * @return bool
+ */
+ protected function checkFilter( $row, $global = false ) {
+ $filterName = AbuseFilter::buildGlobalName( $row->af_id, $global );
+
+ $startConds = $this->parser->getCondCount();
+ $startTime = microtime( true );
+ $origExtraTime = AFComputedVariable::$profilingExtraTime;
+
+ // Store the row somewhere convenient
+ AbuseFilter::cacheFilter( $filterName, $row );
+
+ $pattern = trim( $row->af_pattern );
+ $this->parser->setFilter( $filterName );
+ $result = $this->parser->checkConditions( $pattern, true, $filterName );
+
+ $actualExtra = AFComputedVariable::$profilingExtraTime - $origExtraTime;
+ $timeTaken = 1000 * ( microtime( true ) - $startTime - $actualExtra );
+ $condsUsed = $this->parser->getCondCount() - $startConds;
+
+ $this->profilingData[$filterName] = [
+ 'time' => $timeTaken,
+ 'conds' => $condsUsed,
+ 'result' => $result
+ ];
+
+ return $result;
+ }
+
+ /**
+ * @param array $result Result of the execution, as created in run()
+ * @param string[] $matchedFilters
+ * @param string[] $allFilters
+ */
+ protected function profileExecution( array $result, array $matchedFilters, array $allFilters ) {
+ $this->checkResetProfiling( $allFilters );
+ $this->recordRuntimeProfilingResult(
+ count( $allFilters ),
+ $result['condCount'],
+ $result['runtime']
+ );
+ $this->recordPerFilterProfiling( $result['profiling'] );
+ $this->recordStats( $result['condCount'], $result['runtime'], (bool)$matchedFilters );
+ }
+
+ /**
+ * Check if profiling data for all filters is lesser than the limit. If not, delete it and
+ * also delete per-filter profiling for all filters. Note that we don't need to reset it for
+ * disabled filters too, as their profiling data will be reset upon re-enabling anyway.
+ *
+ * @param array $allFilters
+ */
+ protected function checkResetProfiling( array $allFilters ) {
+ global $wgAbuseFilterProfileActionsCap;
+
+ $profileKey = AbuseFilter::filterProfileGroupKey( $this->group );
+ $stash = MediaWikiServices::getInstance()->getMainObjectStash();
+
+ $profile = $stash->get( $profileKey );
+ $total = $profile['total'] ?? 0;
+
+ if ( $total > $wgAbuseFilterProfileActionsCap ) {
+ $stash->delete( $profileKey );
+ foreach ( $allFilters as $filter ) {
+ AbuseFilter::resetFilterProfile( $filter );
+ }
+ }
+ }
+
+ /**
+ * Record per-filter profiling, for all filters
+ *
+ * @param array $data Profiling data, as stored in $this->profilingData
+ * @phan-param array<string,array{time:float,conds:int,result:bool}> $data
+ */
+ protected function recordPerFilterProfiling( array $data ) {
+ global $wgAbuseFilterSlowFilterRuntimeLimit;
+
+ foreach ( $data as $filterName => $params ) {
+ list( $filterID, $global ) = AbuseFilter::splitGlobalName( $filterName );
+ if ( !$global ) {
+ // @todo Maybe add a parameter to recordProfilingResult to record global filters
+ // data separately (in the foreign wiki)
+ $this->recordProfilingResult( $filterID, $params['time'], $params['conds'], $params['result'] );
+ }
+
+ if ( $params['time'] > $wgAbuseFilterSlowFilterRuntimeLimit ) {
+ $this->recordSlowFilter( $filterName, $params['time'], $params['conds'], $params['result'] );
+ }
+ }
+ }
+
+ /**
+ * Record per-filter profiling data
+ *
+ * @param int $filter
+ * @param float $time Time taken, in milliseconds
+ * @param int $conds
+ * @param bool $matched
+ */
+ protected function recordProfilingResult( $filter, $time, $conds, $matched ) {
+ // Defer updates to avoid massive (~1 second) edit time increases
+ DeferredUpdates::addCallableUpdate( function () use ( $filter, $time, $conds, $matched ) {
+ $stash = MediaWikiServices::getInstance()->getMainObjectStash();
+ $profileKey = AbuseFilter::filterProfileKey( $filter );
+ $profile = $stash->get( $profileKey );
+
+ if ( $profile !== false ) {
+ // Number of observed executions of this filter
+ $profile['count']++;
+ if ( $matched ) {
+ // Number of observed matches of this filter
+ $profile['matches']++;
+ }
+ // Total time spent on this filter from all observed executions
+ $profile['total-time'] += $time;
+ // Total number of conditions for this filter from all executions
+ $profile['total-cond'] += $conds;
+ } else {
+ $profile = [
+ 'count' => 1,
+ 'matches' => (int)$matched,
+ 'total-time' => $time,
+ 'total-cond' => $conds
+ ];
+ }
+ // Note: It is important that all key information be stored together in a single
+ // memcache entry to avoid race conditions where competing Apache instances
+ // partially overwrite the stats.
+ $stash->set( $profileKey, $profile, 3600 );
+ } );
+ }
+
+ /**
+ * Logs slow filter's runtime data for later analysis
+ *
+ * @param string $filterId
+ * @param float $runtime
+ * @param int $totalConditions
+ * @param bool $matched
+ */
+ protected function recordSlowFilter( $filterId, $runtime, $totalConditions, $matched ) {
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->info(
+ 'Edit filter {filter_id} on {wiki} is taking longer than expected',
+ [
+ 'wiki' => WikiMap::getCurrentWikiDbDomain()->getId(),
+ 'filter_id' => $filterId,
+ 'title' => $this->title->getPrefixedText(),
+ 'runtime' => $runtime,
+ 'matched' => $matched,
+ 'total_conditions' => $totalConditions
+ ]
+ );
+ }
+
+ /**
+ * Update global statistics
+ *
+ * @param int $condsUsed The amount of used conditions
+ * @param float $totalTime Time taken, in milliseconds
+ * @param bool $anyMatch Whether at least one filter matched the action
+ */
+ protected function recordStats( $condsUsed, $totalTime, $anyMatch ) {
+ $profileKey = AbuseFilter::filterProfileGroupKey( $this->group );
+ $stash = MediaWikiServices::getInstance()->getMainObjectStash();
+
+ // Note: All related data is stored in a single memcache entry and updated via merge()
+ // to avoid race conditions where partial updates on competing instances corrupt the data.
+ $stash->merge(
+ $profileKey,
+ function ( $cache, $key, $profile ) use ( $condsUsed, $totalTime, $anyMatch ) {
+ global $wgAbuseFilterConditionLimit;
+
+ if ( $profile === false ) {
+ $profile = [
+ // Total number of actions observed
+ 'total' => 0,
+ // Number of actions ending by exceeding condition limit
+ 'overflow' => 0,
+ // Total time of execution of all observed actions
+ 'total-time' => 0,
+ // Total number of conditions from all observed actions
+ 'total-cond' => 0,
+ // Total number of filters matched
+ 'matches' => 0
+ ];
+ }
+
+ $profile['total']++;
+ $profile['total-time'] += $totalTime;
+ $profile['total-cond'] += $condsUsed;
+
+ // Increment overflow counter, if our condition limit overflowed
+ if ( $condsUsed > $wgAbuseFilterConditionLimit ) {
+ $profile['overflow']++;
+ }
+
+ // Increment counter by 1 if there was at least one match
+ if ( $anyMatch ) {
+ $profile['matches']++;
+ }
+
+ return $profile;
+ },
+ AbuseFilter::$statsStoragePeriod
+ );
+ }
+
+ /**
+ * Record runtime profiling data for all filters together
+ *
+ * @param int $totalFilters
+ * @param int $totalConditions
+ * @param float $runtime
+ */
+ protected function recordRuntimeProfilingResult( $totalFilters, $totalConditions, $runtime ) {
+ $keyPrefix = 'abusefilter.runtime-profile.' . WikiMap::getCurrentWikiDbDomain()->getId() . '.';
+
+ $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $statsd->timing( $keyPrefix . 'runtime', $runtime );
+ $statsd->timing( $keyPrefix . 'total_filters', $totalFilters );
+ $statsd->timing( $keyPrefix . 'total_conditions', $totalConditions );
+ }
+
+ /**
+ * Executes a set of actions.
+ *
+ * @param string[] $filters
+ * @return Status returns the operation's status. $status->isOK() will return true if
+ * there were no actions taken, false otherwise. $status->getValue() will return
+ * an array listing the actions taken. $status->getErrors() etc. will provide
+ * the errors and warnings to be shown to the user to explain the actions.
+ */
+ protected function executeFilterActions( array $filters ) : Status {
+ global $wgMainCacheType, $wgAbuseFilterDisallowGlobalLocalBlocks, $wgAbuseFilterRestrictions,
+ $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration;
+
+ $actionsByFilter = AbuseFilter::getConsequencesForFilters( $filters );
+ $actionsTaken = array_fill_keys( $filters, [] );
+
+ $messages = [];
+ // Accumulator to track max block to issue
+ $maxExpiry = -1;
+
+ foreach ( $actionsByFilter as $filter => $actions ) {
+ // Special-case handling for warnings.
+ $filterPublicComments = AbuseFilter::getFilter( $filter )->af_public_comments;
+
+ $isGlobalFilter = AbuseFilter::splitGlobalName( $filter )[1];
+
+ // If the filter has "throttle" enabled and throttling is available via object
+ // caching, check to see if the user has hit the throttle.
+ if ( !empty( $actions['throttle'] ) && $wgMainCacheType !== CACHE_NONE ) {
+ $parameters = $actions['throttle']['parameters'];
+ $throttleId = array_shift( $parameters );
+ list( $rateCount, $ratePeriod ) = explode( ',', array_shift( $parameters ) );
+ $rateCount = (int)$rateCount;
+ $ratePeriod = (int)$ratePeriod;
+
+ $hitThrottle = false;
+
+ // The rest are throttle-types.
+ foreach ( $parameters as $throttleType ) {
+ $hitThrottle = $hitThrottle || $this->isThrottled(
+ $throttleId, $throttleType, $rateCount, $ratePeriod, $isGlobalFilter );
+ }
+
+ unset( $actions['throttle'] );
+ if ( !$hitThrottle ) {
+ $actionsTaken[$filter][] = 'throttle';
+ continue;
+ }
+ }
+
+ if ( $wgAbuseFilterDisallowGlobalLocalBlocks && $isGlobalFilter ) {
+ $actions = array_diff_key( $actions, array_filter( $wgAbuseFilterRestrictions ) );
+ }
+
+ if ( !empty( $actions['warn'] ) ) {
+ $parameters = $actions['warn']['parameters'];
+ // Generate a unique key to determine whether the user has already been warned.
+ // We'll warn again if one of these changes: session, page, triggered filter or action
+ $warnKey = 'abusefilter-warned-' . md5( $this->title->getPrefixedText() ) .
+ '-' . $filter . '-' . $this->action;
+
+ // Make sure the session is started prior to using it
+ $session = SessionManager::getGlobalSession();
+ $session->persist();
+
+ if ( !isset( $session[$warnKey] ) || !$session[$warnKey] ) {
+ $session[$warnKey] = true;
+
+ $msg = $parameters[0] ?? 'abusefilter-warning';
+ $messages[] = [ $msg, $filterPublicComments, $filter ];
+
+ $actionsTaken[$filter][] = 'warn';
+
+ // Don't do anything else.
+ continue;
+ } else {
+ // We already warned them
+ $session[$warnKey] = false;
+ }
+
+ unset( $actions['warn'] );
+ }
+
+ // Prevent double warnings
+ if ( count( array_intersect_key( $actions, array_filter( $wgAbuseFilterRestrictions ) ) ) > 0 &&
+ !empty( $actions['disallow'] )
+ ) {
+ unset( $actions['disallow'] );
+ }
+
+ // Find out the max expiry to issue the longest triggered block.
+ // Need to check here since methods like user->getBlock() aren't available
+ if ( !empty( $actions['block'] ) ) {
+ $parameters = $actions['block']['parameters'];
+
+ if ( count( $parameters ) === 3 ) {
+ // New type of filters with custom block
+ if ( $this->user->isAnon() ) {
+ $expiry = $parameters[1];
+ } else {
+ $expiry = $parameters[2];
+ }
+ } else {
+ // Old type with fixed expiry
+ if ( $this->user->isAnon() && $wgAbuseFilterAnonBlockDuration !== null ) {
+ // The user isn't logged in and the anon block duration
+ // doesn't default to $wgAbuseFilterBlockDuration.
+ $expiry = $wgAbuseFilterAnonBlockDuration;
+ } else {
+ $expiry = $wgAbuseFilterBlockDuration;
+ }
+ }
+
+ $currentExpiry = SpecialBlock::parseExpiryInput( $expiry );
+ if ( $maxExpiry === -1 || $currentExpiry > SpecialBlock::parseExpiryInput( $maxExpiry ) ) {
+ // Save the parameters to issue the block with
+ $maxExpiry = $expiry;
+ $blockValues = [
+ AbuseFilter::getFilter( $filter )->af_public_comments,
+ $filter,
+ is_array( $parameters ) && in_array( 'blocktalk', $parameters )
+ ];
+ }
+ unset( $actions['block'] );
+ }
+
+ // Do the rest of the actions
+ foreach ( $actions as $action => $info ) {
+ $newMsg = $this->takeConsequenceAction(
+ $action,
+ // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive
+ $info['parameters'],
+ AbuseFilter::getFilter( $filter )->af_public_comments,
+ $filter
+ );
+
+ if ( $newMsg !== null ) {
+ $messages[] = $newMsg;
+ }
+ $actionsTaken[$filter][] = $action;
+ }
+ }
+
+ // Since every filter has been analysed, we now know what the
+ // longest block duration is, so we can issue the block if
+ // maxExpiry has been changed.
+ if ( $maxExpiry !== -1 ) {
+ // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
+ $this->doBlock( $blockValues[0], $blockValues[1], $maxExpiry, $blockValues[2] );
+ $message = [
+ 'abusefilter-blocked-display',
+ $blockValues[0],
+ $blockValues[1]
+ ];
+ // Manually add the message. If we're here, there is one.
+ $messages[] = $message;
+ // @phan-suppress-next-line PhanTypeMismatchDimAssignment
+ $actionsTaken[$blockValues[1]][] = 'block';
+ }
+
+ return $this->buildStatus( $actionsTaken, $messages );
+ }
+
+ /**
+ * @param string $throttleId
+ * @param string $types
+ * @param int $rateCount
+ * @param int $ratePeriod
+ * @param bool $global
+ * @return bool
+ */
+ protected function isThrottled(
+ $throttleId,
+ $types,
+ int $rateCount,
+ int $ratePeriod,
+ $global = false
+ ) {
+ $stash = MediaWikiServices::getInstance()->getMainObjectStash();
+ $key = $this->throttleKey( $throttleId, $types, $global );
+ $count = (int)$stash->get( $key );
+
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->debug( "Got value $count for throttle key $key" );
+
+ $count = $stash->incrWithInit( $key, $ratePeriod );
+
+ if ( $count > $rateCount ) {
+ $logger->debug( "Throttle $key hit value $count -- maximum is $rateCount." );
+ return true;
+ }
+ $logger->debug( "Throttle $key not hit!" );
+ return false;
+ }
+
+ /**
+ * @param string $throttleId
+ * @param string $type
+ * @param bool $global
+ * @return string
+ */
+ protected function throttleKey( $throttleId, $type, $global = false ) {
+ global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB;
+
+ $types = explode( ',', $type );
+
+ $identifiers = [];
+
+ foreach ( $types as $subtype ) {
+ $identifiers[] = $this->throttleIdentifier( $subtype );
+ }
+
+ $identifier = sha1( implode( ':', $identifiers ) );
+
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ if ( $global && !$wgAbuseFilterIsCentral ) {
+ return $cache->makeGlobalKey(
+ 'abusefilter', 'throttle', $wgAbuseFilterCentralDB, $throttleId, $type, $identifier
+ );
+ }
+
+ return $cache->makeKey( 'abusefilter', 'throttle', $throttleId, $type, $identifier );
+ }
+
+ /**
+ * @param string $type
+ * @return int|string
+ */
+ protected function throttleIdentifier( $type ) {
+ $request = RequestContext::getMain()->getRequest();
+
+ switch ( $type ) {
+ case 'ip':
+ $identifier = $request->getIP();
+ break;
+ case 'user':
+ $identifier = $this->user->getId();
+ break;
+ case 'range':
+ $identifier = substr( IPUtils::toHex( $request->getIP() ), 0, 4 );
+ break;
+ case 'creationdate':
+ $reg = (int)$this->user->getRegistration();
+ $identifier = $reg - ( $reg % 86400 );
+ break;
+ case 'editcount':
+ // Hack for detecting different single-purpose accounts.
+ $identifier = (int)$this->user->getEditCount();
+ break;
+ case 'site':
+ $identifier = 1;
+ break;
+ case 'page':
+ $identifier = $this->title->getPrefixedText();
+ break;
+ default:
+ // Should never happen
+ // @codeCoverageIgnoreStart
+ $identifier = 0;
+ // @codeCoverageIgnoreEnd
+ }
+
+ return $identifier;
+ }
+
+ /**
+ * @param string $action
+ * @param array $parameters
+ * @param string $ruleDescription
+ * @param int|string $ruleNumber
+ *
+ * @return array|null a message describing the action that was taken,
+ * or null if no action was taken. The message is given as an array
+ * containing the message key followed by any message parameters.
+ */
+ protected function takeConsequenceAction( $action, $parameters, $ruleDescription, $ruleNumber ) {
+ global $wgAbuseFilterCustomActionsHandlers, $wgAbuseFilterBlockAutopromoteDuration;
+
+ $message = null;
+
+ switch ( $action ) {
+ case 'disallow':
+ $msg = $parameters[0] ?? 'abusefilter-disallowed';
+ $message = [ $msg, $ruleDescription, $ruleNumber ];
+ break;
+ case 'rangeblock':
+ $this->doRangeBlock( $ruleDescription, $ruleNumber, '1 week' );
+
+ $message = [
+ 'abusefilter-blocked-display',
+ $ruleDescription,
+ $ruleNumber
+ ];
+ break;
+ case 'degroup':
+ if ( !$this->user->isAnon() ) {
+ // Pull the groups from the VariableHolder, so that they will always be computed.
+ // This allow us to pull the groups from the VariableHolder to undo the degroup
+ // via Special:AbuseFilter/revert.
+ $groups = $this->vars->getVar( 'user_groups', AbuseFilterVariableHolder::GET_LAX );
+ if ( $groups->type !== AFPData::DARRAY ) {
+ // Somehow, the variable wasn't set
+ $groups = $this->user->getEffectiveGroups();
+ $this->vars->setVar( 'user_groups', $groups );
+ } else {
+ $groups = $groups->toNative();
+ }
+ $this->vars->setVar( 'user_groups', $groups );
+
+ foreach ( $groups as $group ) {
+ $this->user->removeGroup( $group );
+ }
+
+ $message = [
+ 'abusefilter-degrouped',
+ $ruleDescription,
+ $ruleNumber
+ ];
+
+ // Don't log it if there aren't any groups being removed!
+ if ( !count( $groups ) ) {
+ break;
+ }
+
+ $logEntry = new ManualLogEntry( 'rights', 'rights' );
+ $logEntry->setPerformer( AbuseFilter::getFilterUser() );
+ $logEntry->setTarget( $this->user->getUserPage() );
+ $logEntry->setComment(
+ wfMessage(
+ 'abusefilter-degroupreason',
+ $ruleDescription,
+ $ruleNumber
+ )->inContentLanguage()->text()
+ );
+ $logEntry->setParameters( [
+ '4::oldgroups' => $groups,
+ '5::newgroups' => []
+ ] );
+ $logEntry->publish( $logEntry->insert() );
+ }
+
+ break;
+ case 'blockautopromote':
+ if ( !$this->user->isAnon() ) {
+ $duration = $wgAbuseFilterBlockAutopromoteDuration * 86400;
+ $blocked = AbuseFilter::blockAutoPromote(
+ $this->user,
+ wfMessage(
+ 'abusefilter-blockautopromotereason',
+ $ruleDescription,
+ $ruleNumber
+ )->inContentLanguage()->text(),
+ $duration
+ );
+
+ if ( $blocked ) {
+ $message = [
+ 'abusefilter-autopromote-blocked',
+ $ruleDescription,
+ $ruleNumber,
+ $duration
+ ];
+ } else {
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->warning(
+ 'Cannot block autopromotion to {target}',
+ [ 'target' => $this->user->getName() ]
+ );
+ }
+ }
+ break;
+
+ case 'block':
+ // Do nothing, handled at the end of executeFilterActions. Here for completeness.
+ break;
+
+ case 'tag':
+ // Mark with a tag on recentchanges.
+ $actionID = $this->getTaggingID();
+ AbuseFilter::bufferTagsToSetByAction( [ $actionID => $parameters ] );
+ break;
+ default:
+ if ( isset( $wgAbuseFilterCustomActionsHandlers[$action] ) ) {
+ $customFunction = $wgAbuseFilterCustomActionsHandlers[$action];
+ if ( is_callable( $customFunction ) ) {
+ $msg = call_user_func(
+ $customFunction,
+ $action,
+ $parameters,
+ $this->title,
+ $this->vars,
+ $ruleDescription,
+ $ruleNumber
+ );
+ }
+ if ( isset( $msg ) ) {
+ $message = [ $msg ];
+ }
+ } else {
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->warning( "Unrecognised action $action" );
+ }
+ }
+
+ return $message;
+ }
+
+ /**
+ * @param string $ruleDesc
+ * @param string|int $ruleNumber
+ * @param string $expiry
+ */
+ private function doRangeBlock( $ruleDesc, $ruleNumber, $expiry ) {
+ global $wgAbuseFilterRangeBlockSize, $wgBlockCIDRLimit;
+
+ $ip = RequestContext::getMain()->getRequest()->getIP();
+ $type = IPUtils::isIPv6( $ip ) ? 'IPv6' : 'IPv4';
+ $CIDRsize = max( $wgAbuseFilterRangeBlockSize[$type], $wgBlockCIDRLimit[$type] );
+ $blockCIDR = $ip . '/' . $CIDRsize;
+
+ $target = IPUtils::sanitizeRange( $blockCIDR );
+ $autoblock = false;
+ $this->doBlockInternal( $ruleDesc, $ruleNumber, $target, $expiry, $autoblock, false );
+ }
+
+ /**
+ * @param string $ruleDesc
+ * @param string|int $ruleNumber
+ * @param string $expiry
+ * @param bool $preventsTalk
+ */
+ private function doBlock( $ruleDesc, $ruleNumber, $expiry, $preventsTalk ) {
+ $target = $this->user->getName();
+ $autoblock = true;
+ $this->doBlockInternal( $ruleDesc, $ruleNumber, $target, $expiry, $autoblock, $preventsTalk );
+ }
+
+ /**
+ * Perform a block by the AbuseFilter system user
+ * @param string $ruleDesc
+ * @param int|string $ruleNumber
+ * @param string $target
+ * @param string $expiry
+ * @param bool $isAutoBlock
+ * @param bool $preventEditOwnUserTalk
+ */
+ private function doBlockInternal(
+ $ruleDesc,
+ $ruleNumber,
+ $target,
+ $expiry,
+ $isAutoBlock,
+ $preventEditOwnUserTalk
+ ) {
+ $filterUser = AbuseFilter::getFilterUser();
+ $reason = wfMessage(
+ 'abusefilter-blockreason',
+ $ruleDesc, $ruleNumber
+ )->inContentLanguage()->text();
+
+ $block = new DatabaseBlock();
+ $block->setTarget( $target );
+ $block->setBlocker( $filterUser );
+ $block->setReason( $reason );
+ $block->isHardblock( false );
+ $block->isAutoblocking( $isAutoBlock );
+ $block->isCreateAccountBlocked( true );
+ $block->isUsertalkEditAllowed( !$preventEditOwnUserTalk );
+ $block->setExpiry( SpecialBlock::parseExpiryInput( $expiry ) );
+
+ $success = $block->insert();
+
+ if ( $success ) {
+ // Log it only if the block was successful
+ $logParams = [];
+ $logParams['5::duration'] = ( $block->getExpiry() === 'infinity' )
+ ? 'indefinite'
+ : $expiry;
+ $flags = [ 'nocreate' ];
+ if ( !$block->isAutoblocking() && !IPUtils::isIPAddress( $target ) ) {
+ // Conditionally added same as SpecialBlock
+ $flags[] = 'noautoblock';
+ }
+ if ( $preventEditOwnUserTalk === true ) {
+ $flags[] = 'nousertalk';
+ }
+ $logParams['6::flags'] = implode( ',', $flags );
+
+ $logEntry = new ManualLogEntry( 'block', 'block' );
+ $logEntry->setTarget( Title::makeTitle( NS_USER, $target ) );
+ $logEntry->setComment( $reason );
+ $logEntry->setPerformer( $filterUser );
+ $logEntry->setParameters( $logParams );
+ $blockIds = array_merge( [ $success['id'] ], $success['autoIds'] );
+ $logEntry->setRelations( [ 'ipb_id' => $blockIds ] );
+ $logEntry->publish( $logEntry->insert() );
+ }
+ }
+
+ /**
+ * Constructs a Status object as returned by executeFilterActions() from the list of
+ * actions taken and the corresponding list of messages.
+ *
+ * @param array[] $actionsTaken associative array mapping each filter to the list if
+ * actions taken because of that filter.
+ * @param array[] $messages a list of arrays, where each array contains a message key
+ * followed by any message parameters.
+ *
+ * @return Status
+ */
+ protected function buildStatus( array $actionsTaken, array $messages ) : Status {
+ $status = Status::newGood( $actionsTaken );
+
+ foreach ( $messages as $msg ) {
+ $status->fatal( ...$msg );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Creates a template to use for logging taken actions
+ *
+ * @return array
+ */
+ protected function buildLogTemplate() : array {
+ global $wgAbuseFilterLogIP;
+
+ $request = RequestContext::getMain()->getRequest();
+ // If $this->user isn't safe to load (e.g. a failure during
+ // AbortAutoAccount), create a dummy anonymous user instead.
+ $user = $this->user->isSafeToLoad() ? $this->user : new User;
+ // Create a template
+ $logTemplate = [
+ 'afl_user' => $user->getId(),
+ 'afl_user_text' => $user->getName(),
+ 'afl_timestamp' => wfGetDB( DB_REPLICA )->timestamp(),
+ 'afl_namespace' => $this->title->getNamespace(),
+ 'afl_title' => $this->title->getDBkey(),
+ 'afl_action' => $this->action,
+ 'afl_ip' => $wgAbuseFilterLogIP ? $request->getIP() : ''
+ ];
+ // Hack to avoid revealing IPs of people creating accounts
+ if (
+ !$user->getId() &&
+ ( $this->action === 'createaccount' || $this->action === 'autocreateaccount' )
+ ) {
+ $logTemplate['afl_user_text'] = $this->vars->getVar( 'accountname' )->toString();
+ }
+ return $logTemplate;
+ }
+
+ /**
+ * Create and publish log entries for taken actions
+ *
+ * @param array[] $actionsTaken
+ * @todo Split this method
+ */
+ protected function addLogEntries( array $actionsTaken ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $logTemplate = $this->buildLogTemplate();
+ $centralLogTemplate = [
+ 'afl_wiki' => WikiMap::getCurrentWikiDbDomain()->getId(),
+ ];
+
+ $logRows = [];
+ $centralLogRows = [];
+ $loggedLocalFilters = [];
+ $loggedGlobalFilters = [];
+
+ foreach ( $actionsTaken as $filter => $actions ) {
+ list( $filterID, $global ) = AbuseFilter::splitGlobalName( $filter );
+ $thisLog = $logTemplate;
+ $thisLog['afl_filter'] = $filter;
+ $thisLog['afl_actions'] = implode( ',', $actions );
+
+ // Don't log if we were only throttling.
+ if ( $thisLog['afl_actions'] !== 'throttle' ) {
+ $logRows[] = $thisLog;
+ // Global logging
+ if ( $global ) {
+ $centralLog = $thisLog + $centralLogTemplate;
+ $centralLog['afl_filter'] = $filterID;
+ $centralLog['afl_title'] = $this->title->getPrefixedText();
+ $centralLog['afl_namespace'] = 0;
+
+ $centralLogRows[] = $centralLog;
+ $loggedGlobalFilters[] = $filterID;
+ } else {
+ $loggedLocalFilters[] = $filter;
+ }
+ }
+ }
+
+ if ( !count( $logRows ) ) {
+ return;
+ }
+
+ // Only store the var dump if we're actually going to add log rows.
+ $varDump = AbuseFilter::storeVarDump( $this->vars );
+ $varDump = "tt:$varDump";
+
+ $localLogIDs = [];
+ global $wgAbuseFilterNotifications, $wgAbuseFilterNotificationsPrivate;
+ foreach ( $logRows as $data ) {
+ $data['afl_var_dump'] = $varDump;
+ $dbw->insert( 'abuse_filter_log', $data, __METHOD__ );
+ $localLogIDs[] = $data['afl_id'] = $dbw->insertId();
+ // Give grep a chance to find the usages:
+ // logentry-abusefilter-hit
+ $entry = new ManualLogEntry( 'abusefilter', 'hit' );
+ // Construct a user object
+ $user = User::newFromId( $data['afl_user'] );
+ $user->setName( $data['afl_user_text'] );
+ $entry->setPerformer( $user );
+ $entry->setTarget( $this->title );
+ // Additional info
+ $entry->setParameters( [
+ 'action' => $data['afl_action'],
+ 'filter' => $data['afl_filter'],
+ 'actions' => $data['afl_actions'],
+ 'log' => $data['afl_id'],
+ ] );
+
+ // Send data to CheckUser if installed and we
+ // aren't already sending a notification to recentchanges
+ if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' )
+ && strpos( $wgAbuseFilterNotifications, 'rc' ) === false
+ ) {
+ global $wgCheckUserLogAdditionalRights;
+ $wgCheckUserLogAdditionalRights[] = 'abusefilter-view';
+ $rc = $entry->getRecentChange();
+ CheckUserHooks::updateCheckUserData( $rc );
+ }
+
+ if ( $wgAbuseFilterNotifications !== false ) {
+ list( $filterID, $global ) = AbuseFilter::splitGlobalName( $data['afl_filter'] );
+ if ( AbuseFilter::filterHidden( $filterID, $global ) && !$wgAbuseFilterNotificationsPrivate ) {
+ continue;
+ }
+ $this->publishEntry( $dbw, $entry, $wgAbuseFilterNotifications );
+ }
+ }
+
+ $method = __METHOD__;
+
+ if ( count( $loggedLocalFilters ) ) {
+ // Update hit-counter.
+ $dbw->onTransactionPreCommitOrIdle(
+ function () use ( $dbw, $loggedLocalFilters, $method ) {
+ $dbw->update( 'abuse_filter',
+ [ 'af_hit_count=af_hit_count+1' ],
+ [ 'af_id' => $loggedLocalFilters ],
+ $method
+ );
+ },
+ $method
+ );
+ }
+
+ $globalLogIDs = [];
+
+ // Global stuff
+ if ( count( $loggedGlobalFilters ) ) {
+ $this->vars->computeDBVars();
+ $globalVarDump = AbuseFilter::storeVarDump( $this->vars, true );
+ $globalVarDump = "tt:$globalVarDump";
+ foreach ( $centralLogRows as $index => $data ) {
+ $centralLogRows[$index]['afl_var_dump'] = $globalVarDump;
+ }
+
+ $fdb = AbuseFilter::getCentralDB( DB_MASTER );
+
+ foreach ( $centralLogRows as $row ) {
+ $fdb->insert( 'abuse_filter_log', $row, __METHOD__ );
+ $globalLogIDs[] = $fdb->insertId();
+ }
+
+ $fdb->onTransactionPreCommitOrIdle(
+ function () use ( $fdb, $loggedGlobalFilters, $method ) {
+ $fdb->update( 'abuse_filter',
+ [ 'af_hit_count=af_hit_count+1' ],
+ [ 'af_id' => $loggedGlobalFilters ],
+ $method
+ );
+ },
+ $method
+ );
+ }
+
+ AbuseFilter::$logIds[ $this->title->getPrefixedText() ] = [
+ 'local' => $localLogIDs,
+ 'global' => $globalLogIDs
+ ];
+
+ $this->checkEmergencyDisable( $loggedLocalFilters );
+ }
+
+ /**
+ * Like LogEntry::publish, but doesn't require an ID (which we don't have) and skips the
+ * tagging part
+ *
+ * @param IDatabase $dbw To cancel the callback if the log insertion fails
+ * @param ManualLogEntry $entry
+ * @param string $to One of 'udp', 'rc' and 'rcandudp'
+ */
+ private function publishEntry( IDatabase $dbw, ManualLogEntry $entry, $to ) {
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $entry, $to ) {
+ $rc = $entry->getRecentChange();
+
+ if ( $to === 'rc' || $to === 'rcandudp' ) {
+ $rc->save( $rc::SEND_NONE );
+ }
+ if ( $to === 'udp' || $to === 'rcandudp' ) {
+ $rc->notifyRCFeeds();
+ }
+ },
+ DeferredUpdates::POSTSEND,
+ $dbw
+ );
+ }
+
+ /**
+ * Determine whether a filter must be throttled, i.e. its potentially dangerous
+ * actions must be disabled.
+ *
+ * @param string[] $filters The filters to check
+ */
+ protected function checkEmergencyDisable( array $filters ) {
+ $stash = MediaWikiServices::getInstance()->getMainObjectStash();
+ // @ToDo this is an amount between 1 and AbuseFilterProfileActionsCap, which means that the
+ // reliability of this number may strongly vary. We should instead use a fixed one.
+ $groupProfile = $stash->get( AbuseFilter::filterProfileGroupKey( $this->group ) );
+ $totalActions = $groupProfile['total'];
+
+ foreach ( $filters as $filter ) {
+ $threshold = AbuseFilter::getEmergencyValue( 'threshold', $this->group );
+ $hitCountLimit = AbuseFilter::getEmergencyValue( 'count', $this->group );
+ $maxAge = AbuseFilter::getEmergencyValue( 'age', $this->group );
+
+ $filterProfile = $stash->get( AbuseFilter::filterProfileKey( $filter ) );
+ $matchCount = $filterProfile['matches'] ?? 1;
+
+ // Figure out if the filter is subject to being throttled.
+ $filterAge = (int)wfTimestamp( TS_UNIX, AbuseFilter::getFilter( $filter )->af_timestamp );
+ $exemptTime = $filterAge + $maxAge;
+
+ if ( $totalActions && $exemptTime > time() && $matchCount > $hitCountLimit &&
+ ( $matchCount / $totalActions ) > $threshold
+ ) {
+ // More than $wgAbuseFilterEmergencyDisableCount matches, constituting more than
+ // $threshold (a fraction) of last few edits. Disable it.
+ DeferredUpdates::addUpdate(
+ new AutoCommitUpdate(
+ wfGetDB( DB_MASTER ),
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) use ( $filter ) {
+ $dbw->update(
+ 'abuse_filter',
+ [ 'af_throttled' => 1 ],
+ [ 'af_id' => $filter ],
+ $fname
+ );
+ }
+ )
+ );
+ }
+ }
+ }
+
+ /**
+ * Helper function to get the ID used to identify an action for later tagging it.
+ * @return string
+ */
+ protected function getTaggingID() {
+ if ( strpos( $this->action, 'createaccount' ) === false ) {
+ $username = $this->user->getName();
+ $actionTitle = $this->title;
+ } else {
+ $username = $this->vars->getVar( 'accountname' )->toString();
+ $actionTitle = Title::makeTitleSafe( NS_USER, $username );
+ }
+ '@phan-var Title $actionTitle';
+
+ return AbuseFilter::getTaggingActionId( $this->action, $actionTitle, $username );
+ }
+}
diff --git a/AbuseFilter/includes/AbuseFilterServices.php b/AbuseFilter/includes/AbuseFilterServices.php
new file mode 100644
index 00000000..a028c5aa
--- /dev/null
+++ b/AbuseFilter/includes/AbuseFilterServices.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter;
+
+use MediaWiki\MediaWikiServices;
+
+class AbuseFilterServices {
+ /**
+ * Conveniency wrapper for strong typing
+ * @return KeywordsManager
+ */
+ public static function getKeywordsManager() : KeywordsManager {
+ return MediaWikiServices::getInstance()->getService( KeywordsManager::SERVICE_NAME );
+ }
+}
diff --git a/AbuseFilter/includes/AbuseFilterVariableHolder.php b/AbuseFilter/includes/AbuseFilterVariableHolder.php
index 498c7b4e..e8dfd2fd 100644
--- a/AbuseFilter/includes/AbuseFilterVariableHolder.php
+++ b/AbuseFilter/includes/AbuseFilterVariableHolder.php
@@ -1,21 +1,80 @@
<?php
+use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
+use MediaWiki\Extension\AbuseFilter\KeywordsManager;
+use Psr\Log\LoggerInterface;
+
class AbuseFilterVariableHolder {
- /** @var (AFPData|AFComputedVariable)[] */
+ /**
+ * Used in self::getVar() to determine what to do if the requested variable is missing. See
+ * the docs of that method for an explanation.
+ */
+ public const GET_LAX = 0;
+ public const GET_STRICT = 1;
+ public const GET_BC = 2;
+
+ /** @var KeywordsManager */
+ private $keywordsManager;
+
+ /** @var LoggerInterface */
+ private $logger;
+
+ /**
+ * @var (AFPData|AFComputedVariable)[]
+ * @fixme This should be private, but it isn't because of T231542: there are serialized instances
+ * stored in the DB, and mVars wouldn't be available in HHVM after deserializing them (T213006)
+ */
public $mVars = [];
- /** @var string[] Variables used to store meta-data, we'd better be safe. See T191715 */
- public static $varBlacklist = [ 'context', 'global_log_ids', 'local_log_ids' ];
+ /** @var bool Whether this object is being used for an ongoing action being filtered */
+ public $forFilter = false;
+
+ /**
+ * @param KeywordsManager|null $keywordsManager Optional for BC
+ */
+ public function __construct( KeywordsManager $keywordsManager = null ) {
+ $this->keywordsManager = $keywordsManager ?? AbuseFilterServices::getKeywordsManager();
+ // Avoid injecting a Logger, as it's just temporary
+ $this->logger = new Psr\Log\NullLogger();
+ }
- /** @var int 2 is the default and means that new variables names (from T173889) should be used.
- * 1 means that the old ones should be used, e.g. if this object is constructed from an
- * afl_var_dump which still bears old variables.
+ /**
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Utility function to translate an array with shape [ varname => value ] into a self instance
+ *
+ * @param array $vars
+ * @param KeywordsManager|null $keywordsManager Optional for BC
+ * @return AbuseFilterVariableHolder
*/
- public $mVarsVersion = 2;
+ public static function newFromArray(
+ array $vars,
+ KeywordsManager $keywordsManager = null
+ ) : AbuseFilterVariableHolder {
+ $ret = new self( $keywordsManager );
+ foreach ( $vars as $var => $value ) {
+ $ret->setVar( $var, $value );
+ }
+ return $ret;
+ }
- public function __construct() {
- // Backwards-compatibility (unused now)
- $this->setVar( 'minor_edit', false );
+ /**
+ * Checks whether any deprecated variable is stored with the old name, and replaces it with
+ * the new name. This should normally only happen when a DB dump is retrieved from the DB.
+ */
+ public function translateDeprecatedVars() : void {
+ $deprecatedVars = $this->keywordsManager->getDeprecatedVariables();
+ foreach ( $this->mVars as $name => $value ) {
+ if ( array_key_exists( $name, $deprecatedVars ) ) {
+ $this->mVars[ $deprecatedVars[$name] ] = $value;
+ unset( $this->mVars[$name] );
+ }
+ }
}
/**
@@ -32,83 +91,107 @@ class AbuseFilterVariableHolder {
}
/**
+ * Get all variables stored in this object
+ *
+ * @return (AFPData|AFComputedVariable)[]
+ */
+ public function getVars() {
+ return $this->mVars;
+ }
+
+ /**
+ * Get a lazy loader for a variable. This method is here for testing ease
+ * @param string $method
+ * @param array $parameters
+ * @return AFComputedVariable
+ */
+ public function getLazyLoader( $method, $parameters ) {
+ return new AFComputedVariable( $method, $parameters );
+ }
+
+ /**
* @param string $variable
* @param string $method
* @param array $parameters
*/
public function setLazyLoadVar( $variable, $method, $parameters ) {
- $placeholder = new AFComputedVariable( $method, $parameters );
+ $placeholder = $this->getLazyLoader( $method, $parameters );
$this->setVar( $variable, $placeholder );
}
/**
* Get a variable from the current object
*
- * @param string $variable
+ * @param string $varName The variable name
+ * @param int $mode One of the self::GET_* constants, determines how to behave when the variable is unset:
+ * - GET_STRICT -> In the future, this will throw an exception. For now it returns a DUNDEFINED and logs a warning
+ * - GET_LAX -> Return a DUNDEFINED AFPData
+ * - GET_BC -> Return a DNULL AFPData (this should only be used for BC, see T230256)
+ * @param string|null $tempFilter Filter ID, if available; only used for debugging (temporarily)
* @return AFPData
*/
- public function getVar( $variable ) {
- $variable = strtolower( $variable );
- if ( $this->mVarsVersion === 1 && in_array( $variable, AbuseFilter::getDeprecatedVariables() ) ) {
- // Variables are stored with old names, but the parser has given us
- // a new name. Translate it back.
- $variable = array_search( $variable, AbuseFilter::getDeprecatedVariables() );
- }
- if ( isset( $this->mVars[$variable] ) ) {
- if ( $this->mVars[$variable] instanceof AFComputedVariable ) {
- /** @suppress PhanUndeclaredMethod False positive */
- $value = $this->mVars[$variable]->compute( $this );
- $this->setVar( $variable, $value );
+ public function getVar( $varName, $mode = self::GET_STRICT, $tempFilter = null ) : AFPData {
+ $varName = strtolower( $varName );
+ if ( $this->varIsSet( $varName ) ) {
+ /** @var $variable AFComputedVariable|AFPData */
+ $variable = $this->mVars[$varName];
+ if ( $variable instanceof AFComputedVariable ) {
+ $value = $variable->compute( $this );
+ $this->setVar( $varName, $value );
return $value;
- } elseif ( $this->mVars[$variable] instanceof AFPData ) {
- return $this->mVars[$variable];
+ } elseif ( $variable instanceof AFPData ) {
+ return $variable;
+ } else {
+ throw new UnexpectedValueException(
+ "Variable $varName has unexpected type " . gettype( $variable )
+ );
}
}
- return new AFPData();
- }
-
- /**
- * @return AbuseFilterVariableHolder
- */
- public static function merge() {
- $newHolder = new AbuseFilterVariableHolder;
- $newHolder->addHolders( ...func_get_args() );
- return $newHolder;
+ // The variable is not set.
+ switch ( $mode ) {
+ case self::GET_STRICT:
+ $this->logger->warning(
+ __METHOD__ . ": requested unset variable {varname} in strict mode, filter: {filter}",
+ [
+ 'varname' => $varName,
+ 'exception' => new RuntimeException(),
+ 'filter' => $tempFilter ?? 'unavailable'
+ ]
+ );
+ // @todo change the line below to throw an exception in a future MW version
+ return new AFPData( AFPData::DUNDEFINED );
+ case self::GET_LAX:
+ return new AFPData( AFPData::DUNDEFINED );
+ case self::GET_BC:
+ // Old behaviour, which can sometimes lead to unexpected results (e.g.
+ // `edit_delta < -5000` will match any non-edit action).
+ return new AFPData( AFPData::DNULL );
+ default:
+ throw new LogicException( "Mode '$mode' not recognized." );
+ }
}
/**
* Merge any number of holders given as arguments into this holder.
*
- * @throws MWException
+ * @param AbuseFilterVariableHolder ...$holders
*/
- public function addHolders() {
- $holders = func_get_args();
-
+ public function addHolders( AbuseFilterVariableHolder ...$holders ) {
foreach ( $holders as $addHolder ) {
- if ( !is_object( $addHolder ) ) {
- throw new MWException( 'Invalid argument to AbuseFilterVariableHolder::addHolders' );
- }
$this->mVars = array_merge( $this->mVars, $addHolder->mVars );
}
}
- public function __wakeup() {
- // Reset the context.
- $this->setVar( 'context', 'stored' );
- }
-
/**
- * Export all variables stored in this object as string
+ * Export all variables stored in this object with their native (PHP) types.
*
- * @return string[]
+ * @return array
*/
public function exportAllVars() {
$exported = [];
foreach ( array_keys( $this->mVars ) as $varName ) {
- if ( !in_array( $varName, self::$varBlacklist ) ) {
- $exported[$varName] = $this->getVar( $varName )->toString();
- }
+ $exported[ $varName ] = $this->getVar( $varName )->toNative();
}
return $exported;
@@ -122,10 +205,7 @@ class AbuseFilterVariableHolder {
public function exportNonLazyVars() {
$exported = [];
foreach ( $this->mVars as $varName => $data ) {
- if (
- !( $data instanceof AFComputedVariable )
- && !in_array( $varName, self::$varBlacklist )
- ) {
+ if ( !( $data instanceof AFComputedVariable ) ) {
$exported[$varName] = $this->getVar( $varName )->toString();
}
}
@@ -144,51 +224,25 @@ class AbuseFilterVariableHolder {
* @return array
*/
public function dumpAllVars( $compute = [], $includeUserVars = false ) {
- $allVarNames = array_keys( $this->mVars );
- $exported = [];
$coreVariables = [];
if ( !$includeUserVars ) {
// Compile a list of all variables set by the extension to be able
// to filter user set ones by name
- global $wgRestrictionTypes;
-
- $coreVariables = AbuseFilter::getBuilderValues();
- $coreVariables = array_keys( $coreVariables['vars'] );
- $deprecatedVariables = array_keys( AbuseFilter::getDeprecatedVariables() );
- $coreVariables = array_merge( $coreVariables, $deprecatedVariables );
-
- // Title vars can have several prefixes
- $prefixes = [ 'MOVED_FROM', 'MOVED_TO', 'PAGE' ];
- $titleVars = [
- '_ID',
- '_NAMESPACE',
- '_TITLE',
- '_PREFIXEDTITLE',
- '_recent_contributors',
- '_age',
- ];
- foreach ( $wgRestrictionTypes as $action ) {
- $titleVars[] = "_restrictions_$action";
- }
-
- foreach ( $titleVars as $var ) {
- foreach ( $prefixes as $prefix ) {
- $coreVariables[] = $prefix . $var;
- }
- }
+ $activeVariables = array_keys( $this->keywordsManager->getVarsMappings() );
+ $deprecatedVariables = array_keys( $this->keywordsManager->getDeprecatedVariables() );
+ $disabledVariables = array_keys( $this->keywordsManager->getDisabledVariables() );
+ $coreVariables = array_merge( $activeVariables, $deprecatedVariables, $disabledVariables );
$coreVariables = array_map( 'strtolower', $coreVariables );
}
- foreach ( $allVarNames as $varName ) {
+ $exported = [];
+ foreach ( array_keys( $this->mVars ) as $varName ) {
+ $computeThis = ( is_array( $compute ) && in_array( $varName, $compute ) ) || $compute === true;
if (
( $includeUserVars || in_array( strtolower( $varName ), $coreVariables ) ) &&
// Only include variables set in the extension in case $includeUserVars is false
- !in_array( $varName, self::$varBlacklist ) &&
- ( $compute === true ||
- ( is_array( $compute ) && in_array( $varName, $compute ) ) ||
- $this->mVars[$varName] instanceof AFPData
- )
+ ( $computeThis || $this->mVars[$varName] instanceof AFPData )
) {
$exported[$varName] = $this->getVar( $varName )->toNative();
}
@@ -208,9 +262,6 @@ class AbuseFilterVariableHolder {
/**
* Compute all vars which need DB access. Useful for vars which are going to be saved
* cross-wiki or used for offline analysis.
- *
- * @suppress PhanUndeclaredProperty for $value->mMethod (phan thinks $value is always AFPData)
- * @suppress PhanUndeclaredMethod for $value->compute (phan thinks $value is always AFPData)
*/
public function computeDBVars() {
static $dbTypes = [
@@ -226,13 +277,23 @@ class AbuseFilterVariableHolder {
'revision-text-by-timestamp'
];
- foreach ( $this->mVars as $name => $value ) {
- if ( $value instanceof AFComputedVariable &&
- in_array( $value->mMethod, $dbTypes )
- ) {
+ /** @var AFComputedVariable[] $missingVars */
+ $missingVars = array_filter( $this->mVars, function ( $el ) {
+ return ( $el instanceof AFComputedVariable );
+ } );
+ foreach ( $missingVars as $name => $value ) {
+ if ( in_array( $value->mMethod, $dbTypes ) ) {
$value = $value->compute( $this );
$this->setVar( $name, $value );
}
}
}
+
+ /**
+ * @fixme Back-compat hack for old objects serialized and stored in the DB.
+ * Remove this once T213006 is done.
+ */
+ public function __wakeup() {
+ $this->keywordsManager = AbuseFilterServices::getKeywordsManager();
+ }
}
diff --git a/AbuseFilter/includes/AbuseLogHitFormatter.php b/AbuseFilter/includes/AbuseLogHitFormatter.php
index a2fbb284..5bdb7878 100644
--- a/AbuseFilter/includes/AbuseLogHitFormatter.php
+++ b/AbuseFilter/includes/AbuseLogHitFormatter.php
@@ -1,7 +1,5 @@
<?php
-use MediaWiki\MediaWikiServices;
-
/**
* This class formats abuse log notifications.
*
@@ -14,7 +12,7 @@ class AbuseLogHitFormatter extends LogFormatter {
*/
protected function getMessageParameters() {
$entry = $this->entry->getParameters();
- $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+ $linkRenderer = $this->getLinkRenderer();
$params = parent::getMessageParameters();
$filter_title = SpecialPage::getTitleFor( 'AbuseFilter', $entry['filter'] );
@@ -40,19 +38,19 @@ class AbuseLogHitFormatter extends LogFormatter {
) );
}
- $actions_taken = $entry['actions'];
- if ( !strlen( trim( $actions_taken ) ) ) {
+ $actions_takenRaw = $entry['actions'];
+ if ( !strlen( trim( $actions_takenRaw ) ) ) {
$actions_taken = $this->msg( 'abusefilter-log-noactions' );
} else {
- $actions = explode( ',', $actions_taken );
+ $actions = explode( ',', $actions_takenRaw );
$displayActions = [];
foreach ( $actions as $action ) {
- $displayActions[] = AbuseFilter::getActionDisplay( $action );
+ $displayActions[] = AbuseFilter::getActionDisplay( $action, $this->context );
}
$actions_taken = $this->context->getLanguage()->commaList( $displayActions );
}
- $params[5] = $actions_taken;
+ $params[5] = Message::rawParam( $actions_taken );
// Bad things happen if the numbers are not in correct order
ksort( $params );
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterAlterVariablesHook.php b/AbuseFilter/includes/Hooks/AbuseFilterAlterVariablesHook.php
new file mode 100644
index 00000000..b8307c6e
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterAlterVariablesHook.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use AbuseFilterVariableHolder;
+use Title;
+use User;
+
+interface AbuseFilterAlterVariablesHook {
+ /**
+ * Hook runner for the `AbuseFilterAlterVariables` hook
+ *
+ * Allows overwriting of abusefilter variables just before they're
+ * checked against filters. Note that you may specify custom variables in a saner way using other hooks:
+ * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars.
+ *
+ * @param AbuseFilterVariableHolder &$vars
+ * @param Title $title Title object target of the action
+ * @param User $user User object performer of the action
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterAlterVariables(
+ AbuseFilterVariableHolder &$vars,
+ Title $title,
+ User $user
+ );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterBuilderHook.php b/AbuseFilter/includes/Hooks/AbuseFilterBuilderHook.php
new file mode 100644
index 00000000..0354709b
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterBuilderHook.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+interface AbuseFilterBuilderHook {
+ /**
+ * Hook runner for the `AbuseFilter-builder` hook
+ *
+ * Allows overwriting of the builder values returned by AbuseFilter::getBuilderValues
+ *
+ * @param array &$realValues Builder values
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterBuilder( array &$realValues );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterComputeVariableHook.php b/AbuseFilter/includes/Hooks/AbuseFilterComputeVariableHook.php
new file mode 100644
index 00000000..ef7a77ec
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterComputeVariableHook.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use AbuseFilterVariableHolder;
+
+interface AbuseFilterComputeVariableHook {
+ /**
+ * Hook runner for the `AbuseFilter-computeVariable` hook
+ *
+ * Like AbuseFilter-interceptVariable but called if the requested method wasn't found.
+ * Return true to indicate that the method is known to the hook and was computed successful.
+ *
+ * @param string $method Method to generate the variable
+ * @param AbuseFilterVariableHolder $vars
+ * @param array $parameters Parameters with data to compute the value
+ * @param ?string &$result Result of the computation
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterComputeVariable(
+ string $method,
+ AbuseFilterVariableHolder $vars,
+ array $parameters,
+ ?string &$result
+ );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterContentToStringHook.php b/AbuseFilter/includes/Hooks/AbuseFilterContentToStringHook.php
new file mode 100644
index 00000000..74ef1ee6
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterContentToStringHook.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use Content;
+
+interface AbuseFilterContentToStringHook {
+ /**
+ * Hook runner for the `AbuseFilter-contentToString` hook
+ *
+ * Called when converting a Content object to a string to which
+ * filters can be applied. If the hook function returns true, Content::getTextForSearchIndex()
+ * will be used for non-text content.
+ *
+ * @param Content $content
+ * @param ?string &$text
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterContentToString(
+ Content $content,
+ ?string &$text
+ );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterDeprecatedVariablesHook.php b/AbuseFilter/includes/Hooks/AbuseFilterDeprecatedVariablesHook.php
new file mode 100644
index 00000000..98340d9e
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterDeprecatedVariablesHook.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+interface AbuseFilterDeprecatedVariablesHook {
+ /**
+ * Hook runner for the `AbuseFilter-deprecatedVariables` hook
+ *
+ * Allows adding deprecated variables. If a filter uses an old variable, the parser
+ * will automatically translate it to the new one.
+ *
+ * @param array &$deprecatedVariables deprecated variables, syntax: [ 'old_name' => 'new_name' ]
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterDeprecatedVariables( array &$deprecatedVariables );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterFilterActionHook.php b/AbuseFilter/includes/Hooks/AbuseFilterFilterActionHook.php
new file mode 100644
index 00000000..655c06d5
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterFilterActionHook.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use AbuseFilterVariableHolder;
+use Title;
+
+interface AbuseFilterFilterActionHook {
+ /**
+ * Hook runner for the `AbuseFilter-filterAction` hook
+ *
+ * DEPRECATED! Use AbuseFilterAlterVariables instead.
+ *
+ * Allows overwriting of abusefilter variables in AbuseFilter::filterAction just before they're
+ * checked against filters. Note that you may specify custom variables in a saner way using other hooks:
+ * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars.
+ *
+ * @param AbuseFilterVariableHolder &$vars
+ * @param Title $title
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterFilterAction(
+ AbuseFilterVariableHolder &$vars,
+ Title $title
+ );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterGenerateGenericVarsHook.php b/AbuseFilter/includes/Hooks/AbuseFilterGenerateGenericVarsHook.php
new file mode 100644
index 00000000..b7ba9217
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterGenerateGenericVarsHook.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use AbuseFilterVariableHolder;
+use RecentChange;
+
+interface AbuseFilterGenerateGenericVarsHook {
+ /**
+ * Hook runner for the `AbuseFilter-generateGenericVars` hook
+ *
+ * Allows altering generic variables, i.e. independent from page and user
+ *
+ * @param AbuseFilterVariableHolder $vars
+ * @param ?RecentChange $rc If the variables should be generated for an RC entry,
+ * this is the entry. Null if it's for the current action being filtered.
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterGenerateGenericVars(
+ AbuseFilterVariableHolder $vars,
+ ?RecentChange $rc
+ );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterGenerateTitleVarsHook.php b/AbuseFilter/includes/Hooks/AbuseFilterGenerateTitleVarsHook.php
new file mode 100644
index 00000000..a14ef980
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterGenerateTitleVarsHook.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use AbuseFilterVariableHolder;
+use RecentChange;
+use Title;
+
+interface AbuseFilterGenerateTitleVarsHook {
+ /**
+ * Hook runner for the `AbuseFilter-generateTitleVars` hook
+ *
+ * Allows altering the variables generated for a title
+ *
+ * @param AbuseFilterVariableHolder $vars
+ * @param Title $title
+ * @param string $prefix Variable name prefix
+ * @param ?RecentChange $rc If the variables should be generated for an RC entry,
+ * this is the entry. Null if it's for the current action being filtered.
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterGenerateTitleVars(
+ AbuseFilterVariableHolder $vars,
+ Title $title,
+ string $prefix,
+ ?RecentChange $rc
+ );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterGenerateUserVarsHook.php b/AbuseFilter/includes/Hooks/AbuseFilterGenerateUserVarsHook.php
new file mode 100644
index 00000000..f304a325
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterGenerateUserVarsHook.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use AbuseFilterVariableHolder;
+use RecentChange;
+use User;
+
+interface AbuseFilterGenerateUserVarsHook {
+ /**
+ * Hook runner for the `AbuseFilter-generateUserVars` hook
+ *
+ * Allows altering the variables generated for a specific user
+ *
+ * @param AbuseFilterVariableHolder $vars
+ * @param User $user
+ * @param ?RecentChange $rc If the variables should be generated for an RC entry,
+ * this is the entry. Null if it's for the current action being filtered.
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterGenerateUserVars(
+ AbuseFilterVariableHolder $vars,
+ User $user,
+ ?RecentChange $rc
+ );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterHookRunner.php b/AbuseFilter/includes/Hooks/AbuseFilterHookRunner.php
new file mode 100644
index 00000000..be6bfaf8
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterHookRunner.php
@@ -0,0 +1,290 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use AbuseFilterVariableHolder;
+use Content;
+use MediaWiki\HookContainer\HookContainer;
+use MediaWiki\MediaWikiServices;
+use RecentChange;
+use Title;
+use User;
+
+/**
+ * Handle running AbuseFilter's hooks
+ * @author DannyS712
+ */
+class AbuseFilterHookRunner implements
+ AbuseFilterAlterVariablesHook,
+ AbuseFilterBuilderHook,
+ AbuseFilterComputeVariableHook,
+ AbuseFilterContentToStringHook,
+ AbuseFilterDeprecatedVariablesHook,
+ AbuseFilterFilterActionHook,
+ AbuseFilterGenerateGenericVarsHook,
+ AbuseFilterGenerateTitleVarsHook,
+ AbuseFilterGenerateUserVarsHook,
+ AbuseFilterInterceptVariableHook,
+ AbuseFilterShouldFilterActionHook
+{
+
+ /** @var HookContainer */
+ private $hookContainer;
+
+ /**
+ * @param HookContainer $hookContainer
+ */
+ public function __construct( HookContainer $hookContainer ) {
+ $this->hookContainer = $hookContainer;
+ }
+
+ /**
+ * Convenience getter for static contexts
+ *
+ * See also core's Hooks::runner
+ *
+ * @return AbuseFilterHookRunner
+ */
+ public static function getRunner() : AbuseFilterHookRunner {
+ return new AbuseFilterHookRunner(
+ MediaWikiServices::getInstance()->getHookContainer()
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilter-builder` hook
+ *
+ * Allows overwriting of the builder values returned by AbuseFilter::getBuilderValues
+ *
+ * @param array &$realValues Builder values
+ * @return bool|void
+ */
+ public function onAbuseFilterBuilder( array &$realValues ) {
+ return $this->hookContainer->run(
+ 'AbuseFilter-builder',
+ [ &$realValues ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilter-deprecatedVariables` hook
+ *
+ * Allows adding deprecated variables. If a filter uses an old variable, the parser
+ * will automatically translate it to the new one.
+ *
+ * @param array &$deprecatedVariables deprecated variables, syntax: [ 'old_name' => 'new_name' ]
+ * @return bool|void
+ */
+ public function onAbuseFilterDeprecatedVariables( array &$deprecatedVariables ) {
+ return $this->hookContainer->run(
+ 'AbuseFilter-deprecatedVariables',
+ [ &$deprecatedVariables ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilter-computeVariable` hook
+ *
+ * Like AbuseFilter-interceptVariable but called if the requested method wasn't found.
+ * Return true to indicate that the method is known to the hook and was computed successful.
+ *
+ * @param string $method Method to generate the variable
+ * @param AbuseFilterVariableHolder $vars
+ * @param array $parameters Parameters with data to compute the value
+ * @param ?string &$result Result of the computation
+ * @return bool|void
+ */
+ public function onAbuseFilterComputeVariable(
+ string $method,
+ AbuseFilterVariableHolder $vars,
+ array $parameters,
+ ?string &$result
+ ) {
+ return $this->hookContainer->run(
+ 'AbuseFilter-computeVariable',
+ [ $method, $vars, $parameters, &$result ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilter-contentToString` hook
+ *
+ * Called when converting a Content object to a string to which
+ * filters can be applied. If the hook function returns true, Content::getTextForSearchIndex()
+ * will be used for non-text content.
+ *
+ * @param Content $content
+ * @param ?string &$text
+ * @return bool|void
+ */
+ public function onAbuseFilterContentToString(
+ Content $content,
+ ?string &$text
+ ) {
+ return $this->hookContainer->run(
+ 'AbuseFilter-contentToString',
+ [ $content, &$text ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilter-filterAction` hook
+ *
+ * DEPRECATED! Use AbuseFilterAlterVariables instead.
+ *
+ * Allows overwriting of abusefilter variables in AbuseFilter::filterAction just before they're
+ * checked against filters. Note that you may specify custom variables in a saner way using other hooks:
+ * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars.
+ *
+ * @param AbuseFilterVariableHolder &$vars
+ * @param Title $title
+ * @return bool|void
+ */
+ public function onAbuseFilterFilterAction(
+ AbuseFilterVariableHolder &$vars,
+ Title $title
+ ) {
+ return $this->hookContainer->run(
+ 'AbuseFilter-filterAction',
+ [ &$vars, $title ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilterAlterVariables` hook
+ *
+ * Allows overwriting of abusefilter variables just before they're
+ * checked against filters. Note that you may specify custom variables in a saner way using other hooks:
+ * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars.
+ *
+ * @param AbuseFilterVariableHolder &$vars
+ * @param Title $title Title object target of the action
+ * @param User $user User object performer of the action
+ * @return bool|void
+ */
+ public function onAbuseFilterAlterVariables(
+ AbuseFilterVariableHolder &$vars,
+ Title $title,
+ User $user
+ ) {
+ return $this->hookContainer->run(
+ 'AbuseFilterAlterVariables',
+ [ &$vars, $title, $user ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilter-generateTitleVars` hook
+ *
+ * Allows altering the variables generated for a title
+ *
+ * @param AbuseFilterVariableHolder $vars
+ * @param Title $title
+ * @param string $prefix Variable name prefix
+ * @param ?RecentChange $rc If the variables should be generated for an RC entry,
+ * this is the entry. Null if it's for the current action being filtered.
+ * @return bool|void
+ */
+ public function onAbuseFilterGenerateTitleVars(
+ AbuseFilterVariableHolder $vars,
+ Title $title,
+ string $prefix,
+ ?RecentChange $rc
+ ) {
+ return $this->hookContainer->run(
+ 'AbuseFilter-generateTitleVars',
+ [ $vars, $title, $prefix, $rc ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilter-generateUserVars` hook
+ *
+ * Allows altering the variables generated for a specific user
+ *
+ * @param AbuseFilterVariableHolder $vars
+ * @param User $user
+ * @param ?RecentChange $rc If the variables should be generated for an RC entry,
+ * this is the entry. Null if it's for the current action being filtered.
+ * @return bool|void
+ */
+ public function onAbuseFilterGenerateUserVars(
+ AbuseFilterVariableHolder $vars,
+ User $user,
+ ?RecentChange $rc
+ ) {
+ return $this->hookContainer->run(
+ 'AbuseFilter-generateUserVars',
+ [ $vars, $user, $rc ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilter-generateGenericVars` hook
+ *
+ * Allows altering generic variables, i.e. independent from page and user
+ *
+ * @param AbuseFilterVariableHolder $vars
+ * @param ?RecentChange $rc If the variables should be generated for an RC entry,
+ * this is the entry. Null if it's for the current action being filtered.
+ * @return bool|void
+ */
+ public function onAbuseFilterGenerateGenericVars(
+ AbuseFilterVariableHolder $vars,
+ ?RecentChange $rc
+ ) {
+ return $this->hookContainer->run(
+ 'AbuseFilter-generateGenericVars',
+ [ $vars, $rc ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilter-interceptVariable` hook
+ *
+ * Called before a variable is set in AFComputedVariable::compute to be able to set
+ * it before the core code runs. Return false to make the function return right after.
+ *
+ * @param string $method Method to generate the variable
+ * @param AbuseFilterVariableHolder $vars
+ * @param array $parameters Parameters with data to compute the value
+ * @param mixed &$result Result of the computation
+ * @return bool|void
+ */
+ public function onAbuseFilterInterceptVariable(
+ string $method,
+ AbuseFilterVariableHolder $vars,
+ array $parameters,
+ &$result
+ ) {
+ return $this->hookContainer->run(
+ 'AbuseFilter-interceptVariable',
+ [ $method, $vars, $parameters, &$result ]
+ );
+ }
+
+ /**
+ * Hook runner for the `AbuseFilterShouldFilterAction` hook
+ *
+ * Called before filtering an action. If the current action should not be filtered,
+ * return false and add a useful reason to $skipReasons.
+ *
+ * @param AbuseFilterVariableHolder $vars
+ * @param Title $title Title object target of the action
+ * @param User $user User object performer of the action
+ * @param array &$skipReasons Array of reasons why the action should be skipped
+ * @return bool|void
+ */
+ public function onAbuseFilterShouldFilterAction(
+ AbuseFilterVariableHolder $vars,
+ Title $title,
+ User $user,
+ array &$skipReasons
+ ) {
+ return $this->hookContainer->run(
+ 'AbuseFilterShouldFilterAction',
+ [ $vars, $title, $user, &$skipReasons ]
+ );
+ }
+
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterInterceptVariableHook.php b/AbuseFilter/includes/Hooks/AbuseFilterInterceptVariableHook.php
new file mode 100644
index 00000000..ba93b3ac
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterInterceptVariableHook.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use AbuseFilterVariableHolder;
+
+interface AbuseFilterInterceptVariableHook {
+ /**
+ * Hook runner for the `AbuseFilter-interceptVariable` hook
+ *
+ * Called before a variable is set in AFComputedVariable::compute to be able to set
+ * it before the core code runs. Return false to make the function return right after.
+ *
+ * @param string $method Method to generate the variable
+ * @param AbuseFilterVariableHolder $vars
+ * @param array $parameters Parameters with data to compute the value
+ * @param mixed &$result Result of the computation
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterInterceptVariable(
+ string $method,
+ AbuseFilterVariableHolder $vars,
+ array $parameters,
+ &$result
+ );
+}
diff --git a/AbuseFilter/includes/Hooks/AbuseFilterShouldFilterActionHook.php b/AbuseFilter/includes/Hooks/AbuseFilterShouldFilterActionHook.php
new file mode 100644
index 00000000..1d67bfff
--- /dev/null
+++ b/AbuseFilter/includes/Hooks/AbuseFilterShouldFilterActionHook.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\Hooks;
+
+use AbuseFilterVariableHolder;
+use Title;
+use User;
+
+interface AbuseFilterShouldFilterActionHook {
+ /**
+ * Hook runner for the `AbuseFilterShouldFilterAction` hook
+ *
+ * Called before filtering an action. If the current action should not be filtered,
+ * return false and add a useful reason to $skipReasons.
+ *
+ * @param AbuseFilterVariableHolder $vars
+ * @param Title $title Title object target of the action
+ * @param User $user User object performer of the action
+ * @param array &$skipReasons Array of reasons why the action should be skipped
+ * @return bool|void True or no return value to continue or false to abort
+ */
+ public function onAbuseFilterShouldFilterAction(
+ AbuseFilterVariableHolder $vars,
+ Title $title,
+ User $user,
+ array &$skipReasons
+ );
+}
diff --git a/AbuseFilter/includes/KeywordsManager.php b/AbuseFilter/includes/KeywordsManager.php
new file mode 100644
index 00000000..d86c9a3f
--- /dev/null
+++ b/AbuseFilter/includes/KeywordsManager.php
@@ -0,0 +1,283 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter;
+
+use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
+
+/**
+ * This service can be used to manage the list of keywords recognized by the Parser
+ */
+class KeywordsManager {
+ public const SERVICE_NAME = 'AbuseFilterKeywordsManager';
+
+ private const BUILDER_VALUES = [
+ 'op-arithmetic' => [
+ '+' => 'addition',
+ '-' => 'subtraction',
+ '*' => 'multiplication',
+ '/' => 'divide',
+ '%' => 'modulo',
+ '**' => 'pow'
+ ],
+ 'op-comparison' => [
+ '==' => 'equal',
+ '===' => 'equal-strict',
+ '!=' => 'notequal',
+ '!==' => 'notequal-strict',
+ '<' => 'lt',
+ '>' => 'gt',
+ '<=' => 'lte',
+ '>=' => 'gte'
+ ],
+ 'op-bool' => [
+ '!' => 'not',
+ '&' => 'and',
+ '|' => 'or',
+ '^' => 'xor'
+ ],
+ 'misc' => [
+ 'in' => 'in',
+ 'contains' => 'contains',
+ 'like' => 'like',
+ '""' => 'stringlit',
+ 'rlike' => 'rlike',
+ 'irlike' => 'irlike',
+ 'cond ? iftrue : iffalse' => 'tern',
+ 'if cond then iftrue else iffalse end' => 'cond',
+ 'if cond then iftrue end' => 'cond-short',
+ ],
+ 'funcs' => [
+ 'length(string)' => 'length',
+ 'lcase(string)' => 'lcase',
+ 'ucase(string)' => 'ucase',
+ 'ccnorm(string)' => 'ccnorm',
+ 'ccnorm_contains_any(haystack,needle1,needle2,..)' => 'ccnorm-contains-any',
+ 'ccnorm_contains_all(haystack,needle1,needle2,..)' => 'ccnorm-contains-all',
+ 'rmdoubles(string)' => 'rmdoubles',
+ 'specialratio(string)' => 'specialratio',
+ 'norm(string)' => 'norm',
+ 'count(needle,haystack)' => 'count',
+ 'rcount(needle,haystack)' => 'rcount',
+ 'get_matches(needle,haystack)' => 'get_matches',
+ 'rmwhitespace(text)' => 'rmwhitespace',
+ 'rmspecials(text)' => 'rmspecials',
+ 'ip_in_range(ip, range)' => 'ip_in_range',
+ 'contains_any(haystack,needle1,needle2,...)' => 'contains-any',
+ 'contains_all(haystack,needle1,needle2,...)' => 'contains-all',
+ 'equals_to_any(haystack,needle1,needle2,...)' => 'equals-to-any',
+ 'substr(subject, offset, length)' => 'substr',
+ 'strpos(haystack, needle)' => 'strpos',
+ 'str_replace(subject, search, replace)' => 'str_replace',
+ 'rescape(string)' => 'rescape',
+ 'set_var(var,value)' => 'set_var',
+ 'sanitize(string)' => 'sanitize',
+ ],
+ 'vars' => [
+ 'timestamp' => 'timestamp',
+ 'accountname' => 'accountname',
+ 'action' => 'action',
+ 'added_lines' => 'addedlines',
+ 'edit_delta' => 'delta',
+ 'edit_diff' => 'diff',
+ 'new_size' => 'newsize',
+ 'old_size' => 'oldsize',
+ 'new_content_model' => 'new-content-model',
+ 'old_content_model' => 'old-content-model',
+ 'removed_lines' => 'removedlines',
+ 'summary' => 'summary',
+ 'page_id' => 'page-id',
+ 'page_namespace' => 'page-ns',
+ 'page_title' => 'page-title',
+ 'page_prefixedtitle' => 'page-prefixedtitle',
+ 'page_age' => 'page-age',
+ 'moved_from_id' => 'movedfrom-id',
+ 'moved_from_namespace' => 'movedfrom-ns',
+ 'moved_from_title' => 'movedfrom-title',
+ 'moved_from_prefixedtitle' => 'movedfrom-prefixedtitle',
+ 'moved_from_age' => 'movedfrom-age',
+ 'moved_to_id' => 'movedto-id',
+ 'moved_to_namespace' => 'movedto-ns',
+ 'moved_to_title' => 'movedto-title',
+ 'moved_to_prefixedtitle' => 'movedto-prefixedtitle',
+ 'moved_to_age' => 'movedto-age',
+ 'user_editcount' => 'user-editcount',
+ 'user_age' => 'user-age',
+ 'user_name' => 'user-name',
+ 'user_groups' => 'user-groups',
+ 'user_rights' => 'user-rights',
+ 'user_blocked' => 'user-blocked',
+ 'user_emailconfirm' => 'user-emailconfirm',
+ 'old_wikitext' => 'old-wikitext',
+ 'new_wikitext' => 'new-wikitext',
+ 'added_links' => 'added-links',
+ 'removed_links' => 'removed-links',
+ 'all_links' => 'all-links',
+ 'new_pst' => 'new-pst',
+ 'edit_diff_pst' => 'diff-pst',
+ 'added_lines_pst' => 'addedlines-pst',
+ 'new_text' => 'new-text',
+ 'new_html' => 'new-html',
+ 'page_restrictions_edit' => 'restrictions-edit',
+ 'page_restrictions_move' => 'restrictions-move',
+ 'page_restrictions_create' => 'restrictions-create',
+ 'page_restrictions_upload' => 'restrictions-upload',
+ 'page_recent_contributors' => 'recent-contributors',
+ 'page_first_contributor' => 'first-contributor',
+ 'moved_from_restrictions_edit' => 'movedfrom-restrictions-edit',
+ 'moved_from_restrictions_move' => 'movedfrom-restrictions-move',
+ 'moved_from_restrictions_create' => 'movedfrom-restrictions-create',
+ 'moved_from_restrictions_upload' => 'movedfrom-restrictions-upload',
+ 'moved_from_recent_contributors' => 'movedfrom-recent-contributors',
+ 'moved_from_first_contributor' => 'movedfrom-first-contributor',
+ 'moved_to_restrictions_edit' => 'movedto-restrictions-edit',
+ 'moved_to_restrictions_move' => 'movedto-restrictions-move',
+ 'moved_to_restrictions_create' => 'movedto-restrictions-create',
+ 'moved_to_restrictions_upload' => 'movedto-restrictions-upload',
+ 'moved_to_recent_contributors' => 'movedto-recent-contributors',
+ 'moved_to_first_contributor' => 'movedto-first-contributor',
+ 'old_links' => 'old-links',
+ 'file_sha1' => 'file-sha1',
+ 'file_size' => 'file-size',
+ 'file_mime' => 'file-mime',
+ 'file_mediatype' => 'file-mediatype',
+ 'file_width' => 'file-width',
+ 'file_height' => 'file-height',
+ 'file_bits_per_channel' => 'file-bits-per-channel',
+ 'wiki_name' => 'wiki-name',
+ 'wiki_language' => 'wiki-language',
+ ],
+ ];
+
+ /** @var array Old vars which aren't in use anymore */
+ private const DISABLED_VARS = [
+ 'old_text' => 'old-text',
+ 'old_html' => 'old-html',
+ 'minor_edit' => 'minor-edit'
+ ];
+
+ private const DEPRECATED_VARS = [
+ 'article_text' => 'page_title',
+ 'article_prefixedtext' => 'page_prefixedtitle',
+ 'article_namespace' => 'page_namespace',
+ 'article_articleid' => 'page_id',
+ 'article_restrictions_edit' => 'page_restrictions_edit',
+ 'article_restrictions_move' => 'page_restrictions_move',
+ 'article_restrictions_create' => 'page_restrictions_create',
+ 'article_restrictions_upload' => 'page_restrictions_upload',
+ 'article_recent_contributors' => 'page_recent_contributors',
+ 'article_first_contributor' => 'page_first_contributor',
+ 'moved_from_text' => 'moved_from_title',
+ 'moved_from_prefixedtext' => 'moved_from_prefixedtitle',
+ 'moved_from_articleid' => 'moved_from_id',
+ 'moved_to_text' => 'moved_to_title',
+ 'moved_to_prefixedtext' => 'moved_to_prefixedtitle',
+ 'moved_to_articleid' => 'moved_to_id',
+ ];
+
+ /** @var string[][] Final list of builder values */
+ private $builderValues;
+
+ /** @var string[] Final list of deprecated vars */
+ private $deprecatedVars;
+
+ /** @var AbuseFilterHookRunner */
+ private $hookRunner;
+
+ /**
+ * @param AbuseFilterHookRunner $hookRunner
+ */
+ public function __construct( AbuseFilterHookRunner $hookRunner ) {
+ $this->hookRunner = $hookRunner;
+ }
+
+ /**
+ * @return array
+ */
+ public function getDisabledVariables(): array {
+ return self::DISABLED_VARS;
+ }
+
+ /**
+ * @return array
+ */
+ public function getDeprecatedVariables(): array {
+ if ( $this->deprecatedVars === null ) {
+ $this->deprecatedVars = self::DEPRECATED_VARS;
+ $this->hookRunner->onAbuseFilterDeprecatedVariables( $this->deprecatedVars );
+ }
+ return $this->deprecatedVars;
+ }
+
+ /**
+ * @return array
+ */
+ public function getBuilderValues(): array {
+ if ( $this->builderValues === null ) {
+ $this->builderValues = self::BUILDER_VALUES;
+ $this->hookRunner->onAbuseFilterBuilder( $this->builderValues );
+ }
+ return $this->builderValues;
+ }
+
+ /**
+ * @param string $name
+ * @return bool
+ */
+ public function isVarDisabled( string $name ): bool {
+ return array_key_exists( $name, self::DISABLED_VARS );
+ }
+
+ /**
+ * @param string $name
+ * @return bool
+ */
+ public function isVarDeprecated( string $name ): bool {
+ return array_key_exists( $name, $this->getDeprecatedVariables() );
+ }
+
+ /**
+ * @param string $name
+ * @return bool
+ */
+ public function isVarInUse( string $name ): bool {
+ return array_key_exists( $name, $this->getVarsMappings() );
+ }
+
+ /**
+ * Check whether the given name corresponds to a known variable.
+ * @param string $name
+ * @return bool
+ */
+ public function varExists( string $name ): bool {
+ return $this->isVarInUse( $name ) ||
+ $this->isVarDisabled( $name ) ||
+ $this->isVarDeprecated( $name );
+ }
+
+ /**
+ * Get the message for a builtin variable; takes deprecated variables into account.
+ * Returns null for non-builtin variables.
+ *
+ * @param string $var
+ * @return string|null
+ */
+ public function getMessageKeyForVar( string $var ): ?string {
+ if ( !$this->varExists( $var ) ) {
+ return null;
+ }
+ if ( $this->isVarDeprecated( $var ) ) {
+ $var = $this->getDeprecatedVariables()[$var];
+ }
+
+ $key = self::DISABLED_VARS[$var] ??
+ $this->getVarsMappings()[$var];
+ return "abusefilter-edit-builder-vars-$key";
+ }
+
+ /**
+ * @return array
+ */
+ public function getVarsMappings(): array {
+ return $this->getBuilderValues()['vars'];
+ }
+}
diff --git a/AbuseFilter/includes/ServiceWiring.php b/AbuseFilter/includes/ServiceWiring.php
new file mode 100644
index 00000000..5901ca80
--- /dev/null
+++ b/AbuseFilter/includes/ServiceWiring.php
@@ -0,0 +1,13 @@
+<?php
+
+use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
+use MediaWiki\Extension\AbuseFilter\KeywordsManager;
+use MediaWiki\MediaWikiServices;
+
+return [
+ KeywordsManager::SERVICE_NAME => function ( MediaWikiServices $services ): KeywordsManager {
+ return new KeywordsManager(
+ new AbuseFilterHookRunner( $services->getHookContainer() )
+ );
+ },
+];
diff --git a/AbuseFilter/includes/VariableGenerator/RCVariableGenerator.php b/AbuseFilter/includes/VariableGenerator/RCVariableGenerator.php
new file mode 100644
index 00000000..8c4a1d81
--- /dev/null
+++ b/AbuseFilter/includes/VariableGenerator/RCVariableGenerator.php
@@ -0,0 +1,235 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\VariableGenerator;
+
+use AbuseFilterVariableHolder;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MWFileProps;
+use MWTimestamp;
+use RecentChange;
+use Title;
+use User;
+
+/**
+ * This class contains the logic used to create AbuseFilterVariableHolder objects used to
+ * examine a RecentChanges row.
+ */
+class RCVariableGenerator extends VariableGenerator {
+ /**
+ * @var RecentChange
+ */
+ protected $rc;
+
+ /** @var User */
+ private $contextUser;
+
+ /**
+ * @param AbuseFilterVariableHolder $vars
+ * @param RecentChange $rc
+ * @param User $contextUser
+ */
+ public function __construct(
+ AbuseFilterVariableHolder $vars,
+ RecentChange $rc,
+ User $contextUser
+ ) {
+ parent::__construct( $vars );
+
+ $this->rc = $rc;
+ $this->contextUser = $contextUser;
+ }
+
+ /**
+ * Get an instance for a given rc_id.
+ *
+ * @todo FIXME this method doesn't appear to have any uses
+ *
+ * @param int $id
+ * @param AbuseFilterVariableHolder $vars
+ * @param User $contextUser
+ * @return self|null
+ */
+ public static function newFromId(
+ int $id,
+ AbuseFilterVariableHolder $vars,
+ User $contextUser
+ ) : ?self {
+ $rc = RecentChange::newFromId( $id );
+
+ if ( !$rc ) {
+ return null;
+ }
+ return new self( $vars, $rc, $contextUser );
+ }
+
+ /**
+ * @return AbuseFilterVariableHolder|null
+ */
+ public function getVars() : ?AbuseFilterVariableHolder {
+ if ( $this->rc->getAttribute( 'rc_type' ) == RC_LOG ) {
+ switch ( $this->rc->getAttribute( 'rc_log_type' ) ) {
+ case 'move':
+ $this->addMoveVars();
+ break;
+ case 'newusers':
+ $this->addCreateAccountVars();
+ break;
+ case 'delete':
+ $this->addDeleteVars();
+ break;
+ case 'upload':
+ $this->addUploadVars();
+ break;
+ default:
+ return null;
+ }
+ } elseif ( $this->rc->getAttribute( 'rc_last_oldid' ) ) {
+ // It's an edit.
+ $this->addEditVarsForRow();
+ } else {
+ // @todo Ensure this cannot happen, and throw if it does
+ return null;
+ }
+
+ $this->addGenericVars();
+ $this->vars->setVar(
+ 'timestamp',
+ MWTimestamp::convert( TS_UNIX, $this->rc->getAttribute( 'rc_timestamp' ) )
+ );
+
+ return $this->vars;
+ }
+
+ /**
+ * @return $this
+ */
+ private function addMoveVars() : self {
+ $user = $this->rc->getPerformer();
+
+ $oldTitle = $this->rc->getTitle();
+ $newTitle = Title::newFromText( $this->rc->getParam( '4::target' ) );
+
+ $this->addUserVars( $user, $this->rc )
+ ->addTitleVars( $oldTitle, 'moved_from', $this->rc )
+ ->addTitleVars( $newTitle, 'moved_to', $this->rc );
+
+ $this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) );
+ $this->vars->setVar( 'action', 'move' );
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ private function addCreateAccountVars() : self {
+ $this->vars->setVar(
+ 'action',
+ $this->rc->getAttribute( 'rc_log_action' ) === 'autocreate'
+ ? 'autocreateaccount'
+ : 'createaccount'
+ );
+
+ $name = $this->rc->getTitle()->getText();
+ // Add user data if the account was created by a registered user
+ $user = $this->rc->getPerformer();
+ if ( !$user->isAnon() && $name !== $user->getName() ) {
+ $this->addUserVars( $user, $this->rc );
+ }
+
+ $this->vars->setVar( 'accountname', $name );
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ private function addDeleteVars() : self {
+ $title = $this->rc->getTitle();
+ $user = $this->rc->getPerformer();
+
+ $this->addUserVars( $user, $this->rc )
+ ->addTitleVars( $title, 'page', $this->rc );
+
+ $this->vars->setVar( 'action', 'delete' );
+ $this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) );
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ private function addUploadVars() : self {
+ $title = $this->rc->getTitle();
+ $user = $this->rc->getPerformer();
+
+ $this->addUserVars( $user, $this->rc )
+ ->addTitleVars( $title, 'page', $this->rc );
+
+ $this->vars->setVar( 'action', 'upload' );
+ $this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) );
+
+ $time = $this->rc->getParam( 'img_timestamp' );
+ $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile(
+ $title, [ 'time' => $time, 'private' => $this->contextUser ]
+ );
+ if ( !$file ) {
+ // FixMe This shouldn't happen!
+ $logger = LoggerFactory::getInstance( 'AbuseFilter' );
+ $logger->debug( "Cannot find file from RC row with title $title" );
+ return $this;
+ }
+
+ // This is the same as AbuseFilterHooks::filterUpload, but from a different source
+ $this->vars->setVar( 'file_sha1', \Wikimedia\base_convert( $file->getSha1(), 36, 16, 40 ) );
+ $this->vars->setVar( 'file_size', $file->getSize() );
+
+ $this->vars->setVar( 'file_mime', $file->getMimeType() );
+ $this->vars->setVar(
+ 'file_mediatype',
+ MediaWikiServices::getInstance()->getMimeAnalyzer()
+ ->getMediaType( null, $file->getMimeType() )
+ );
+ $this->vars->setVar( 'file_width', $file->getWidth() );
+ $this->vars->setVar( 'file_height', $file->getHeight() );
+
+ $mwProps = new MWFileProps( MediaWikiServices::getInstance()->getMimeAnalyzer() );
+ $bits = $mwProps->getPropsFromPath( $file->getLocalRefPath(), true )['bits'];
+ $this->vars->setVar( 'file_bits_per_channel', $bits );
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ private function addEditVarsForRow() : self {
+ $title = $this->rc->getTitle();
+ $user = $this->rc->getPerformer();
+
+ $this->addUserVars( $user, $this->rc )
+ ->addTitleVars( $title, 'page', $this->rc );
+
+ // @todo Set old_content_model and new_content_model
+ $this->vars->setVar( 'action', 'edit' );
+ $this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) );
+
+ $this->vars->setLazyLoadVar( 'new_wikitext', 'revision-text-by-id',
+ [ 'revid' => $this->rc->getAttribute( 'rc_this_oldid' ) ] );
+
+ $parentId = $this->rc->getAttribute( 'rc_last_oldid' );
+ if ( $parentId ) {
+ $this->vars->setLazyLoadVar( 'old_wikitext', 'revision-text-by-id',
+ [ 'revid' => $parentId ] );
+ } else {
+ $this->vars->setVar( 'old_wikitext', '' );
+ }
+
+ $this->addEditVars( $title );
+
+ return $this;
+ }
+}
diff --git a/AbuseFilter/includes/VariableGenerator/RunVariableGenerator.php b/AbuseFilter/includes/VariableGenerator/RunVariableGenerator.php
new file mode 100644
index 00000000..1f0deab4
--- /dev/null
+++ b/AbuseFilter/includes/VariableGenerator/RunVariableGenerator.php
@@ -0,0 +1,316 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\VariableGenerator;
+
+use AbuseFilter;
+use AbuseFilterVariableHolder;
+use AFComputedVariable;
+use Content;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use MWException;
+use MWFileProps;
+use Title;
+use UploadBase;
+use User;
+use WikiPage;
+
+/**
+ * This class contains the logic used to create AbuseFilterVariableHolder objects before filtering
+ * an action.
+ */
+class RunVariableGenerator extends VariableGenerator {
+ /**
+ * @var User
+ */
+ protected $user;
+
+ /**
+ * @var Title
+ */
+ protected $title;
+
+ /**
+ * @param AbuseFilterVariableHolder $vars
+ * @param User $user
+ * @param Title $title
+ */
+ public function __construct( AbuseFilterVariableHolder $vars, User $user, Title $title ) {
+ parent::__construct( $vars );
+ $this->user = $user;
+ $this->title = $title;
+ }
+
+ /**
+ * Get variables for pre-filtering an edit during stash
+ *
+ * @param Content $content
+ * @param string $summary
+ * @param string $slot
+ * @param WikiPage $page
+ * @return AbuseFilterVariableHolder|null
+ */
+ public function getStashEditVars(
+ Content $content,
+ string $summary,
+ $slot,
+ WikiPage $page
+ ) : ?AbuseFilterVariableHolder {
+ $filterText = $this->getEditTextForFiltering( $page, $content, $slot );
+ if ( $filterText === null ) {
+ return null;
+ }
+ list( $oldContent, $oldAfText, $text ) = $filterText;
+ return $this->newVariableHolderForEdit(
+ $page, $summary, $content, $text, $oldAfText, $oldContent
+ );
+ }
+
+ /**
+ * Get the text of an edit to be used for filtering
+ * @todo Full support for multi-slots
+ *
+ * @param WikiPage $page
+ * @param Content $content
+ * @param string $slot
+ * @return array|null
+ */
+ protected function getEditTextForFiltering( WikiPage $page, Content $content, $slot ) : ?array {
+ $oldRevRecord = $page->getRevisionRecord();
+ if ( !$oldRevRecord ) {
+ return null;
+ }
+
+ $oldContent = $oldRevRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
+ $oldAfText = AbuseFilter::revisionToString( $oldRevRecord, $this->user );
+
+ // XXX: Recreate what the new revision will probably be so we can get the full AF
+ // text for all slots
+ $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevRecord );
+ $newRevision->setContent( $slot, $content );
+ $text = AbuseFilter::revisionToString( $newRevision, $this->user );
+
+ // Cache article object so we can share a parse operation
+ $articleCacheKey = $this->title->getNamespace() . ':' . $this->title->getText();
+ AFComputedVariable::$articleCache[$articleCacheKey] = $page;
+
+ // Don't trigger for null edits. Compare Content objects if available, but check the
+ // stringified contents as well, e.g. for line endings normalization (T240115).
+ // Don't treat content model change as null edit though.
+ if (
+ ( $oldContent && $content->equals( $oldContent ) ) ||
+ ( $oldContent->getModel() === $content->getModel() && strcmp( $oldAfText, $text ) === 0 )
+ ) {
+ return null;
+ }
+
+ return [ $oldContent, $oldAfText, $text ];
+ }
+
+ /**
+ * @param WikiPage|null $page
+ * @param string $summary
+ * @param Content $newcontent
+ * @param string $text
+ * @param string $oldtext
+ * @param Content|null $oldcontent
+ * @return AbuseFilterVariableHolder
+ * @throws MWException
+ */
+ private function newVariableHolderForEdit(
+ ?WikiPage $page,
+ string $summary,
+ Content $newcontent,
+ string $text,
+ string $oldtext,
+ Content $oldcontent = null
+ ) : AbuseFilterVariableHolder {
+ $this->addUserVars( $this->user )
+ ->addTitleVars( $this->title, 'page' );
+ $this->vars->setVar( 'action', 'edit' );
+ $this->vars->setVar( 'summary', $summary );
+ if ( $oldcontent instanceof Content ) {
+ $oldmodel = $oldcontent->getModel();
+ } else {
+ $oldmodel = '';
+ $oldtext = '';
+ }
+ $this->vars->setVar( 'old_content_model', $oldmodel );
+ $this->vars->setVar( 'new_content_model', $newcontent->getModel() );
+ $this->vars->setVar( 'old_wikitext', $oldtext );
+ $this->vars->setVar( 'new_wikitext', $text );
+ $this->addEditVars( $this->title, $page );
+
+ return $this->vars;
+ }
+
+ /**
+ * Get variables for filtering an edit.
+ *
+ * @param Content $content
+ * @param string $text
+ * @param string $summary
+ * @param string $slot
+ * @param WikiPage|null $page
+ * @return AbuseFilterVariableHolder|null
+ */
+ public function getEditVars(
+ Content $content,
+ string $text,
+ string $summary,
+ $slot,
+ WikiPage $page = null
+ ) : ?AbuseFilterVariableHolder {
+ $oldContent = null;
+
+ if ( $page !== null ) {
+ $filterText = $this->getEditTextForFiltering( $page, $content, $slot );
+ if ( $filterText === null ) {
+ return null;
+ }
+ list( $oldContent, $oldAfText, $text ) = $filterText;
+ } else {
+ $oldAfText = '';
+ }
+
+ return $this->newVariableHolderForEdit(
+ $page, $summary, $content, $text, $oldAfText, $oldContent
+ );
+ }
+
+ /**
+ * Get variables used to filter a move.
+ *
+ * @param Title $newTitle
+ * @param string $reason
+ * @return AbuseFilterVariableHolder
+ */
+ public function getMoveVars(
+ Title $newTitle,
+ string $reason
+ ) : AbuseFilterVariableHolder {
+ $this->addUserVars( $this->user )
+ ->addTitleVars( $this->title, 'MOVED_FROM' )
+ ->addTitleVars( $newTitle, 'MOVED_TO' );
+ $this->vars->setVar( 'summary', $reason );
+ $this->vars->setVar( 'action', 'move' );
+ return $this->vars;
+ }
+
+ /**
+ * Get variables for filtering a deletion.
+ *
+ * @param string $reason
+ * @return AbuseFilterVariableHolder
+ */
+ public function getDeleteVars(
+ string $reason
+ ) : AbuseFilterVariableHolder {
+ $this->addUserVars( $this->user )
+ ->addTitleVars( $this->title, 'page' );
+
+ $this->vars->setVar( 'summary', $reason );
+ $this->vars->setVar( 'action', 'delete' );
+ return $this->vars;
+ }
+
+ /**
+ * Get variables for filtering an upload.
+ *
+ * @param string $action
+ * @param UploadBase $upload
+ * @param string|null $summary
+ * @param string|null $text
+ * @param array|null $props
+ * @return AbuseFilterVariableHolder|null
+ */
+ public function getUploadVars(
+ string $action,
+ UploadBase $upload,
+ ?string $summary,
+ ?string $text,
+ ?array $props
+ ) : ?AbuseFilterVariableHolder {
+ $mimeAnalyzer = MediaWikiServices::getInstance()->getMimeAnalyzer();
+ if ( !$props ) {
+ $props = ( new MWFileProps( $mimeAnalyzer ) )->getPropsFromPath(
+ $upload->getTempPath(),
+ true
+ );
+ }
+
+ $this->addUserVars( $this->user )
+ ->addTitleVars( $this->title, 'page' );
+ $this->vars->setVar( 'action', $action );
+
+ // We use the hexadecimal version of the file sha1.
+ // Use UploadBase::getTempFileSha1Base36 so that we don't have to calculate the sha1 sum again
+ $sha1 = \Wikimedia\base_convert( $upload->getTempFileSha1Base36(), 36, 16, 40 );
+
+ // This is the same as AbuseFilterRowVariableGenerator::addUploadVars, but from a different source
+ $this->vars->setVar( 'file_sha1', $sha1 );
+ $this->vars->setVar( 'file_size', $upload->getFileSize() );
+
+ $this->vars->setVar( 'file_mime', $props['mime'] );
+ $this->vars->setVar( 'file_mediatype', $mimeAnalyzer->getMediaType( null, $props['mime'] ) );
+ $this->vars->setVar( 'file_width', $props['width'] );
+ $this->vars->setVar( 'file_height', $props['height'] );
+ $this->vars->setVar( 'file_bits_per_channel', $props['bits'] );
+
+ // We only have the upload comment and page text when using the UploadVerifyUpload hook
+ if ( $summary !== null && $text !== null ) {
+ // This block is adapted from self::getTextForFiltering()
+ if ( $this->title->exists() ) {
+ $page = WikiPage::factory( $this->title );
+ $revRec = $page->getRevisionRecord();
+ if ( !$revRec ) {
+ return null;
+ }
+
+ $oldcontent = $revRec->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
+ $oldtext = AbuseFilter::contentToString( $oldcontent );
+
+ // Cache article object so we can share a parse operation
+ $articleCacheKey = $this->title->getNamespace() . ':' . $this->title->getText();
+ AFComputedVariable::$articleCache[$articleCacheKey] = $page;
+
+ // Page text is ignored for uploads when the page already exists
+ $text = $oldtext;
+ } else {
+ $page = null;
+ $oldtext = '';
+ }
+
+ // Load vars for filters to check
+ $this->vars->setVar( 'summary', $summary );
+ $this->vars->setVar( 'old_wikitext', $oldtext );
+ $this->vars->setVar( 'new_wikitext', $text );
+ // TODO: set old_content and new_content vars, use them
+ $this->addEditVars( $this->title, $page );
+ }
+ return $this->vars;
+ }
+
+ /**
+ * Get variables for filtering an account creation
+ *
+ * @param User $createdUser This is the user being created, not the creator (which is $this->user)
+ * @param bool $autocreate
+ * @return AbuseFilterVariableHolder
+ */
+ public function getAccountCreationVars(
+ User $createdUser,
+ bool $autocreate
+ ) : AbuseFilterVariableHolder {
+ // generateUserVars records $this->user->getName() which would be the IP for unregistered users
+ if ( $this->user->isLoggedIn() ) {
+ $this->addUserVars( $this->user );
+ }
+
+ $this->vars->setVar( 'action', $autocreate ? 'autocreateaccount' : 'createaccount' );
+ $this->vars->setVar( 'accountname', $createdUser->getName() );
+ return $this->vars;
+ }
+}
diff --git a/AbuseFilter/includes/VariableGenerator/VariableGenerator.php b/AbuseFilter/includes/VariableGenerator/VariableGenerator.php
new file mode 100644
index 00000000..468d5c75
--- /dev/null
+++ b/AbuseFilter/includes/VariableGenerator/VariableGenerator.php
@@ -0,0 +1,230 @@
+<?php
+
+namespace MediaWiki\Extension\AbuseFilter\VariableGenerator;
+
+use AbuseFilterVariableHolder;
+use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
+use RecentChange;
+use Title;
+use User;
+use WikiPage;
+
+/**
+ * Class used to generate variables, for instance related to a given user or title.
+ */
+class VariableGenerator {
+ /**
+ * @var AbuseFilterVariableHolder
+ */
+ protected $vars;
+
+ /** @var AbuseFilterHookRunner */
+ private $hookRunner;
+
+ /**
+ * @param AbuseFilterVariableHolder $vars
+ */
+ public function __construct( AbuseFilterVariableHolder $vars ) {
+ $this->vars = $vars;
+
+ // TODO this class is constructed in other extensions; make this a parameter
+ $this->hookRunner = AbuseFilterHookRunner::getRunner();
+ }
+
+ /**
+ * @return AbuseFilterVariableHolder
+ */
+ public function getVariableHolder() : AbuseFilterVariableHolder {
+ return $this->vars;
+ }
+
+ /**
+ * Computes all variables unrelated to title and user. In general, these variables may be known
+ * even without an ongoing action.
+ *
+ * @param RecentChange|null $rc If the variables should be generated for an RC entry,
+ * this is the entry. Null if it's for the current action being filtered.
+ * @return $this For chaining
+ */
+ public function addGenericVars( RecentChange $rc = null ) : self {
+ // These are lazy-loaded just to reduce the amount of preset variables, but they
+ // shouldn't be expensive.
+ $this->vars->setLazyLoadVar( 'wiki_name', 'get-wiki-name', [] );
+ $this->vars->setLazyLoadVar( 'wiki_language', 'get-wiki-language', [] );
+
+ $this->hookRunner->onAbuseFilterGenerateGenericVars( $this->vars, $rc );
+ return $this;
+ }
+
+ /**
+ * @param User $user
+ * @param RecentChange|null $rc If the variables should be generated for an RC entry,
+ * this is the entry. Null if it's for the current action being filtered.
+ * @return $this For chaining
+ */
+ public function addUserVars( User $user, RecentChange $rc = null ) : self {
+ $this->vars->setLazyLoadVar(
+ 'user_editcount',
+ 'simple-user-accessor',
+ [ 'user' => $user, 'method' => 'getEditCount' ]
+ );
+
+ $this->vars->setVar( 'user_name', $user->getName() );
+
+ $this->vars->setLazyLoadVar(
+ 'user_emailconfirm',
+ 'simple-user-accessor',
+ [ 'user' => $user, 'method' => 'getEmailAuthenticationTimestamp' ]
+ );
+
+ $this->vars->setLazyLoadVar(
+ 'user_age',
+ 'user-age',
+ [ 'user' => $user, 'asof' => wfTimestampNow() ]
+ );
+
+ $this->vars->setLazyLoadVar(
+ 'user_groups',
+ 'simple-user-accessor',
+ [ 'user' => $user, 'method' => 'getEffectiveGroups' ]
+ );
+
+ $this->vars->setLazyLoadVar(
+ 'user_rights',
+ 'simple-user-accessor',
+ [ 'user' => $user, 'method' => 'getRights' ]
+ );
+
+ $this->vars->setLazyLoadVar(
+ 'user_blocked',
+ 'user-block',
+ [ 'user' => $user ]
+ );
+
+ $this->hookRunner->onAbuseFilterGenerateUserVars( $this->vars, $user, $rc );
+
+ return $this;
+ }
+
+ /**
+ * @param Title $title
+ * @param string $prefix
+ * @param RecentChange|null $rc If the variables should be generated for an RC entry,
+ * this is the entry. Null if it's for the current action being filtered.
+ * @return $this For chaining
+ */
+ public function addTitleVars(
+ Title $title,
+ string $prefix,
+ RecentChange $rc = null
+ ) : self {
+ $this->vars->setVar( $prefix . '_id', $title->getArticleID() );
+ $this->vars->setVar( $prefix . '_namespace', $title->getNamespace() );
+ $this->vars->setVar( $prefix . '_title', $title->getText() );
+ $this->vars->setVar( $prefix . '_prefixedtitle', $title->getPrefixedText() );
+
+ // We only support the default values in $wgRestrictionTypes. Custom restrictions wouldn't
+ // have i18n messages. If a restriction is not enabled we'll just return the empty array.
+ $types = [ 'edit', 'move', 'create', 'upload' ];
+ foreach ( $types as $action ) {
+ $this->vars->setLazyLoadVar( "{$prefix}_restrictions_$action", 'get-page-restrictions',
+ [ 'title' => $title->getText(),
+ 'namespace' => $title->getNamespace(),
+ 'action' => $action
+ ]
+ );
+ }
+
+ $this->vars->setLazyLoadVar( "{$prefix}_recent_contributors", 'load-recent-authors',
+ [
+ 'title' => $title->getText(),
+ 'namespace' => $title->getNamespace()
+ ] );
+
+ $this->vars->setLazyLoadVar( "{$prefix}_age", 'page-age',
+ [
+ 'title' => $title->getText(),
+ 'namespace' => $title->getNamespace(),
+ 'asof' => wfTimestampNow()
+ ] );
+
+ $this->vars->setLazyLoadVar( "{$prefix}_first_contributor", 'load-first-author',
+ [
+ 'title' => $title->getText(),
+ 'namespace' => $title->getNamespace()
+ ] );
+
+ $this->hookRunner->onAbuseFilterGenerateTitleVars( $this->vars, $title, $prefix, $rc );
+
+ return $this;
+ }
+
+ /**
+ * @param Title $title
+ * @param WikiPage|null $page
+ * @return $this For chaining
+ */
+ public function addEditVars( Title $title, WikiPage $page = null ) : self {
+ // NOTE: $page may end up remaining null, e.g. if $title points to a special page.
+ if ( !$page && $title->canExist() ) {
+ // TODO: The caller should do this!
+ $page = WikiPage::factory( $title );
+ }
+
+ $this->vars->setLazyLoadVar( 'edit_diff', 'diff-array',
+ [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_wikitext' ] );
+ $this->vars->setLazyLoadVar( 'edit_diff_pst', 'diff-array',
+ [ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_pst' ] );
+ $this->vars->setLazyLoadVar( 'new_size', 'length', [ 'length-var' => 'new_wikitext' ] );
+ $this->vars->setLazyLoadVar( 'old_size', 'length', [ 'length-var' => 'old_wikitext' ] );
+ $this->vars->setLazyLoadVar( 'edit_delta', 'subtract-int',
+ [ 'val1-var' => 'new_size', 'val2-var' => 'old_size' ] );
+
+ // Some more specific/useful details about the changes.
+ $this->vars->setLazyLoadVar( 'added_lines', 'diff-split',
+ [ 'diff-var' => 'edit_diff', 'line-prefix' => '+' ] );
+ $this->vars->setLazyLoadVar( 'removed_lines', 'diff-split',
+ [ 'diff-var' => 'edit_diff', 'line-prefix' => '-' ] );
+ $this->vars->setLazyLoadVar( 'added_lines_pst', 'diff-split',
+ [ 'diff-var' => 'edit_diff_pst', 'line-prefix' => '+' ] );
+
+ // Links
+ $this->vars->setLazyLoadVar( 'added_links', 'link-diff-added',
+ [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] );
+ $this->vars->setLazyLoadVar( 'removed_links', 'link-diff-removed',
+ [ 'oldlink-var' => 'old_links', 'newlink-var' => 'all_links' ] );
+ $this->vars->setLazyLoadVar( 'new_text', 'strip-html',
+ [ 'html-var' => 'new_html' ] );
+
+ $this->vars->setLazyLoadVar( 'all_links', 'links-from-wikitext',
+ [
+ 'namespace' => $title->getNamespace(),
+ 'title' => $title->getText(),
+ 'text-var' => 'new_wikitext',
+ 'article' => $page
+ ] );
+ $this->vars->setLazyLoadVar( 'old_links', 'links-from-wikitext-or-database',
+ [
+ 'namespace' => $title->getNamespace(),
+ 'title' => $title->getText(),
+ 'text-var' => 'old_wikitext'
+ ] );
+ $this->vars->setLazyLoadVar( 'new_pst', 'parse-wikitext',
+ [
+ 'namespace' => $title->getNamespace(),
+ 'title' => $title->getText(),
+ 'wikitext-var' => 'new_wikitext',
+ 'article' => $page,
+ 'pst' => true,
+ ] );
+ $this->vars->setLazyLoadVar( 'new_html', 'parse-wikitext',
+ [
+ 'namespace' => $title->getNamespace(),
+ 'title' => $title->getText(),
+ 'wikitext-var' => 'new_wikitext',
+ 'article' => $page
+ ] );
+
+ return $this;
+ }
+}
diff --git a/AbuseFilter/includes/Views/AbuseFilterView.php b/AbuseFilter/includes/Views/AbuseFilterView.php
index 9b019335..a8cbcadb 100644
--- a/AbuseFilter/includes/Views/AbuseFilterView.php
+++ b/AbuseFilter/includes/Views/AbuseFilterView.php
@@ -1,9 +1,29 @@
<?php
+use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use Wikimedia\Rdbms\IDatabase;
abstract class AbuseFilterView extends ContextSource {
- public $mFilter, $mHistoryID, $mSubmit, $mPage, $mParams;
+ /**
+ * @var string The ID of the current filter
+ */
+ public $mFilter;
+ /**
+ * @var int|null The history ID of the current filter
+ */
+ public $mHistoryID;
+ /**
+ * @var bool Whether the form was submitted
+ */
+ public $mSubmit;
+ /**
+ * @var SpecialAbuseFilter The related special page object
+ */
+ public $mPage;
+ /**
+ * @var array The parameters of the current request
+ */
+ public $mParams;
/**
* @var \MediaWiki\Linker\LinkRenderer
@@ -14,7 +34,7 @@ abstract class AbuseFilterView extends ContextSource {
* @param SpecialAbuseFilter $page
* @param array $params
*/
- public function __construct( $page, $params ) {
+ public function __construct( SpecialAbuseFilter $page, $params ) {
$this->mPage = $page;
$this->mParams = $params;
$this->setContext( $this->mPage->getContext() );
@@ -22,7 +42,7 @@ abstract class AbuseFilterView extends ContextSource {
}
/**
- * @param string $subpage
+ * @param string|int $subpage
* @return Title
*/
public function getTitle( $subpage = '' ) {
@@ -35,74 +55,45 @@ abstract class AbuseFilterView extends ContextSource {
abstract public function show();
/**
- * @return bool
- */
- public function canEdit() {
- return (
- !$this->getUser()->isBlocked() &&
- $this->getUser()->isAllowed( 'abusefilter-modify' )
- );
- }
-
- /**
- * @return bool
- */
- public function canEditGlobal() {
- return $this->getUser()->isAllowed( 'abusefilter-modify-global' );
- }
-
- /**
- * Whether the user can edit the given filter.
- *
- * @param object $row Filter row
- *
- * @return bool
- */
- public function canEditFilter( $row ) {
- return (
- $this->canEdit() &&
- !( isset( $row->af_global ) && $row->af_global == 1 && !$this->canEditGlobal() )
- );
- }
-
- /**
* @param string $rules
- * @param string $textName
* @param bool $addResultDiv
* @param bool $externalForm
* @param bool $needsModifyRights
* @param-taint $rules none
* @return string
*/
- public function buildEditBox(
+ protected function buildEditBox(
$rules,
- $textName = 'wpFilterRules',
$addResultDiv = true,
$externalForm = false,
$needsModifyRights = true
) {
$this->getOutput()->enableOOUI();
+ $user = $this->getUser();
// Rules are in English
- $editorAttrib = [ 'dir' => 'ltr' ];
+ $editorAttribs = [ 'dir' => 'ltr' ];
$noTestAttrib = [];
$isUserAllowed = $needsModifyRights ?
- $this->getUser()->isAllowed( 'abusefilter-modify' ) :
- $this->canViewPrivate();
+ AbuseFilter::canEdit( $user ) :
+ AbuseFilter::canViewPrivate( $user );
if ( !$isUserAllowed ) {
$noTestAttrib['disabled'] = 'disabled';
$addResultDiv = false;
}
$rules = rtrim( $rules ) . "\n";
- $canEdit = $needsModifyRights ? $this->canEdit() : $this->canViewPrivate();
$switchEditor = null;
+ $rulesContainer = '';
if ( ExtensionRegistry::getInstance()->isLoaded( 'CodeEditor' ) ) {
- $editorAttrib['name'] = 'wpAceFilterEditor';
- $editorAttrib['id'] = 'wpAceFilterEditor';
- $editorAttrib['class'] = 'mw-abusefilter-editor';
+ $aceAttribs = [
+ 'name' => 'wpAceFilterEditor',
+ 'id' => 'wpAceFilterEditor',
+ 'class' => 'mw-abusefilter-editor'
+ ];
+ $attribs = array_merge( $editorAttribs, $aceAttribs );
$switchEditor =
new OOUI\ButtonWidget(
@@ -112,35 +103,27 @@ abstract class AbuseFilterView extends ContextSource {
] + $noTestAttrib
);
- $rulesContainer = Xml::element( 'div', $editorAttrib, $rules );
-
- // Dummy textarea for submitting form and to use in case JS is disabled
- $textareaAttribs = [];
- if ( !$canEdit ) {
- $textareaAttribs['readonly'] = 'readonly';
- }
- if ( $externalForm ) {
- $textareaAttribs['form'] = 'wpFilterForm';
- }
- $rulesContainer .= Xml::textarea( $textName, $rules, 40, 15, $textareaAttribs );
-
- $editorConfig = AbuseFilter::getAceConfig( $canEdit );
+ $rulesContainer .= Xml::element( 'div', $attribs, $rules );
// Add Ace configuration variable
+ $editorConfig = AbuseFilter::getAceConfig( $isUserAllowed );
$this->getOutput()->addJsConfigVars( 'aceConfig', $editorConfig );
- } else {
- if ( !$canEdit ) {
- $editorAttrib['readonly'] = 'readonly';
- }
- if ( $externalForm ) {
- $editorAttrib['form'] = 'wpFilterForm';
- }
- $rulesContainer = Xml::textarea( $textName, $rules, 40, 15, $editorAttrib );
}
- if ( $canEdit ) {
+ // Build a dummy textarea to be used: for submitting form if CodeEditor isn't installed,
+ // and in case JS is disabled (with or without CodeEditor)
+ if ( !$isUserAllowed ) {
+ $editorAttribs['readonly'] = 'readonly';
+ }
+ if ( $externalForm ) {
+ $editorAttribs['form'] = 'wpFilterForm';
+ }
+ $rulesContainer .= Xml::textarea( 'wpFilterRules', $rules, 40, 15, $editorAttribs );
+
+ if ( $isUserAllowed ) {
+ $keywordsManager = AbuseFilterServices::getKeywordsManager();
// Generate builder drop-down
- $rawDropDown = AbuseFilter::getBuilderValues();
+ $rawDropDown = $keywordsManager->getBuilderValues();
// The array needs to be rearranged to be understood by OOUI. It comes with the format
// [ group-msg-key => [ text-to-add => text-msg-key ] ] and we need it as
@@ -211,14 +194,52 @@ abstract class AbuseFilterView extends ContextSource {
// Add script
$this->getOutput()->addModules( 'ext.abuseFilter.edit' );
- AbuseFilter::$editboxName = $textName;
return $rulesContainer;
}
/**
+ * Build input and button for loading a filter
+ *
+ * @return string
+ */
+ public function buildFilterLoader() {
+ $loadText =
+ new OOUI\TextInputWidget(
+ [
+ 'type' => 'number',
+ 'name' => 'wpInsertFilter',
+ 'id' => 'mw-abusefilter-load-filter'
+ ]
+ );
+ $loadButton =
+ new OOUI\ButtonWidget(
+ [
+ 'label' => $this->msg( 'abusefilter-test-load' )->text(),
+ 'id' => 'mw-abusefilter-load'
+ ]
+ );
+ $loadGroup =
+ new OOUI\ActionFieldLayout(
+ $loadText,
+ $loadButton,
+ [
+ 'label' => $this->msg( 'abusefilter-test-load-filter' )->text()
+ ]
+ );
+ // CSS class for reducing default input field width
+ $loadDiv =
+ Xml::tags(
+ 'div',
+ [ 'class' => 'mw-abusefilter-load-filter-id' ],
+ $loadGroup
+ );
+ return $loadDiv;
+ }
+
+ /**
* @param IDatabase $db
- * @param string|bool $action 'edit', 'move', 'createaccount', 'delete' or false for all
+ * @param string|false $action 'edit', 'move', 'createaccount', 'delete' or false for all
* @return string
*/
public function buildTestConditions( IDatabase $db, $action = false ) {
@@ -250,7 +271,18 @@ abstract class AbuseFilterView extends ContextSource {
'rc_log_type' => 'delete',
'rc_log_action' => 'delete'
], LIST_AND );
- // @ToDo: case 'upload'
+ case 'upload':
+ return $db->makeList( [
+ 'rc_source' => RecentChange::SRC_LOG,
+ 'rc_log_type' => 'upload',
+ 'rc_log_action' => [ 'upload', 'overwrite', 'revert' ]
+ ], LIST_AND );
+ case false:
+ // Done later
+ break;
+ default:
+ // @phan-suppress-next-line PhanTypeSuspiciousStringExpression False does not reach here
+ throw new MWException( __METHOD__ . ' called with invalid action: ' . $action );
}
return $db->makeList( [
@@ -273,7 +305,10 @@ abstract class AbuseFilterView extends ContextSource {
'rc_log_type' => 'delete',
'rc_log_action' => 'delete'
], LIST_AND ),
- // @todo: add upload
+ $db->makeList( [
+ 'rc_log_type' => 'upload',
+ 'rc_log_action' => [ 'upload', 'overwrite', 'revert' ]
+ ], LIST_AND ),
], LIST_OR ),
], LIST_AND ),
], LIST_OR );
@@ -290,19 +325,4 @@ abstract class AbuseFilterView extends ContextSource {
$text
);
}
-
- /**
- * @return bool
- */
- public static function canViewPrivate() {
- global $wgUser;
- static $canView = null;
-
- if ( is_null( $canView ) ) {
- $canView = $wgUser->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' );
- }
-
- return $canView;
- }
-
}
diff --git a/AbuseFilter/includes/Views/AbuseFilterViewDiff.php b/AbuseFilter/includes/Views/AbuseFilterViewDiff.php
index 8128ad65..11699a50 100644
--- a/AbuseFilter/includes/Views/AbuseFilterViewDiff.php
+++ b/AbuseFilter/includes/Views/AbuseFilterViewDiff.php
@@ -1,9 +1,23 @@
<?php
-
+/**
+ * @phan-file-suppress PhanTypeArraySuspiciousNullable Some confusion with class members
+ */
class AbuseFilterViewDiff extends AbuseFilterView {
+ /**
+ * @var (string|array)[]|null The old version of the filter
+ */
public $mOldVersion = null;
+ /**
+ * @var (string|array)[]|null The new version of the filter
+ */
public $mNewVersion = null;
+ /**
+ * @var int|null The history ID of the next version, if any
+ */
public $mNextHistoryId = null;
+ /**
+ * @var int|null The ID of the filter
+ */
public $mFilter = null;
/**
@@ -55,7 +69,7 @@ class AbuseFilterViewDiff extends AbuseFilterView {
] );
}
- if ( !is_null( $this->mNextHistoryId ) ) {
+ if ( $this->mNextHistoryId !== null ) {
// Create a "next change" link if this isn't the last change of the given filter
$href = $this->getTitle(
'history/' . $this->mFilter . '/diff/prev/' . $this->mNextHistoryId
@@ -85,21 +99,29 @@ class AbuseFilterViewDiff extends AbuseFilterView {
$newSpec = $this->mParams[4];
$this->mFilter = $this->mParams[1];
- if ( AbuseFilter::filterHidden( $this->mFilter )
- && !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' )
- ) {
- $this->getOutput()->addWikiMsg( 'abusefilter-history-error-hidden' );
+ if ( !is_numeric( $this->mFilter ) ) {
+ $this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' );
return false;
}
$this->mOldVersion = $this->loadSpec( $oldSpec, $newSpec );
$this->mNewVersion = $this->loadSpec( $newSpec, $oldSpec );
- if ( is_null( $this->mOldVersion ) || is_null( $this->mNewVersion ) ) {
+ if ( $this->mOldVersion === null || $this->mNewVersion === null ) {
$this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' );
return false;
}
+ if ( !AbuseFilter::canViewPrivate( $this->getUser() ) &&
+ (
+ in_array( 'hidden', explode( ',', $this->mOldVersion['info']['flags'] ) ) ||
+ in_array( 'hidden', explode( ',', $this->mNewVersion['info']['flags'] ) )
+ )
+ ) {
+ $this->getOutput()->addWikiMsg( 'abusefilter-history-error-hidden' );
+ return false;
+ }
+
$this->mNextHistoryId = $this->getNextHistoryId(
$this->mNewVersion['meta']['history_id']
);
@@ -134,7 +156,7 @@ class AbuseFilterViewDiff extends AbuseFilterView {
/**
* @param string $spec
* @param string $otherSpec
- * @return array|null
+ * @return (string|array)[]|null
*/
public function loadSpec( $spec, $otherSpec ) {
static $dependentSpecs = [ 'prev', 'next' ];
@@ -166,7 +188,7 @@ class AbuseFilterViewDiff extends AbuseFilterView {
[ 'afh_id' => $spec, 'afh_filter' => $this->mFilter ],
__METHOD__
);
- } elseif ( $spec == 'cur' ) {
+ } elseif ( $spec === 'cur' ) {
$row = $dbr->selectRow(
'abuse_filter_history',
$selectFields,
@@ -174,39 +196,23 @@ class AbuseFilterViewDiff extends AbuseFilterView {
__METHOD__,
[ 'ORDER BY' => 'afh_timestamp desc' ]
);
- } elseif ( $spec == 'prev' && !in_array( $otherSpec, $dependentSpecs ) ) {
- // cached
- $other = $this->loadSpec( $otherSpec, $spec );
-
- $row = $dbr->selectRow(
- 'abuse_filter_history',
- $selectFields,
- [
- 'afh_filter' => $this->mFilter,
- 'afh_id<' . $dbr->addQuotes( $other['meta']['history_id'] ),
- ],
- __METHOD__,
- [ 'ORDER BY' => 'afh_timestamp desc' ]
- );
- if ( $other && !$row ) {
- $t = $this->getTitle(
- 'history/' . $this->mFilter . '/item/' . $other['meta']['history_id'] );
- $this->getOutput()->redirect( $t->getFullURL() );
- return null;
- }
- } elseif ( $spec == 'next' && !in_array( $otherSpec, $dependentSpecs ) ) {
+ } elseif ( ( $spec === 'prev' || $spec === 'next' ) &&
+ !in_array( $otherSpec, $dependentSpecs )
+ ) {
// cached
$other = $this->loadSpec( $otherSpec, $spec );
+ $comparison = $spec === 'prev' ? '<' : '>';
+ $order = $spec === 'prev' ? 'DESC' : 'ASC';
$row = $dbr->selectRow(
'abuse_filter_history',
$selectFields,
[
'afh_filter' => $this->mFilter,
- 'afh_id>' . $dbr->addQuotes( $other['meta']['history_id'] ),
+ "afh_id $comparison" . $dbr->addQuotes( $other['meta']['history_id'] ),
],
__METHOD__,
- [ 'ORDER BY' => 'afh_timestamp ASC' ]
+ [ 'ORDER BY' => "afh_timestamp $order" ]
);
if ( $other && !$row ) {
@@ -228,7 +234,7 @@ class AbuseFilterViewDiff extends AbuseFilterView {
/**
* @param stdClass $row
- * @return array
+ * @return (string|array)[]
*/
public function loadFromHistoryRow( $row ) {
return [
@@ -319,7 +325,7 @@ class AbuseFilterViewDiff extends AbuseFilterView {
);
if (
count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ||
- $oldVersion['info']['group'] != $newVersion['info']['group']
+ $oldVersion['info']['group'] !== $newVersion['info']['group']
) {
$info .= $this->getDiffRow(
'abusefilter-edit-group',
@@ -329,8 +335,8 @@ class AbuseFilterViewDiff extends AbuseFilterView {
}
$info .= $this->getDiffRow(
'abusefilter-edit-flags',
- AbuseFilter::formatFlags( $oldVersion['info']['flags'] ),
- AbuseFilter::formatFlags( $newVersion['info']['flags'] )
+ AbuseFilter::formatFlags( $oldVersion['info']['flags'], $this->getLanguage() ),
+ AbuseFilter::formatFlags( $newVersion['info']['flags'], $this->getLanguage() )
);
$info .= $this->getDiffRow(
@@ -343,7 +349,6 @@ class AbuseFilterViewDiff extends AbuseFilterView {
$body .= $infoHeader . $info;
}
- // Pattern
$patternHeader = $this->getHeaderRow( 'abusefilter-diff-pattern' );
$pattern = '';
$pattern .= $this->getDiffRow(
@@ -356,7 +361,6 @@ class AbuseFilterViewDiff extends AbuseFilterView {
$body .= $patternHeader . $pattern;
}
- // Actions
$actionsHeader = $this->getHeaderRow( 'abusefilter-edit-consequences' );
$actions = '';
@@ -392,7 +396,7 @@ class AbuseFilterViewDiff extends AbuseFilterView {
ksort( $actions );
foreach ( $actions as $action => $parameters ) {
- $lines[] = AbuseFilter::formatAction( $action, $parameters );
+ $lines[] = AbuseFilter::formatAction( $action, $parameters, $this->getLanguage() );
}
if ( !count( $lines ) ) {
diff --git a/AbuseFilter/includes/Views/AbuseFilterViewEdit.php b/AbuseFilter/includes/Views/AbuseFilterViewEdit.php
index e4c5a93e..d395564d 100644
--- a/AbuseFilter/includes/Views/AbuseFilterViewEdit.php
+++ b/AbuseFilter/includes/Views/AbuseFilterViewEdit.php
@@ -1,12 +1,13 @@
<?php
+use MediaWiki\MediaWikiServices;
+
class AbuseFilterViewEdit extends AbuseFilterView {
- public static $mLoadedRow = null, $mLoadedActions = null;
/**
* @param SpecialAbuseFilter $page
* @param array $params
*/
- public function __construct( $page, $params ) {
+ public function __construct( SpecialAbuseFilter $page, array $params ) {
parent::__construct( $page, $params );
$this->mFilter = $page->mFilter;
$this->mHistoryID = $page->mHistoryID;
@@ -19,25 +20,34 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$user = $this->getUser();
$out = $this->getOutput();
$request = $this->getRequest();
- $config = $this->getConfig();
$out->setPageTitle( $this->msg( 'abusefilter-edit' ) );
$out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
$filter = $this->mFilter;
+ if ( !is_numeric( $filter ) && $filter !== 'new' ) {
+ $out->addHTML(
+ Xml::tags(
+ 'p',
+ null,
+ Html::errorBox( $this->msg( 'abusefilter-edit-badfilter' )->parse() )
+ )
+ );
+ return;
+ }
$history_id = $this->mHistoryID;
if ( $this->mHistoryID ) {
$dbr = wfGetDB( DB_REPLICA );
- $row = $dbr->selectRow(
+ $lastID = (int)$dbr->selectField(
'abuse_filter_history',
'afh_id',
[
'afh_filter' => $filter,
],
__METHOD__,
- [ 'ORDER BY' => 'afh_timestamp DESC' ]
+ [ 'ORDER BY' => 'afh_id DESC' ]
);
// change $history_id to null if it's current version id
- if ( $row->afh_id === $this->mHistoryID ) {
+ if ( $lastID === $this->mHistoryID ) {
$history_id = null;
}
}
@@ -45,7 +55,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
// Add the default warning and disallow messages in a JS variable
$this->exposeMessages();
- if ( $filter == 'new' && !$this->canEdit() ) {
+ $canEdit = AbuseFilter::canEdit( $user );
+
+ if ( $filter === 'new' && !$canEdit ) {
$out->addHTML(
Xml::tags(
'p',
@@ -59,86 +71,115 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$editToken = $request->getVal( 'wpEditToken' );
$tokenMatches = $user->matchEditToken(
$editToken, [ 'abusefilter', $filter ], $request );
+ $isImport = $request->getRawVal( 'wpImportText' ) !== null;
+
+ if ( $request->wasPosted() && $canEdit && $tokenMatches ) {
+ $this->saveCurrentFilter( $filter, $history_id );
+ return;
+ }
- if ( $tokenMatches && $this->canEdit() ) {
- list( $newRow, $actions ) = $this->loadRequest( $filter );
- $status = AbuseFilter::saveFilter( $this, $filter, $request, $newRow, $actions );
+ if ( $request->wasPosted() && !$isImport && !$tokenMatches ) {
+ // Special case for when the token has expired with the page open, warn to retry
+ $out->addHTML(
+ Html::warningBox( $this->msg( 'abusefilter-edit-token-not-match' )->escaped() )
+ );
+ }
+
+ if ( $isImport || ( $request->wasPosted() && !$tokenMatches ) ) {
+ // Make sure to load from HTTP if the token doesn't match!
+ $status = $this->loadRequest( $filter );
if ( !$status->isGood() ) {
- $err = $status->getErrors();
- $msg = $err[0]['message'];
- $params = $err[0]['params'];
- if ( $status->isOK() ) {
- $out->addHTML(
- $this->buildFilterEditor(
- $this->msg( $msg, $params )->parseAsBlock(),
- $filter,
- $history_id
- )
- );
- } else {
- $out->addWikiMsg( $msg );
- }
- } else {
- if ( $status->getValue() === false ) {
- // No change
- $out->redirect( $this->getTitle()->getLocalURL() );
- } else {
- list( $new_id, $history_id ) = $status->getValue();
- $out->redirect(
- $this->getTitle()->getLocalURL(
- [
- 'result' => 'success',
- 'changedfilter' => $new_id,
- 'changeid' => $history_id,
- ]
- )
- );
- }
- }
- } else {
- if ( $tokenMatches ) {
- // Lost rights meanwhile
$out->addHTML(
Xml::tags(
'p',
null,
- Html::errorBox( $this->msg( 'abusefilter-edit-notallowed' )->parse() )
+ Html::errorBox( $status->getMessage()->parse() )
)
);
- } elseif ( $request->wasPosted() ) {
- // Warn the user to re-attempt save
- $out->addHTML(
- Html::warningBox( $this->msg( 'abusefilter-edit-token-not-match' )->escaped() )
- );
+ return;
}
+ $data = $status->getValue();
+ } else {
+ $data = $this->loadFromDatabase( $filter, $history_id );
+ }
- if ( $history_id ) {
- $out->addWikiMsg(
- 'abusefilter-edit-oldwarning', $history_id, $filter );
- }
+ list( $row, $actions ) = $data ?? [ null, [] ];
- $out->addHTML( $this->buildFilterEditor( null, $filter, $history_id ) );
+ // Either the user is just viewing the filter, they cannot edit it, they lost the
+ // abusefilter-modify right with the page open, the token is invalid, or they're viewing
+ // the result of importing a filter
+ $this->buildFilterEditor( null, $row, $actions, $filter, $history_id );
+ }
- if ( $history_id ) {
- $out->addWikiMsg(
- 'abusefilter-edit-oldwarning', $history_id, $filter );
+ /**
+ * @param int|string $filter The filter ID or 'new'.
+ * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null
+ */
+ private function saveCurrentFilter( $filter, $history_id ) : void {
+ $out = $this->getOutput();
+ $reqStatus = $this->loadRequest( $filter );
+ if ( !$reqStatus->isGood() ) {
+ // In the current implementation, this cannot happen.
+ throw new LogicException( 'Should always be able to retrieve data for saving' );
+ }
+ list( $newRow, $actions ) = $reqStatus->getValue();
+ $dbw = wfGetDB( DB_MASTER );
+ $status = AbuseFilter::saveFilter( $this, $filter, $newRow, $actions, $dbw );
+
+ if ( !$status->isGood() ) {
+ $err = $status->getErrors();
+ $msg = $err[0]['message'];
+ $params = $err[0]['params'];
+ if ( $status->isOK() ) {
+ // Fixable error, show the editing interface
+ $this->buildFilterEditor(
+ $this->msg( $msg, $params )->parseAsBlock(),
+ $newRow,
+ $actions,
+ $filter,
+ $history_id
+ );
+ } else {
+ // Permission-related error
+ $out->addWikiMsg( $msg );
}
+ } elseif ( $status->getValue() === false ) {
+ // No change
+ $out->redirect( $this->getTitle()->getLocalURL() );
+ } else {
+ // Everything went fine!
+ list( $new_id, $history_id ) = $status->getValue();
+ $out->redirect(
+ $this->getTitle()->getLocalURL(
+ [
+ 'result' => 'success',
+ 'changedfilter' => $new_id,
+ 'changeid' => $history_id,
+ ]
+ )
+ );
}
}
/**
- * Builds the full form for edit filters.
- * Loads data either from the database or from the HTTP request.
- * The request takes precedence over the database
+ * Builds the full form for edit filters, adding it to the OutputPage. $row and $actions can be
+ * passed in (for instance if there was a failure during save) to avoid searching the DB.
+ *
* @param string|null $error An error message to show above the filter box.
- * @param int $filter The filter ID
+ * @param stdClass|null $row abuse_filter row representing this filter, null if it doesn't exist
+ * @param array $actions Actions enabled and their parameters
+ * @param int|string $filter The filter ID or 'new'.
* @param int|null $history_id The history ID of the filter, if applicable. Otherwise null
- * @return bool|string False if there is a failure building the editor,
- * otherwise the HTML text for the editor.
*/
- public function buildFilterEditor( $error, $filter, $history_id = null ) {
+ protected function buildFilterEditor(
+ $error,
+ ?stdClass $row,
+ array $actions,
+ $filter,
+ $history_id
+ ) {
if ( $filter === null ) {
- return false;
+ return;
}
// Build the edit form
@@ -148,10 +189,11 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$lang = $this->getLanguage();
$user = $this->getUser();
- // Load from request OR database.
- list( $row, $actions ) = $this->loadRequest( $filter, $history_id );
-
- if ( !$row ) {
+ if (
+ $row === null ||
+ // @fixme Temporary stopgap for T237887
+ ( $history_id && $row->af_id !== $filter )
+ ) {
$out->addHTML(
Xml::tags(
'p',
@@ -165,44 +207,53 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'href' => $href
] );
$out->addHTML( $btn );
- return false;
+ return;
}
$out->addSubtitle( $this->msg(
$filter === 'new' ? 'abusefilter-edit-subtitle-new' : 'abusefilter-edit-subtitle',
- $this->getLanguage()->formatNum( $filter ), $history_id
+ $filter === 'new' ? $filter : $this->getLanguage()->formatNum( $filter ),
+ $history_id
)->parse() );
// Hide hidden filters.
- if ( ( ( isset( $row->af_hidden ) && $row->af_hidden ) ||
- AbuseFilter::filterHidden( $filter ) )
- && !$this->canViewPrivate() ) {
- return $this->msg( 'abusefilter-edit-denied' )->escaped();
+ if (
+ ( $row->af_hidden || ( $filter !== 'new' && AbuseFilter::filterHidden( $filter ) ) ) &&
+ !AbuseFilter::canViewPrivate( $user )
+ ) {
+ $out->addHTML( $this->msg( 'abusefilter-edit-denied' )->escaped() );
+ return;
}
- $output = '';
- if ( $error ) {
- $output .= Html::errorBox( $error );
+ if ( $history_id ) {
+ $oldWarningMessage = AbuseFilter::canEditFilter( $user, $row )
+ ? 'abusefilter-edit-oldwarning'
+ : 'abusefilter-edit-oldwarning-view';
+ $out->addWikiMsg(
+ $oldWarningMessage,
+ $history_id,
+ $filter
+ );
}
- // Read-only attribute
- $readOnlyAttrib = [];
-
- if ( !$this->canEditFilter( $row ) ) {
- $readOnlyAttrib['disabled'] = 'disabled';
+ if ( $error ) {
+ $out->addHTML( Html::errorBox( $error ) );
}
+ $readOnly = !AbuseFilter::canEditFilter( $user, $row );
+
$fields = [];
$fields['abusefilter-edit-id'] =
- $this->mFilter == 'new' ?
+ $this->mFilter === 'new' ?
$this->msg( 'abusefilter-edit-new' )->escaped() :
$lang->formatNum( $filter );
$fields['abusefilter-edit-description'] =
new OOUI\TextInputWidget( [
'name' => 'wpFilterDescription',
- 'value' => $row->af_public_comments ?? ''
- ] + $readOnlyAttrib
+ 'value' => $row->af_public_comments ?? '',
+ 'readOnly' => $readOnly
+ ]
);
$validGroups = $this->getConfig()->get( 'AbuseFilterValidGroups' );
@@ -212,7 +263,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'name' => 'wpFilterGroup',
'id' => 'mw-abusefilter-edit-group-input',
'value' => 'default',
- 'disabled' => !empty( $readOnlyAttrib )
+ 'disabled' => $readOnly
] );
$options = [];
@@ -231,7 +282,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
}
// Hit count display
- if ( !empty( $row->af_hit_count ) && $user->isAllowed( 'abusefilter-log-detail' ) ) {
+ if ( !empty( $row->af_hit_count ) && SpecialAbuseLog::canSeeDetails( $user ) ) {
$count_display = $this->msg( 'abusefilter-hitcount' )
->numParams( (int)$row->af_hit_count )->text();
$hitCount = $this->linkRenderer->makeKnownLink(
@@ -244,39 +295,31 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$fields['abusefilter-edit-hitcount'] = $hitCount;
}
- if ( $filter !== 'new' ) {
+ if ( $filter !== 'new' && $row->af_enabled ) {
// Statistics
- $stash = ObjectCache::getMainStashInstance();
- $matches_count = (int)$stash->get( AbuseFilter::filterMatchesKey( $filter ) );
- $total = (int)$stash->get( AbuseFilter::filterUsedKey( $row->af_group ) );
-
- if ( $total > 0 ) {
- $matches_percent = sprintf( '%.2f', 100 * $matches_count / $total );
- if ( $this->getConfig()->get( 'AbuseFilterProfile' ) ) {
- list( $timeProfile, $condProfile ) = AbuseFilter::getFilterProfile( $filter );
- $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status-profile' )
- ->numParams( $total, $matches_count, $matches_percent, $timeProfile, $condProfile )
- ->escaped();
- } else {
- $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status' )
- ->numParams( $total, $matches_count, $matches_percent )
- ->parse();
- }
+ list( $totalCount, $matchesCount, $avgTime, $avgCond ) =
+ AbuseFilter::getFilterProfile( $filter );
+
+ if ( $totalCount > 0 ) {
+ $matchesPercent = round( 100 * $matchesCount / $totalCount, 2 );
+ $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status' )
+ ->numParams( $totalCount, $matchesCount, $matchesPercent, $avgTime, $avgCond )
+ ->parse();
}
}
$fields['abusefilter-edit-rules'] = $this->buildEditBox(
$row->af_pattern,
- 'wpFilterRules',
true
);
$fields['abusefilter-edit-notes'] =
new OOUI\MultilineTextInputWidget( [
'name' => 'wpFilterNotes',
'value' => isset( $row->af_comments ) ? $row->af_comments . "\n" : "\n",
- 'rows' => 15
- ] + $readOnlyAttrib
- );
+ 'rows' => 15,
+ 'readOnly' => $readOnly,
+ 'id' => 'mw-abusefilter-notes-editor'
+ ] );
// Build checkboxes
$checkboxes = [ 'hidden', 'enabled', 'deleted' ];
@@ -301,12 +344,10 @@ class AbuseFilterViewEdit extends AbuseFilterView {
array_keys( $throttledActions )
);
- $flags .= $out->parse(
- Html::warningBox(
- $this->msg( 'abusefilter-edit-throttled-warning' )
- ->plaintextParams( $lang->commaList( $throttledActions ) )
- ->text()
- )
+ $flags .= Html::warningBox(
+ $this->msg( 'abusefilter-edit-throttled-warning' )
+ ->plaintextParams( $lang->commaList( $throttledActions ) )
+ ->parseAsBlock()
);
}
}
@@ -325,25 +366,26 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'name' => $postVar,
'id' => $postVar,
'selected' => $row->$dbField ?? false,
- ] + $readOnlyAttrib;
+ 'disabled' => $readOnly
+ ];
$labelAttribs = [
'label' => $this->msg( $message )->text(),
'align' => 'inline',
];
- if ( $checkboxId == 'global' && !$this->canEditGlobal() ) {
+ if ( $checkboxId === 'global' && !AbuseFilter::canEditGlobal( $user ) ) {
$checkboxAttribs['disabled'] = 'disabled';
}
// Set readonly on deleted if the filter isn't disabled
- if ( $checkboxId == 'deleted' && $row->af_enabled == 1 ) {
+ if ( $checkboxId === 'deleted' && $row->af_enabled == 1 ) {
$checkboxAttribs['disabled'] = 'disabled';
}
// Add infusable where needed
- if ( $checkboxId == 'deleted' || $checkboxId == 'enabled' ) {
+ if ( $checkboxId === 'deleted' || $checkboxId === 'enabled' ) {
$checkboxAttribs['infusable'] = true;
- if ( $checkboxId == 'deleted' ) {
+ if ( $checkboxId === 'deleted' ) {
$labelAttribs['id'] = $postVar . 'Label';
$labelAttribs['infusable'] = true;
}
@@ -358,10 +400,12 @@ class AbuseFilterViewEdit extends AbuseFilterView {
}
$fields['abusefilter-edit-flags'] = $flags;
- $tools = '';
- if ( $filter != 'new' ) {
- if ( $user->isAllowed( 'abusefilter-revert' ) ) {
+ if ( $filter !== 'new' ) {
+ $tools = '';
+ if ( MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $user, 'abusefilter-revert' )
+ ) {
$tools .= Xml::tags(
'p', null,
$this->linkRenderer->makeLink(
@@ -371,7 +415,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
);
}
- if ( $this->canEdit() ) {
+ if ( AbuseFilter::canViewPrivate( $user ) ) {
// Test link
$tools .= Xml::tags(
'p', null,
@@ -407,21 +451,21 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$history_display = new HtmlArmor( $this->msg( 'abusefilter-edit-viewhistory' )->parse() );
$fields['abusefilter-edit-history'] =
$this->linkRenderer->makeKnownLink( $this->getTitle( 'history/' . $filter ), $history_display );
- }
- // Add export
- $exportText = FormatJson::encode( [ 'row' => $row, 'actions' => $actions ] );
- $tools .= Xml::tags( 'a', [ 'href' => '#', 'id' => 'mw-abusefilter-export-link' ],
- $this->msg( 'abusefilter-edit-export' )->parse() );
- $tools .=
- new OOUI\MultilineTextInputWidget( [
- 'id' => 'mw-abusefilter-export',
- 'readOnly' => true,
- 'value' => $exportText,
- 'rows' => 10
- ] );
+ // Add export
+ $exportText = FormatJson::encode( [ 'row' => $row, 'actions' => $actions ] );
+ $tools .= Xml::tags( 'a', [ 'href' => '#', 'id' => 'mw-abusefilter-export-link' ],
+ $this->msg( 'abusefilter-edit-export' )->parse() );
+ $tools .=
+ new OOUI\MultilineTextInputWidget( [
+ 'id' => 'mw-abusefilter-export',
+ 'readOnly' => true,
+ 'value' => $exportText,
+ 'rows' => 10
+ ] );
- $fields['abusefilter-edit-tools'] = $tools;
+ $fields['abusefilter-edit-tools'] = $tools;
+ }
$form = Xml::buildForm( $fields );
$form = Xml::fieldset( $this->msg( 'abusefilter-edit-main' )->text(), $form );
@@ -430,7 +474,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$this->buildConsequenceEditor( $row, $actions )
);
- if ( $this->canEditFilter( $row ) ) {
+ if ( AbuseFilter::canEditFilter( $user, $row ) ) {
$form .=
new OOUI\ButtonInputWidget( [
'type' => 'submit',
@@ -448,24 +492,28 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$form = Xml::tags( 'form',
[
'action' => $this->getTitle( $filter )->getFullURL(),
- 'method' => 'post'
+ 'method' => 'post',
+ 'id' => 'mw-abusefilter-editing-form'
],
$form
);
- $output .= $form;
+ $out->addHTML( $form );
- return $output;
+ if ( $history_id ) {
+ // @phan-suppress-next-line PhanPossiblyUndeclaredVariable
+ $out->addWikiMsg( $oldWarningMessage, $history_id, $filter );
+ }
}
/**
* Builds the "actions" editor for a given filter.
* @param stdClass $row A row from the abuse_filter table.
- * @param array $actions Array of rows from the abuse_filter_action table
+ * @param array[] $actions Array of rows from the abuse_filter_action table
* corresponding to the abuse filter held in $row.
* @return string HTML text for an action editor.
*/
- public function buildConsequenceEditor( $row, $actions ) {
+ private function buildConsequenceEditor( $row, array $actions ) {
$enabledActions = array_filter(
$this->getConfig()->get( 'AbuseFilterActions' )
);
@@ -478,11 +526,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$output = '';
foreach ( $enabledActions as $action => $_ ) {
- Wikimedia\suppressWarnings();
- $params = $actions[$action]['parameters'];
- Wikimedia\restoreWarnings();
+ $params = $actions[$action] ?? null;
$output .= $this->buildConsequenceSelector(
- $action, $setActions[$action], $params, $row );
+ $action, $setActions[$action], $row, $params );
}
return $output;
@@ -491,22 +537,19 @@ class AbuseFilterViewEdit extends AbuseFilterView {
/**
* @param string $action The action to build an editor for
* @param bool $set Whether or not the action is activated
- * @param array $parameters Action parameters
* @param stdClass $row abuse_filter row object
+ * @param string[]|null $parameters Action parameters. Null iff $set is false.
* @return string|\OOUI\FieldLayout
*/
- public function buildConsequenceSelector( $action, $set, $parameters, $row ) {
+ private function buildConsequenceSelector( $action, $set, $row, ?array $parameters ) {
$config = $this->getConfig();
+ $user = $this->getUser();
$actions = $config->get( 'AbuseFilterActions' );
if ( empty( $actions[$action] ) ) {
return '';
}
- $readOnlyAttrib = [];
-
- if ( !$this->canEditFilter( $row ) ) {
- $readOnlyAttrib['disabled'] = 'disabled';
- }
+ $readOnly = !AbuseFilter::canEditFilter( $user, $row );
switch ( $action ) {
case 'throttle':
@@ -520,8 +563,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'name' => 'wpFilterActionThrottle',
'id' => 'mw-abusefilter-action-checkbox-throttle',
'selected' => $set,
- 'classes' => [ 'mw-abusefilter-action-checkbox' ]
- ] + $readOnlyAttrib
+ 'classes' => [ 'mw-abusefilter-action-checkbox' ],
+ 'disabled' => $readOnly
+ ]
),
[
'label' => $this->msg( 'abusefilter-edit-action-throttle' )->text(),
@@ -531,12 +575,10 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$throttleFields = [];
if ( $set ) {
- array_shift( $parameters );
- $throttleRate = explode( ',', $parameters[0] );
- $throttleCount = $throttleRate[0];
- $throttlePeriod = $throttleRate[1];
+ // @phan-suppress-next-line PhanTypeArraySuspiciousNullable $parameters is array here
+ list( $throttleCount, $throttlePeriod ) = explode( ',', $parameters[1], 2 );
- $throttleGroups = array_slice( $parameters, 1 );
+ $throttleGroups = array_slice( $parameters, 2 );
} else {
$throttleCount = 3;
$throttlePeriod = 60;
@@ -549,8 +591,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
new OOUI\TextInputWidget( [
'type' => 'number',
'name' => 'wpFilterThrottleCount',
- 'value' => $throttleCount
- ] + $readOnlyAttrib
+ 'value' => $throttleCount,
+ 'readOnly' => $readOnly
+ ]
),
[
'label' => $this->msg( 'abusefilter-edit-throttle-count' )->text()
@@ -561,21 +604,26 @@ class AbuseFilterViewEdit extends AbuseFilterView {
new OOUI\TextInputWidget( [
'type' => 'number',
'name' => 'wpFilterThrottlePeriod',
- 'value' => $throttlePeriod
- ] + $readOnlyAttrib
+ 'value' => $throttlePeriod,
+ 'readOnly' => $readOnly
+ ]
),
[
'label' => $this->msg( 'abusefilter-edit-throttle-period' )->text()
]
);
- $throttleConfig = [
- 'values' => $throttleGroups,
- 'label' => $this->msg( 'abusefilter-edit-throttle-groups' )->parse(),
- 'disabled' => $readOnlyAttrib
- ];
- $this->getOutput()->addJsConfigVars( 'throttleConfig', $throttleConfig );
-
+ $groupsHelpLink = Html::element(
+ 'a',
+ [
+ 'href' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/' .
+ 'Extension:AbuseFilter/Actions#Throttling',
+ 'target' => '_blank'
+ ],
+ $this->msg( 'abusefilter-edit-throttle-groups-help-text' )->text()
+ );
+ $groupsHelp = $this->msg( 'abusefilter-edit-throttle-groups-help' )
+ ->rawParams( $groupsHelpLink )->escaped();
$hiddenGroups =
new OOUI\FieldLayout(
new OOUI\MultilineTextInputWidget( [
@@ -584,20 +632,31 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'rows' => 5,
'placeholder' => $this->msg( 'abusefilter-edit-throttle-hidden-placeholder' )->text(),
'infusable' => true,
- 'id' => 'mw-abusefilter-hidden-throttle-field'
- ] + $readOnlyAttrib
+ 'id' => 'mw-abusefilter-hidden-throttle-field',
+ 'readOnly' => $readOnly
+ ]
),
[
'label' => new OOUI\HtmlSnippet(
$this->msg( 'abusefilter-edit-throttle-groups' )->parse()
),
'align' => 'top',
- 'id' => 'mw-abusefilter-hidden-throttle'
+ 'id' => 'mw-abusefilter-hidden-throttle',
+ 'help' => new OOUI\HtmlSnippet( $groupsHelp ),
+ 'helpInline' => true
]
);
$throttleFields['abusefilter-edit-throttle-groups'] = $hiddenGroups;
+ $throttleConfig = [
+ 'values' => $throttleGroups,
+ 'label' => $this->msg( 'abusefilter-edit-throttle-groups' )->parse(),
+ 'disabled' => $readOnly,
+ 'help' => $groupsHelp
+ ];
+ $this->getOutput()->addJsConfigVars( 'throttleConfig', $throttleConfig );
+
$throttleSettings .=
Xml::tags(
'div',
@@ -616,8 +675,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
// mw-abusefilter-action-checkbox-warn, mw-abusefilter-action-checkbox-disallow
'id' => "mw-abusefilter-action-checkbox-$action",
'selected' => $set,
- 'classes' => [ 'mw-abusefilter-action-checkbox' ]
- ] + $readOnlyAttrib
+ 'classes' => [ 'mw-abusefilter-action-checkbox' ],
+ 'disabled' => $readOnly
+ ]
),
[
// abusefilter-edit-action-warn, abusefilter-edit-action-disallow
@@ -644,8 +704,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$msg = $action === 'warn' ? 'abusefilter-warning' : 'abusefilter-disallowed';
}
+ $fields = [];
$fields["abusefilter-edit-$action-message"] =
- $this->getExistingSelector( $msg, !empty( $readOnlyAttrib ), $action );
+ $this->getExistingSelector( $msg, $readOnly, $action );
$otherFieldName = $action === 'warn' ? 'wpFilterWarnMessageOther'
: 'wpFilterDisallowMessageOther';
@@ -656,8 +717,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'value' => $msg,
// mw-abusefilter-warn-message-other, mw-abusefilter-disallow-message-other
'id' => "mw-abusefilter-$action-message-other",
- 'infusable' => true
- ] + $readOnlyAttrib
+ 'infusable' => true,
+ 'readOnly' => $readOnly
+ ]
),
[
'label' => new OOUI\HtmlSnippet(
@@ -679,7 +741,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
);
$buttonGroup = $previewButton;
- if ( $this->getUser()->isAllowed( 'editinterface' ) ) {
+ if ( MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $user, 'editinterface' )
+ ) {
$editButton =
new OOUI\ButtonInputWidget( [
// abusefilter-edit-warn-edit, abusefilter-edit-disallow-edit
@@ -720,11 +784,8 @@ class AbuseFilterViewEdit extends AbuseFilterView {
return $output;
case 'tag':
- if ( $set ) {
- $tags = $parameters;
- } else {
- $tags = [];
- }
+ $tags = $set ? $parameters : [];
+ '@phan-var string[] $parameters';
$output = '';
$checkbox =
@@ -733,8 +794,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'name' => 'wpFilterActionTag',
'id' => 'mw-abusefilter-action-checkbox-tag',
'selected' => $set,
- 'classes' => [ 'mw-abusefilter-action-checkbox' ]
- ] + $readOnlyAttrib
+ 'classes' => [ 'mw-abusefilter-action-checkbox' ],
+ 'disabled' => $readOnly
+ ]
),
[
'label' => $this->msg( 'abusefilter-edit-action-tag' )->text(),
@@ -746,7 +808,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$tagConfig = [
'values' => $tags,
'label' => $this->msg( 'abusefilter-edit-tag-tag' )->parse(),
- 'disabled' => $readOnlyAttrib
+ 'disabled' => $readOnly
];
$this->getOutput()->addJsConfigVars( 'tagConfig', $tagConfig );
@@ -758,8 +820,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'rows' => 5,
'placeholder' => $this->msg( 'abusefilter-edit-tag-hidden-placeholder' )->text(),
'infusable' => true,
- 'id' => 'mw-abusefilter-hidden-tag-field'
- ] + $readOnlyAttrib
+ 'id' => 'mw-abusefilter-hidden-tag-field',
+ 'readOnly' => $readOnly
+ ]
),
[
'label' => new OOUI\HtmlSnippet(
@@ -778,12 +841,11 @@ class AbuseFilterViewEdit extends AbuseFilterView {
case 'block':
if ( $set && count( $parameters ) === 3 ) {
// Both blocktalk and custom block durations available
- $blockTalk = $parameters[0];
- $defaultAnonDuration = $parameters[1];
- $defaultUserDuration = $parameters[2];
+ list( $blockTalk, $defaultAnonDuration, $defaultUserDuration ) = $parameters;
} else {
if ( $set && count( $parameters ) === 1 ) {
// Only blocktalk available
+ // @phan-suppress-next-line PhanTypeArraySuspiciousNullable $parameters is array here
$blockTalk = $parameters[0];
}
if ( $config->get( 'AbuseFilterAnonBlockDuration' ) ) {
@@ -803,8 +865,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'name' => 'wpFilterActionBlock',
'id' => 'mw-abusefilter-action-checkbox-block',
'selected' => $set,
- 'classes' => [ 'mw-abusefilter-action-checkbox' ]
- ] + $readOnlyAttrib
+ 'classes' => [ 'mw-abusefilter-action-checkbox' ],
+ 'disabled' => $readOnly
+ ]
),
[
'label' => $this->msg( 'abusefilter-edit-action-block' )->text(),
@@ -820,7 +883,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'name' => 'wpBlockAnonDuration',
'options' => $suggestedBlocks,
'value' => $defaultAnonDuration,
- 'disabled' => !$this->canEditFilter( $row )
+ 'disabled' => !AbuseFilter::canEditFilter( $user, $row )
] );
$userDuration =
@@ -828,7 +891,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'name' => 'wpBlockUserDuration',
'options' => $suggestedBlocks,
'value' => $defaultUserDuration,
- 'disabled' => !$this->canEditFilter( $row )
+ 'disabled' => !AbuseFilter::canEditFilter( $user, $row )
] );
$blockOptions = [];
@@ -838,9 +901,10 @@ class AbuseFilterViewEdit extends AbuseFilterView {
new OOUI\CheckboxInputWidget( [
'name' => 'wpFilterBlockTalk',
'id' => 'mw-abusefilter-action-checkbox-blocktalk',
- 'selected' => isset( $blockTalk ) && $blockTalk == 'blocktalk',
- 'classes' => [ 'mw-abusefilter-action-checkbox' ]
- ] + $readOnlyAttrib
+ 'selected' => isset( $blockTalk ) && $blockTalk === 'blocktalk',
+ 'classes' => [ 'mw-abusefilter-action-checkbox' ],
+ 'disabled' => $readOnly
+ ]
),
[
'label' => $this->msg( 'abusefilter-edit-action-blocktalk' )->text(),
@@ -889,15 +953,15 @@ class AbuseFilterViewEdit extends AbuseFilterView {
'name' => $form_field,
'id' => "mw-abusefilter-action-checkbox-$action",
'selected' => $status,
- 'classes' => [ 'mw-abusefilter-action-checkbox' ]
- ] + $readOnlyAttrib
+ 'classes' => [ 'mw-abusefilter-action-checkbox' ],
+ 'disabled' => $readOnly
+ ]
),
[
'label' => $this->msg( $message )->text(),
'align' => 'inline'
]
);
- $thisAction = $thisAction;
return $thisAction;
}
}
@@ -927,7 +991,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
// mw-abusefilter-warn-message-existing, mw-abusefilter-disallow-message-existing
'id' => "mw-abusefilter-$formId-message-existing",
// abusefilter-warning, abusefilter-disallowed
- 'value' => $warnMsg == "abusefilter-$action" ? "abusefilter-$action" : 'other',
+ 'value' => $warnMsg === "abusefilter-$action" ? "abusefilter-$action" : 'other',
'infusable' => true
] );
@@ -940,9 +1004,9 @@ class AbuseFilterViewEdit extends AbuseFilterView {
// Find other messages.
$dbr = wfGetDB( DB_REPLICA );
$pageTitlePrefix = "Abusefilter-$action";
- $res = $dbr->select(
+ $titles = $dbr->selectFieldValues(
'page',
- [ 'page_title' ],
+ 'page_title',
[
'page_namespace' => 8,
'page_title LIKE ' . $dbr->addQuotes( $pageTitlePrefix . '%' )
@@ -951,19 +1015,19 @@ class AbuseFilterViewEdit extends AbuseFilterView {
);
$lang = $this->getLanguage();
- foreach ( $res as $row ) {
- if ( $lang->lcfirst( $row->page_title ) == $lang->lcfirst( $warnMsg ) ) {
+ foreach ( $titles as $title ) {
+ if ( $lang->lcfirst( $title ) === $lang->lcfirst( $warnMsg ) ) {
$existingSelector->setValue( $lang->lcfirst( $warnMsg ) );
}
- if ( $row->page_title != "Abusefilter-$action" ) {
- $options += [ $lang->lcfirst( $row->page_title ) => $lang->lcfirst( $row->page_title ) ];
+ if ( $title !== "Abusefilter-$action" ) {
+ $options[ $lang->lcfirst( $title ) ] = $lang->lcfirst( $title );
}
}
}
// abusefilter-edit-warn-other, abusefilter-edit-disallow-other
- $options += [ $this->msg( "abusefilter-edit-$formId-other" )->text() => 'other' ];
+ $options[ $this->msg( "abusefilter-edit-$formId-other" )->text() ] = 'other';
$options = Xml::listDropDownOptionsOoui( $options );
$existingSelector->setOptions( $options );
@@ -987,7 +1051,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
* @param array $durations
* @return array
*/
- protected static function normalizeBlocks( $durations ) {
+ protected static function normalizeBlocks( array $durations ) {
global $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration;
// We need to have same values since it may happen that ipblocklist
// and one (or both) of the global variables use different wording
@@ -1029,19 +1093,23 @@ class AbuseFilterViewEdit extends AbuseFilterView {
/**
* Loads filter data from the database by ID.
- * @param int $id The filter's ID number
- * @return array|null Either an associative array representing the filter,
+ * @param int|string $id The filter's ID number, or 'new'
+ * @return array|null Either a [ DB row, actions ] array representing the filter,
* or NULL if the filter does not exist.
*/
public function loadFilterData( $id ) {
- if ( $id == 'new' ) {
- $obj = new stdClass;
- $obj->af_pattern = '';
- $obj->af_enabled = 1;
- $obj->af_hidden = 0;
- $obj->af_global = 0;
- $obj->af_throttled = 0;
- return [ $obj, [] ];
+ if ( $id === 'new' ) {
+ return [
+ (object)[
+ 'af_pattern' => '',
+ 'af_enabled' => 1,
+ 'af_hidden' => 0,
+ 'af_global' => 0,
+ 'af_throttled' => 0,
+ 'af_hit_count' => 0
+ ],
+ []
+ ];
}
// Load from master to avoid unintended reversions where there's replication lag.
@@ -1079,7 +1147,6 @@ class AbuseFilterViewEdit extends AbuseFilterView {
}
// Load the actions
- $actions = [];
$res = $dbr->select(
'abuse_filter_action',
[ 'afa_consequence', 'afa_parameters' ],
@@ -1087,57 +1154,78 @@ class AbuseFilterViewEdit extends AbuseFilterView {
__METHOD__
);
+ $actions = [];
foreach ( $res as $actionRow ) {
- $thisAction = [];
- $thisAction['action'] = $actionRow->afa_consequence;
- $thisAction['parameters'] = array_filter( explode( "\n", $actionRow->afa_parameters ) );
-
- $actions[$actionRow->afa_consequence] = $thisAction;
+ $actions[$actionRow->afa_consequence] =
+ array_filter( explode( "\n", $actionRow->afa_parameters ) );
}
return [ $row, $actions ];
}
/**
- * Load filter data to show in the edit view.
- * Either from the HTTP request or from the filter/history_id given.
- * The HTTP request always takes precedence.
- * Includes caching.
- * @param int $filter The filter ID being requested.
+ * Load filter data to show in the edit view from the DB.
+ * @param int|string $filter The filter ID being requested or 'new'.
* @param int|null $history_id If any, the history ID being requested.
* @return array|null Array with filter data if available, otherwise null.
* The first element contains the abuse_filter database row,
* the second element is an array of related abuse_filter_action rows.
*/
- public function loadRequest( $filter, $history_id = null ) {
- $row = self::$mLoadedRow;
- $actions = self::$mLoadedActions;
- $request = $this->getRequest();
-
- if ( !is_null( $actions ) && !is_null( $row ) ) {
- return [ $row, $actions ];
- } elseif ( $request->wasPosted() ) {
- // Nothing, we do it all later
- } elseif ( $history_id ) {
+ private function loadFromDatabase( $filter, $history_id = null ) {
+ if ( $history_id ) {
return $this->loadHistoryItem( $history_id );
} else {
return $this->loadFilterData( $filter );
}
+ }
+
+ /**
+ * Load data from the already-POSTed HTTP request.
+ *
+ * @throws BadMethodCallException If called without the request being POSTed or when trying
+ * to import a filter but $filter is not 'new'
+ * @param int|string $filter The filter ID being requested.
+ * @return Status If good, the value is the array [ row, actions ]. If not, it contains an
+ * error message.
+ */
+ public function loadRequest( $filter ): Status {
+ $request = $this->getRequest();
+ if ( !$request->wasPosted() ) {
+ // Sanity
+ throw new BadMethodCallException( __METHOD__ . ' called without the request being POSTed.' );
+ }
// We need some details like last editor
- list( $row, $origActions ) = $this->loadFilterData( $filter );
+ list( $origRow, $origActions ) = $this->loadFilterData( $filter );
- $row->mOriginalRow = clone $row;
+ // Default values
+ $row = (object)[
+ 'af_throttled' => $origRow->af_throttled,
+ 'af_hit_count' => $origRow->af_hit_count,
+ ];
+ $row->mOriginalRow = $origRow;
$row->mOriginalActions = $origActions;
// Check for importing
$import = $request->getVal( 'wpImportText' );
if ( $import ) {
+ if ( $filter !== 'new' ) {
+ // Sanity
+ throw new BadMethodCallException( __METHOD__ . ' called for importing on existing filter.' );
+ }
$data = FormatJson::decode( $import );
+ if ( !$this->isValidImportData( $data ) ) {
+ return Status::newFatal( 'abusefilter-import-invalid-data' );
+ }
+
$importRow = $data->row;
$actions = wfObjectToArray( $data->actions );
+ // Some more default values
+ $row->af_group = 'default';
+ $row->af_global = 0;
+
$copy = [
'af_public_comments',
'af_pattern',
@@ -1151,6 +1239,15 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$row->$name = $importRow->$name;
}
} else {
+ if ( $filter !== 'new' ) {
+ // These aren't needed when saving the filter, but they are otherwise (e.g. if
+ // saving fails and we need to show the edit interface again).
+ $row->af_id = $origRow->af_id;
+ $row->af_user = $origRow->af_user;
+ $row->af_user_text = $origRow->af_user_text;
+ $row->af_timestamp = $origRow->af_timestamp;
+ }
+
$textLoads = [
'af_public_comments' => 'wpFilterDescription',
'af_pattern' => 'wpFilterRules',
@@ -1169,7 +1266,6 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$row->af_global = $request->getCheck( 'wpFilterGlobal' )
&& $this->getConfig()->get( 'AbuseFilterIsCentral' );
- // Actions
$actions = [];
foreach ( array_filter( $this->getConfig()->get( 'AbuseFilterActions' ) ) as $action => $_ ) {
// Check if it's set
@@ -1178,7 +1274,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
if ( $enabled ) {
$parameters = [];
- if ( $action == 'throttle' ) {
+ if ( $action === 'throttle' ) {
// We need to load the parameters
$throttleCount = $request->getIntOrNull( 'wpFilterThrottleCount' );
$throttlePeriod = $request->getIntOrNull( 'wpFilterThrottlePeriod' );
@@ -1198,52 +1294,55 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$parameters[0] = $this->mFilter;
$parameters[1] = "$throttleCount,$throttlePeriod";
$parameters = array_merge( $parameters, $throttleGroups );
- } elseif ( $action == 'warn' ) {
+ } elseif ( $action === 'warn' ) {
$specMsg = $request->getVal( 'wpFilterWarnMessage' );
- if ( $specMsg == 'other' ) {
+ if ( $specMsg === 'other' ) {
$specMsg = $request->getVal( 'wpFilterWarnMessageOther' );
}
$parameters[0] = $specMsg;
- } elseif ( $action == 'block' ) {
+ } elseif ( $action === 'block' ) {
$parameters[0] = $request->getCheck( 'wpFilterBlockTalk' ) ?
'blocktalk' : 'noTalkBlockSet';
$parameters[1] = $request->getVal( 'wpBlockAnonDuration' );
$parameters[2] = $request->getVal( 'wpBlockUserDuration' );
- } elseif ( $action == 'disallow' ) {
+ } elseif ( $action === 'disallow' ) {
$specMsg = $request->getVal( 'wpFilterDisallowMessage' );
- if ( $specMsg == 'other' ) {
+ if ( $specMsg === 'other' ) {
$specMsg = $request->getVal( 'wpFilterDisallowMessageOther' );
}
$parameters[0] = $specMsg;
- } elseif ( $action == 'tag' ) {
+ } elseif ( $action === 'tag' ) {
$parameters = explode( ',', trim( $request->getText( 'wpFilterTags' ) ) );
+ if ( $parameters === [ '' ] ) {
+ // Since it's not possible to manually add an empty tag, this only happens
+ // if the form is submitted without touching the tag input field.
+ // We pass an empty array so that the widget won't show an empty tag in the topbar
+ $parameters = [];
+ }
}
- $thisAction = [ 'action' => $action, 'parameters' => $parameters ];
- $actions[$action] = $thisAction;
+ $actions[$action] = $parameters;
}
}
}
- $row->af_actions = implode( ',', array_keys( array_filter( $actions ) ) );
+ $row->af_actions = implode( ',', array_keys( $actions ) );
- self::$mLoadedRow = $row;
- self::$mLoadedActions = $actions;
- return [ $row, $actions ];
+ return Status::newGood( [ $row, $actions ] );
}
/**
* Loads historical data in a form that the editor can understand.
* @param int $id History ID
- * @return array|bool False if the history ID is not valid, otherwise array in the usual format:
+ * @return array|null Null if the history ID is not valid, otherwise array in the usual format:
* First element contains the abuse_filter row (as it was).
* Second element contains an array of abuse_filter_action rows.
*/
- public function loadHistoryItem( $id ) {
+ private function loadHistoryItem( $id ) : ?array {
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow( 'abuse_filter_history',
@@ -1253,7 +1352,7 @@ class AbuseFilterViewEdit extends AbuseFilterView {
);
if ( !$row ) {
- return false;
+ return null;
}
return AbuseFilter::translateFromHistory( $row );
@@ -1272,4 +1371,42 @@ class AbuseFilterViewEdit extends AbuseFilterView {
$this->getConfig()->get( 'AbuseFilterDefaultDisallowMessage' )
);
}
+
+ /**
+ * Perform basic validation on the JSON-decoded import data. This doesn't check if parameters
+ * are valid etc., but only if the shape of the object is right.
+ *
+ * @param mixed $data Already JSON-decoded
+ * @return bool
+ */
+ private function isValidImportData( $data ) {
+ global $wgAbuseFilterActions;
+
+ if ( !is_object( $data ) ) {
+ return false;
+ }
+
+ $arr = get_object_vars( $data );
+
+ $expectedKeys = [ 'row' => true, 'actions' => true ];
+ if ( count( $arr ) !== count( $expectedKeys ) || array_diff_key( $arr, $expectedKeys ) ) {
+ return false;
+ }
+
+ if ( !is_object( $arr['row'] ) || !is_object( $arr['actions'] ) ) {
+ return false;
+ }
+
+ foreach ( $arr['actions'] as $action => $params ) {
+ if ( !array_key_exists( $action, $wgAbuseFilterActions ) || !is_array( $params ) ) {
+ return false;
+ }
+ }
+
+ if ( !AbuseFilter::isFullAbuseFilterRow( $arr['row'] ) ) {
+ return false;
+ }
+
+ return true;
+ }
}
diff --git a/AbuseFilter/includes/Views/AbuseFilterViewExamine.php b/AbuseFilter/includes/Views/AbuseFilterViewExamine.php
index 6c3d065c..6ef6354d 100644
--- a/AbuseFilter/includes/Views/AbuseFilterViewExamine.php
+++ b/AbuseFilter/includes/Views/AbuseFilterViewExamine.php
@@ -1,10 +1,29 @@
<?php
-class AbuseFilterViewExamine extends AbuseFilterView {
- public static $examineType = null;
- public static $examineId = null;
+use MediaWiki\Extension\AbuseFilter\VariableGenerator\RCVariableGenerator;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
- public $mCounter, $mSearchUser, $mSearchPeriodStart, $mSearchPeriodEnd;
+class AbuseFilterViewExamine extends AbuseFilterView {
+ /**
+ * @var int Line number of the row, see RecentChange::$counter
+ */
+ public $mCounter;
+ /**
+ * @var string The user whose entries we're examinating
+ */
+ public $mSearchUser;
+ /**
+ * @var string The start time of the search period
+ */
+ public $mSearchPeriodStart;
+ /**
+ * @var string The end time of the search period
+ */
+ public $mSearchPeriodEnd;
+ /**
+ * @var string The ID of the filter we're examinating
+ */
public $mTestFilter;
/**
@@ -21,7 +40,7 @@ class AbuseFilterViewExamine extends AbuseFilterView {
if ( count( $this->mParams ) > 1 && is_numeric( $this->mParams[1] ) ) {
$this->showExaminerForRC( $this->mParams[1] );
} elseif ( count( $this->mParams ) > 2
- && $this->mParams[1] == 'log'
+ && $this->mParams[1] === 'log'
&& is_numeric( $this->mParams[2] )
) {
$this->showExaminerForLogEntry( $this->mParams[2] );
@@ -96,32 +115,26 @@ class AbuseFilterViewExamine extends AbuseFilterView {
*/
public function showExaminerForRC( $rcid ) {
// Get data
- $dbr = wfGetDB( DB_REPLICA );
- $rcQuery = RecentChange::getQueryInfo();
- $row = $dbr->selectRow(
- $rcQuery['tables'],
- $rcQuery['fields'],
- [ 'rc_id' => $rcid ],
- __METHOD__,
- [],
- $rcQuery['joins']
- );
+ $rc = RecentChange::newFromId( $rcid );
$out = $this->getOutput();
- if ( !$row ) {
+ if ( !$rc ) {
$out->addWikiMsg( 'abusefilter-examine-notfound' );
return;
}
- if ( !ChangesList::userCan( RecentChange::newFromRow( $row ), Revision::SUPPRESSED_ALL ) ) {
+ if ( !ChangesList::userCan( $rc, RevisionRecord::SUPPRESSED_ALL ) ) {
$out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' );
return;
}
- self::$examineType = 'rc';
- self::$examineId = $rcid;
+ $vars = new AbuseFilterVariableHolder();
+ $varGenerator = new RCVariableGenerator( $vars, $rc, $this->getUser() );
+ $vars = $varGenerator->getVars();
+ $out->addJsConfigVars( [
+ 'wgAbuseFilterVariables' => $vars ? $vars->dumpAllVars( true ) : [],
+ 'abuseFilterExamine' => [ 'type' => 'rc', 'id' => $rcid ]
+ ] );
- $vars = AbuseFilter::getVarsFromRCRow( $row );
- $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) );
$this->showExaminer( $vars );
}
@@ -131,6 +144,9 @@ class AbuseFilterViewExamine extends AbuseFilterView {
public function showExaminerForLogEntry( $logid ) {
// Get data
$dbr = wfGetDB( DB_REPLICA );
+ $user = $this->getUser();
+ $out = $this->getOutput();
+
$row = $dbr->selectRow(
'abuse_filter_log',
[
@@ -142,35 +158,37 @@ class AbuseFilterViewExamine extends AbuseFilterView {
[ 'afl_id' => $logid ],
__METHOD__
);
- $out = $this->getOutput();
if ( !$row ) {
$out->addWikiMsg( 'abusefilter-examine-notfound' );
return;
}
- self::$examineType = 'log';
- self::$examineId = $logid;
-
- if ( !SpecialAbuseLog::canSeeDetails( $row->afl_filter ) ) {
+ list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter );
+ if ( !SpecialAbuseLog::canSeeDetails( $user, $filterID, $global ) ) {
$out->addWikiMsg( 'abusefilter-log-cannot-see-details' );
return;
}
- if ( $row->afl_deleted && !SpecialAbuseLog::canSeeHidden() ) {
+ if ( $row->afl_deleted && !SpecialAbuseLog::canSeeHidden( $user ) ) {
$out->addWikiMsg( 'abusefilter-log-details-hidden' );
return;
}
if ( SpecialAbuseLog::isHidden( $row ) === 'implicit' ) {
- $rev = Revision::newFromId( $row->afl_rev_id );
- if ( !$rev->userCan( Revision::SUPPRESSED_ALL, $this->getUser() ) ) {
+ $revRec = MediaWikiServices::getInstance()
+ ->getRevisionLookup()
+ ->getRevisionById( (int)$row->afl_rev_id );
+ if ( !AbuseFilter::userCanViewRev( $revRec, $user ) ) {
$out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' );
return;
}
}
$vars = AbuseFilter::loadVarDump( $row->afl_var_dump );
- $out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) );
+ $out->addJsConfigVars( [
+ 'wgAbuseFilterVariables' => $vars->dumpAllVars( true ),
+ 'abuseFilterExamine' => [ 'type' => 'log', 'id' => $logid ]
+ ] );
$this->showExaminer( $vars );
}
@@ -195,10 +213,10 @@ class AbuseFilterViewExamine extends AbuseFilterView {
$output->addModules( 'ext.abuseFilter.examine' );
// Add test bit
- if ( $this->canViewPrivate() ) {
+ if ( AbuseFilter::canViewPrivate( $this->getUser() ) ) {
$tester = Xml::tags( 'h2', null, $this->msg( 'abusefilter-examine-test' )->parse() );
- $tester .= $this->buildEditBox( $this->mTestFilter, 'wpTestFilter', false, false, false );
- $tester .= AbuseFilter::buildFilterLoader();
+ $tester .= $this->buildEditBox( $this->mTestFilter, false, false, false );
+ $tester .= $this->buildFilterLoader();
$html .= Xml::tags( 'div', [ 'id' => 'mw-abusefilter-examine-editor' ], $tester );
$html .= Xml::tags( 'p',
null,
diff --git a/AbuseFilter/includes/Views/AbuseFilterViewHistory.php b/AbuseFilter/includes/Views/AbuseFilterViewHistory.php
index 66a14a56..f25d0a4e 100644
--- a/AbuseFilter/includes/Views/AbuseFilterViewHistory.php
+++ b/AbuseFilter/includes/Views/AbuseFilterViewHistory.php
@@ -5,7 +5,7 @@ class AbuseFilterViewHistory extends AbuseFilterView {
* @param SpecialAbuseFilter $page
* @param array $params
*/
- public function __construct( $page, $params ) {
+ public function __construct( SpecialAbuseFilter $page, $params ) {
parent::__construct( $page, $params );
$this->mFilter = $page->mFilter;
}
@@ -17,6 +17,8 @@ class AbuseFilterViewHistory extends AbuseFilterView {
$out = $this->getOutput();
$out->enableOOUI();
$filter = $this->getRequest()->getText( 'filter' ) ?: $this->mFilter;
+ // Ensure the parameter is a valid filter ID
+ $filter = (int)$filter;
if ( $filter ) {
$out->setPageTitle( $this->msg( 'abusefilter-history' )->numParams( $filter ) );
@@ -24,9 +26,8 @@ class AbuseFilterViewHistory extends AbuseFilterView {
$out->setPageTitle( $this->msg( 'abusefilter-filter-log' ) );
}
- // Check perms. abusefilter-modify is a superset of abusefilter-view-private
if ( $filter && AbuseFilter::filterHidden( $filter )
- && !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' )
+ && !AbuseFilter::canViewPrivate( $this->getUser() )
) {
$out->addWikiMsg( 'abusefilter-history-error-hidden' );
return;
@@ -75,9 +76,9 @@ class AbuseFilterViewHistory extends AbuseFilterView {
'label-message' => 'abusefilter-history-select-user'
],
'filter' => [
- 'type' => 'text',
+ 'type' => 'int',
'name' => 'filter',
- 'default' => $filter,
+ 'default' => $filter ?: '',
'size' => '45',
'label-message' => 'abusefilter-history-select-filter'
],
@@ -93,10 +94,6 @@ class AbuseFilterViewHistory extends AbuseFilterView {
$pager = new AbuseFilterHistoryPager( $filter, $this, $user, $this->linkRenderer );
- $out->addHTML(
- $pager->getNavigationBar() .
- $pager->getBody() .
- $pager->getNavigationBar()
- );
+ $out->addParserOutputContent( $pager->getFullOutput() );
}
}
diff --git a/AbuseFilter/includes/Views/AbuseFilterViewImport.php b/AbuseFilter/includes/Views/AbuseFilterViewImport.php
index 01e2fc85..80dc8c98 100644
--- a/AbuseFilter/includes/Views/AbuseFilterViewImport.php
+++ b/AbuseFilter/includes/Views/AbuseFilterViewImport.php
@@ -6,7 +6,7 @@ class AbuseFilterViewImport extends AbuseFilterView {
*/
public function show() {
$out = $this->getOutput();
- if ( !$this->getUser()->isAllowed( 'abusefilter-modify' ) ) {
+ if ( !AbuseFilter::canEdit( $this->getUser() ) ) {
$out->addWikiMsg( 'abusefilter-edit-notallowed' );
return;
}
@@ -17,9 +17,10 @@ class AbuseFilterViewImport extends AbuseFilterView {
$formDescriptor = [
'ImportText' => [
'type' => 'textarea',
+ 'required' => true
]
];
- $htmlform = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
+ HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
->setSubmitTextMsg( 'abusefilter-import-submit' )
->setAction( $url )
->show();
diff --git a/AbuseFilter/includes/Views/AbuseFilterViewList.php b/AbuseFilter/includes/Views/AbuseFilterViewList.php
index ad92ad6c..efbf6217 100644
--- a/AbuseFilter/includes/Views/AbuseFilterViewList.php
+++ b/AbuseFilter/includes/Views/AbuseFilterViewList.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\MediaWikiServices;
+
/**
* The default view used in Special:AbuseFilter
*/
@@ -11,6 +13,7 @@ class AbuseFilterViewList extends AbuseFilterView {
$out = $this->getOutput();
$request = $this->getRequest();
$config = $this->getConfig();
+ $user = $this->getUser();
// Show filter performance statistics
$this->showStatus();
@@ -18,18 +21,27 @@ class AbuseFilterViewList extends AbuseFilterView {
$out->addWikiMsg( 'abusefilter-intro' );
// New filter button
- if ( $this->canEdit() ) {
+ if ( AbuseFilter::canEdit( $user ) ) {
$out->enableOOUI();
- $link = new OOUI\ButtonWidget( [
- 'label' => $this->msg( 'abusefilter-new' )->text(),
- 'href' => $this->getTitle( 'new' )->getFullURL(),
+ $buttons = new OOUI\HorizontalLayout( [
+ 'items' => [
+ new OOUI\ButtonWidget( [
+ 'label' => $this->msg( 'abusefilter-new' )->text(),
+ 'href' => $this->getTitle( 'new' )->getFullURL(),
+ ] ),
+ new OOUI\ButtonWidget( [
+ 'label' => $this->msg( 'abusefilter-import-button' )->text(),
+ 'href' => $this->getTitle( 'import' )->getFullURL(),
+ ] )
+ ]
] );
- $out->addHTML( $link );
+ $out->addHTML( $buttons );
}
$conds = [];
$deleted = $request->getVal( 'deletedfilters' );
$furtherOptions = $request->getArray( 'furtheroptions', [] );
+ '@phan-var array $furtherOptions';
// Backward compatibility with old links
if ( $request->getBool( 'hidedisabled' ) ) {
$furtherOptions[] = 'hidedisabled';
@@ -45,22 +57,22 @@ class AbuseFilterViewList extends AbuseFilterView {
}
$scope = $request->getVal( 'rulescope', $defaultscope );
- $searchEnabled = $this->canViewPrivate() && !(
+ $searchEnabled = AbuseFilter::canViewPrivate( $user ) && !(
$config->get( 'AbuseFilterCentralDB' ) !== null &&
!$config->get( 'AbuseFilterIsCentral' ) &&
- $scope == 'global' );
+ $scope === 'global' );
if ( $searchEnabled ) {
- $querypattern = $request->getVal( 'querypattern' );
+ $querypattern = $request->getVal( 'querypattern', '' );
$searchmode = $request->getVal( 'searchoption', 'LIKE' );
} else {
$querypattern = '';
$searchmode = '';
}
- if ( $deleted == 'show' ) {
+ if ( $deleted === 'show' ) {
// Nothing
- } elseif ( $deleted == 'only' ) {
+ } elseif ( $deleted === 'only' ) {
$conds['af_deleted'] = 1;
} else {
// hide, or anything else.
@@ -75,79 +87,57 @@ class AbuseFilterViewList extends AbuseFilterView {
$conds['af_hidden'] = 0;
}
- if ( $scope == 'local' ) {
+ if ( $scope === 'local' ) {
$conds['af_global'] = 0;
- } elseif ( $scope == 'global' ) {
+ } elseif ( $scope === 'global' ) {
$conds['af_global'] = 1;
}
- $dbr = wfGetDB( DB_REPLICA );
-
if ( $querypattern !== '' ) {
- if ( $searchmode !== 'LIKE' ) {
- // Check regex pattern validity
- Wikimedia\suppressWarnings();
- $validreg = preg_match( '/' . $querypattern . '/', null );
- Wikimedia\restoreWarnings();
+ // Check the search pattern. Filtering the results is done in AbuseFilterPager
+ $error = null;
+ if ( !in_array( $searchmode, [ 'LIKE', 'RLIKE', 'IRLIKE' ] ) ) {
+ $error = 'abusefilter-list-invalid-searchmode';
+ } elseif ( $searchmode !== 'LIKE' && !StringUtils::isValidPCRERegex( "/$querypattern/" ) ) {
+ $error = 'abusefilter-list-regexerror';
+ }
- if ( $validreg === false ) {
- $out->addHTML(
- Xml::tags(
- 'p',
- null,
- Html::errorBox( $this->msg( 'abusefilter-list-regexerror' )->parse() )
- )
- );
- $this->showList(
- [ 'af_deleted' => 0 ],
- compact(
- 'deleted',
- 'furtherOptions',
- 'querypattern',
- 'searchmode',
- 'scope',
- 'searchEnabled'
- )
- );
- return;
- }
- if ( $searchmode === 'RLIKE' ) {
- $conds[] = 'af_pattern RLIKE ' .
- $dbr->addQuotes( $querypattern );
- } else {
- $conds[] = 'LOWER( CAST( af_pattern AS char ) ) RLIKE ' .
- strtolower( $dbr->addQuotes( $querypattern ) );
- }
- } else {
- // Build like query escaping tokens and encapsulating in % to search everywhere
- $conds[] = 'LOWER( CAST( af_pattern AS char ) ) ' .
- $dbr->buildLike(
- $dbr->anyString(),
- strtolower( $querypattern ),
- $dbr->anyString()
- );
+ if ( $error !== null ) {
+ $out->addHTML(
+ Xml::tags(
+ 'p',
+ null,
+ Html::errorBox( $this->msg( $error )->escaped() )
+ )
+ );
+
+ // Reset the conditions in case of error
+ $conds = [ 'af_deleted' => 0 ];
+ $querypattern = '';
}
}
$this->showList(
- $conds,
compact(
'deleted',
'furtherOptions',
'querypattern',
'searchmode',
- 'scope',
- 'searchEnabled'
- )
+ 'scope'
+ ),
+ $conds
);
}
/**
- * @param array $conds
* @param array $optarray
+ * @param array $conds
*/
- public function showList( $conds = [ 'af_deleted' => 0 ], $optarray = [] ) {
+ private function showList( array $optarray, array $conds = [ 'af_deleted' => 0 ] ) {
+ $user = $this->getUser();
$config = $this->getConfig();
+ $centralDB = $config->get( 'AbuseFilterCentralDB' );
+ $dbIsCentral = $config->get( 'AbuseFilterIsCentral' );
$this->getOutput()->addHTML(
Xml::tags( 'h2', null, $this->msg( 'abusefilter-list' )->parse() )
);
@@ -155,15 +145,10 @@ class AbuseFilterViewList extends AbuseFilterView {
$deleted = $optarray['deleted'];
$furtherOptions = $optarray['furtherOptions'];
$scope = $optarray['scope'];
- $searchEnabled = $optarray['searchEnabled'];
$querypattern = $optarray['querypattern'];
$searchmode = $optarray['searchmode'];
- if (
- $config->get( 'AbuseFilterCentralDB' ) !== null
- && !$config->get( 'AbuseFilterIsCentral' )
- && $scope == 'global'
- ) {
+ if ( $centralDB !== null && !$dbIsCentral && $scope === 'global' ) {
$pager = new GlobalAbuseFilterPager(
$this,
$conds,
@@ -174,31 +159,20 @@ class AbuseFilterViewList extends AbuseFilterView {
$this,
$conds,
$this->linkRenderer,
- [ $querypattern, $searchmode ]
+ $querypattern,
+ $searchmode
);
}
// Options form
$formDescriptor = [];
- $formDescriptor['deletedfilters'] = [
- 'name' => 'deletedfilters',
- 'type' => 'radio',
- 'flatlist' => true,
- 'label-message' => 'abusefilter-list-options-deleted',
- 'options-messages' => [
- 'abusefilter-list-options-deleted-show' => 'show',
- 'abusefilter-list-options-deleted-hide' => 'hide',
- 'abusefilter-list-options-deleted-only' => 'only',
- ],
- 'default' => $deleted,
- ];
- if ( $config->get( 'AbuseFilterCentralDB' ) !== null ) {
+ if ( $centralDB !== null ) {
$optionsMsg = [
'abusefilter-list-options-scope-local' => 'local',
'abusefilter-list-options-scope-global' => 'global',
];
- if ( $config->get( 'AbuseFilterIsCentral' ) ) {
+ if ( $dbIsCentral ) {
// For central wiki: add third scope option
$optionsMsg['abusefilter-list-options-scope-all'] = 'all';
}
@@ -212,6 +186,19 @@ class AbuseFilterViewList extends AbuseFilterView {
];
}
+ $formDescriptor['deletedfilters'] = [
+ 'name' => 'deletedfilters',
+ 'type' => 'radio',
+ 'flatlist' => true,
+ 'label-message' => 'abusefilter-list-options-deleted',
+ 'options-messages' => [
+ 'abusefilter-list-options-deleted-show' => 'show',
+ 'abusefilter-list-options-deleted-hide' => 'hide',
+ 'abusefilter-list-options-deleted-only' => 'only',
+ ],
+ 'default' => $deleted,
+ ];
+
$formDescriptor['furtheroptions'] = [
'name' => 'furtheroptions',
'type' => 'multiselect',
@@ -224,11 +211,12 @@ class AbuseFilterViewList extends AbuseFilterView {
'default' => $furtherOptions
];
- // ToDo: Since this is only for saving space, we should convert it to use a 'hide-if'
- if ( $searchEnabled ) {
+ if ( AbuseFilter::canViewPrivate( $user ) ) {
+ $globalEnabled = $centralDB !== null && !$dbIsCentral;
$formDescriptor['querypattern'] = [
'name' => 'querypattern',
'type' => 'text',
+ 'hide-if' => $globalEnabled ? [ '===', 'rulescope', 'global' ] : [],
'label-message' => 'abusefilter-list-options-searchfield',
'placeholder' => $this->msg( 'abusefilter-list-options-searchpattern' )->text(),
'default' => $querypattern
@@ -239,6 +227,9 @@ class AbuseFilterViewList extends AbuseFilterView {
'type' => 'radio',
'flatlist' => true,
'label-message' => 'abusefilter-list-options-searchoptions',
+ 'hide-if' => $globalEnabled ?
+ [ 'OR', [ '===', 'querypattern', '' ], $formDescriptor['querypattern']['hide-if'] ] :
+ [ '===', 'querypattern', '' ],
'options-messages' => [
'abusefilter-list-options-search-like' => 'LIKE',
'abusefilter-list-options-search-rlike' => 'RLIKE',
@@ -265,37 +256,39 @@ class AbuseFilterViewList extends AbuseFilterView {
->prepareForm()
->displayForm( false );
- $this->getOutput()->addHTML(
- $pager->getNavigationBar() .
- $pager->getBody() .
- $pager->getNavigationBar()
- );
+ $this->getOutput()->addParserOutputContent( $pager->getFullOutput() );
}
/**
- * Show stats
+ * Generates a summary of filter activity using the internal statistics.
*/
public function showStatus() {
- $stash = ObjectCache::getMainStashInstance();
- $overflow_count = (int)$stash->get( AbuseFilter::filterLimitReachedKey() );
- $match_count = (int)$stash->get( AbuseFilter::filterMatchesKey() );
- $total_count = 0;
+ $stash = MediaWikiServices::getInstance()->getMainObjectStash();
+
+ $totalCount = 0;
+ $matchCount = 0;
+ $overflowCount = 0;
foreach ( $this->getConfig()->get( 'AbuseFilterValidGroups' ) as $group ) {
- $total_count += (int)$stash->get( AbuseFilter::filterUsedKey( $group ) );
+ $profile = $stash->get( AbuseFilter::filterProfileGroupKey( $group ) );
+ if ( $profile !== false ) {
+ $totalCount += $profile[ 'total' ];
+ $overflowCount += $profile[ 'overflow' ];
+ $matchCount += $profile[ 'matches' ];
+ }
}
- if ( $total_count > 0 ) {
- $overflow_percent = sprintf( "%.2f", 100 * $overflow_count / $total_count );
- $match_percent = sprintf( "%.2f", 100 * $match_count / $total_count );
+ if ( $totalCount > 0 ) {
+ $overflowPercent = round( 100 * $overflowCount / $totalCount, 2 );
+ $matchPercent = round( 100 * $matchCount / $totalCount, 2 );
$status = $this->msg( 'abusefilter-status' )
->numParams(
- $total_count,
- $overflow_count,
- $overflow_percent,
+ $totalCount,
+ $overflowCount,
+ $overflowPercent,
$this->getConfig()->get( 'AbuseFilterConditionLimit' ),
- $match_count,
- $match_percent
+ $matchCount,
+ $matchPercent
)->parse();
$status = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-status' ], $status );
diff --git a/AbuseFilter/includes/Views/AbuseFilterViewRevert.php b/AbuseFilter/includes/Views/AbuseFilterViewRevert.php
index 38a0d073..ab873b22 100644
--- a/AbuseFilter/includes/Views/AbuseFilterViewRevert.php
+++ b/AbuseFilter/includes/Views/AbuseFilterViewRevert.php
@@ -1,7 +1,28 @@
<?php
+use MediaWiki\Block\DatabaseBlock;
+use MediaWiki\MediaWikiServices;
+
class AbuseFilterViewRevert extends AbuseFilterView {
- public $origPeriodStart, $origPeriodEnd, $mPeriodStart, $mPeriodEnd;
+ /**
+ * @var string The start time of the lookup period
+ */
+ public $origPeriodStart;
+ /**
+ * @var string The end time of the lookup period
+ */
+ public $origPeriodEnd;
+ /**
+ * @var string|null The same as $origPeriodStart
+ */
+ public $mPeriodStart;
+ /**
+ * @var string|null The same as $origPeriodEnd
+ */
+ public $mPeriodEnd;
+ /**
+ * @var string|null The reason provided for the revert
+ */
public $mReason;
/**
@@ -14,10 +35,17 @@ class AbuseFilterViewRevert extends AbuseFilterView {
$user = $this->getUser();
$out = $this->getOutput();
- if ( !$user->isAllowed( 'abusefilter-revert' ) ) {
+ if ( !MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $user, 'abusefilter-revert' )
+ ) {
throw new PermissionsError( 'abusefilter-revert' );
}
+ $block = $user->getBlock();
+ if ( $block && $block->isSitewide() ) {
+ throw new UserBlockedError( $block );
+ }
+
$this->loadParameters();
if ( $this->attemptRevert() ) {
@@ -33,8 +61,8 @@ class AbuseFilterViewRevert extends AbuseFilterView {
$max = wfTimestampNow();
$filterLink =
$this->linkRenderer->makeLink(
- SpecialPage::getTitleFor( 'AbuseFilter', intval( $filter ) ),
- $lang->formatNum( intval( $filter ) )
+ SpecialPage::getTitleFor( 'AbuseFilter', $filter ),
+ $lang->formatNum( $filter )
);
$searchFields = [];
$searchFields['filterid'] = [
@@ -77,10 +105,12 @@ class AbuseFilterViewRevert extends AbuseFilterView {
$results = $this->doLookup();
$list = [];
+ $context = $this->getContext();
foreach ( $results as $result ) {
- $displayActions = array_map(
- [ 'AbuseFilter', 'getActionDisplay' ],
- $result['actions'] );
+ $displayActions = [];
+ foreach ( $result['actions'] as $action ) {
+ $displayActions[] = AbuseFilter::getActionDisplay( $action, $context );
+ }
$msg = $this->msg( 'abusefilter-revert-preview-item' )
->params(
@@ -146,7 +176,7 @@ class AbuseFilterViewRevert extends AbuseFilterView {
}
/**
- * @return array
+ * @return array[]
*/
public function doLookup() {
$periodStart = $this->mPeriodStart;
@@ -157,14 +187,13 @@ class AbuseFilterViewRevert extends AbuseFilterView {
$dbr = wfGetDB( DB_REPLICA );
- if ( $periodStart ) {
+ if ( $periodStart !== null ) {
$conds[] = 'afl_timestamp >= ' . $dbr->addQuotes( $dbr->timestamp( $periodStart ) );
}
- if ( $periodEnd ) {
+ if ( $periodEnd !== null ) {
$conds[] = 'afl_timestamp <= ' . $dbr->addQuotes( $dbr->timestamp( $periodEnd ) );
}
- // All but afl_filter, afl_ip, afl_deleted, afl_patrolled_by, afl_rev_id and afl_log_id
$selectFields = [
'afl_id',
'afl_user',
@@ -213,10 +242,10 @@ class AbuseFilterViewRevert extends AbuseFilterView {
$request = $this->getRequest();
$this->origPeriodStart = $request->getText( 'wpPeriodStart' );
- $this->mPeriodStart = strtotime( $this->origPeriodStart );
+ $this->mPeriodStart = strtotime( $this->origPeriodStart ) ?: null;
$this->origPeriodEnd = $request->getText( 'wpPeriodEnd' );
- $this->mPeriodEnd = strtotime( $this->origPeriodEnd );
- $this->mSubmit = $request->getVal( 'submit' );
+ $this->mPeriodEnd = strtotime( $this->origPeriodEnd ) ?: null;
+ $this->mSubmit = $request->getBool( 'submit' );
$this->mReason = $request->getVal( 'wpReason' );
}
@@ -258,8 +287,8 @@ class AbuseFilterViewRevert extends AbuseFilterView {
public function revertAction( $action, $result ) {
switch ( $action ) {
case 'block':
- $block = Block::newFromTarget( $result['user'] );
- if ( !( $block && $block->getBy() == AbuseFilter::getFilterUser()->getId() ) ) {
+ $block = DatabaseBlock::newFromTarget( $result['user'] );
+ if ( !( $block && $block->getBy() === AbuseFilter::getFilterUser()->getId() ) ) {
// Not blocked by abuse filter
return false;
}
@@ -275,17 +304,16 @@ class AbuseFilterViewRevert extends AbuseFilterView {
$logEntry->publish( $logEntry->insert() );
return true;
case 'blockautopromote':
- ObjectCache::getMainStashInstance()->delete(
- AbuseFilter::autoPromoteBlockKey( User::newFromId( $result['userid'] ) )
- );
- return true;
+ $target = User::newFromId( $result['userid'] );
+ $msg = $this->msg(
+ 'abusefilter-revert-reason', $this->mPage->mFilter, $this->mReason
+ )->inContentLanguage()->text();
+
+ return AbuseFilter::unblockAutopromote( $target, $this->getUser(), $msg );
case 'degroup':
// Pull the user's groups from the vars.
$oldGroups = $result['vars']->getVar( 'user_groups' )->toNative();
- $oldGroups = array_diff(
- $oldGroups,
- array_intersect( $oldGroups, User::getImplicitGroups() )
- );
+ $oldGroups = array_diff( $oldGroups, User::getImplicitGroups() );
$rows = [];
foreach ( $oldGroups as $group ) {
diff --git a/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php b/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php
index 168933ec..89ae088a 100644
--- a/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php
+++ b/AbuseFilter/includes/Views/AbuseFilterViewTestBatch.php
@@ -1,11 +1,45 @@
<?php
+use MediaWiki\Extension\AbuseFilter\VariableGenerator\RCVariableGenerator;
+
class AbuseFilterViewTestBatch extends AbuseFilterView {
- // Hard-coded for now.
+ /**
+ * @var int The limit of changes to test, hard coded for now
+ */
protected static $mChangeLimit = 100;
- public $mShowNegative, $mTestPeriodStart, $mTestPeriodEnd, $mTestPage;
- public $mTestUser, $mExcludeBots, $mTestAction;
+ /**
+ * @var bool Whether to show changes that don't trigger the specified pattern
+ */
+ public $mShowNegative;
+ /**
+ * @var string The start time of the lookup period
+ */
+ public $mTestPeriodStart;
+ /**
+ * @var string The end time of the lookup period
+ */
+ public $mTestPeriodEnd;
+ /**
+ * @var string The page of which edits we're interested in
+ */
+ public $mTestPage;
+ /**
+ * @var string The user whose actions we want to test
+ */
+ public $mTestUser;
+ /**
+ * @var bool Whether to exclude bot edits
+ */
+ public $mExcludeBots;
+ /**
+ * @var string The action (performed by the user) we want to search for
+ */
+ public $mTestAction;
+ /**
+ * @var string The text of the rule to test changes against
+ */
+ private $testPattern;
/**
* Shows the page
@@ -13,9 +47,7 @@ class AbuseFilterViewTestBatch extends AbuseFilterView {
public function show() {
$out = $this->getOutput();
- AbuseFilter::disableConditionLimit();
-
- if ( !$this->canViewPrivate() ) {
+ if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) {
$out->addWikiMsg( 'abusefilter-mustviewprivateoredit' );
return;
}
@@ -29,14 +61,13 @@ class AbuseFilterViewTestBatch extends AbuseFilterView {
$output = '';
$output .=
$this->buildEditBox(
- $this->mFilter,
- 'wpTestFilter',
+ $this->testPattern,
true,
true,
false
) . "\n";
- $output .= AbuseFilter::buildFilterLoader();
+ $output .= $this->buildFilterLoader();
$output = Xml::tags( 'div', [ 'id' => 'mw-abusefilter-test-editor' ], $output );
$RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
@@ -54,8 +85,8 @@ class AbuseFilterViewTestBatch extends AbuseFilterView {
$this->msg( 'abusefilter-test-search-type-edit' )->text() => 'edit',
$this->msg( 'abusefilter-test-search-type-move' )->text() => 'move',
$this->msg( 'abusefilter-test-search-type-delete' )->text() => 'delete',
- $this->msg( 'abusefilter-test-search-type-createaccount' )->text() => 'createaccount'
- // @ToDo: add 'upload' once T170249 is resolved
+ $this->msg( 'abusefilter-test-search-type-createaccount' )->text() => 'createaccount',
+ $this->msg( 'abusefilter-test-search-type-upload' )->text() => 'upload'
]
];
$formFields['wpTestUser'] = [
@@ -126,8 +157,10 @@ class AbuseFilterViewTestBatch extends AbuseFilterView {
public function doTest() {
// Quick syntax check.
$out = $this->getOutput();
- $result = AbuseFilter::checkSyntax( $this->mFilter );
- if ( $result !== true ) {
+ $parser = AbuseFilter::getDefaultParser();
+
+ $validSyntax = $parser->checkSyntax( $this->testPattern );
+ if ( $validSyntax !== true ) {
$out->addWikiMsg( 'abusefilter-test-syntaxerr' );
return;
}
@@ -160,18 +193,15 @@ class AbuseFilterViewTestBatch extends AbuseFilterView {
}
}
- $action = $this->mTestAction != '0' ? $this->mTestAction : false;
- $conds[] = $this->buildTestConditions( $dbr, $action );
-
- $conds = array_filter( $conds );
-
- // To be added after filtering, otherwise it gets stripped
if ( $this->mExcludeBots ) {
$conds['rc_bot'] = 0;
}
+ $action = $this->mTestAction !== '0' ? $this->mTestAction : false;
+ $conds[] = $this->buildTestConditions( $dbr, $action );
+
// Get our ChangesList
- $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->mFilter );
+ $changesList = new AbuseFilterChangesList( $this->getSkin(), $this->testPattern );
$output = $changesList->beginRecentChangesList();
$rcQuery = RecentChange::getQueryInfo();
@@ -186,19 +216,24 @@ class AbuseFilterViewTestBatch extends AbuseFilterView {
$counter = 1;
+ $contextUser = $this->getUser();
+ $parser->toggleConditionLimit( false );
foreach ( $res as $row ) {
- $vars = AbuseFilter::getVarsFromRCRow( $row );
+ $vars = new AbuseFilterVariableHolder();
+ $rc = RecentChange::newFromRow( $row );
+ $varGenerator = new RCVariableGenerator( $vars, $rc, $contextUser );
+ $vars = $varGenerator->getVars();
if ( !$vars ) {
continue;
}
- $result = AbuseFilter::checkConditions( $this->mFilter, $vars );
+ $parser->setVariables( $vars );
+ $result = $parser->checkConditions( $this->testPattern );
if ( $result || $this->mShowNegative ) {
// Stash result in RC item
- $rc = RecentChange::newFromRow( $row );
- /** @suppress PhanUndeclaredProperty for $rc->filterResult, which isn't a big deal */
+ // @phan-suppress-next-line PhanUndeclaredProperty not a big deal
$rc->filterResult = $result;
$rc->counter = $counter++;
$output .= $changesList->recentChangesLine( $rc, false );
@@ -216,7 +251,7 @@ class AbuseFilterViewTestBatch extends AbuseFilterView {
public function loadParameters() {
$request = $this->getRequest();
- $this->mFilter = $request->getText( 'wpTestFilter' );
+ $this->testPattern = $request->getText( 'wpFilterRules' );
$this->mShowNegative = $request->getBool( 'wpShowNegative' );
$testUsername = $request->getText( 'wpTestUser' );
$this->mTestPeriodEnd = $request->getText( 'wpTestPeriodEnd' );
@@ -225,12 +260,12 @@ class AbuseFilterViewTestBatch extends AbuseFilterView {
$this->mExcludeBots = $request->getBool( 'wpExcludeBots' );
$this->mTestAction = $request->getText( 'wpTestAction' );
- if ( !$this->mFilter
+ if ( !$this->testPattern
&& count( $this->mParams ) > 1
&& is_numeric( $this->mParams[1] )
) {
$dbr = wfGetDB( DB_REPLICA );
- $this->mFilter = $dbr->selectField( 'abuse_filter',
+ $this->testPattern = $dbr->selectField( 'abuse_filter',
'af_pattern',
[ 'af_id' => $this->mParams[1] ],
__METHOD__
diff --git a/AbuseFilter/includes/Views/AbuseFilterViewTools.php b/AbuseFilter/includes/Views/AbuseFilterViewTools.php
index 44ee0eb4..70c1091f 100644
--- a/AbuseFilter/includes/Views/AbuseFilterViewTools.php
+++ b/AbuseFilter/includes/Views/AbuseFilterViewTools.php
@@ -9,7 +9,7 @@ class AbuseFilterViewTools extends AbuseFilterView {
$out->enableOOUI();
$request = $this->getRequest();
- if ( !$this->canViewPrivate() ) {
+ if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) {
$out->addWikiMsg( 'abusefilter-mustviewprivateoredit' );
return;
}
@@ -20,8 +20,7 @@ class AbuseFilterViewTools extends AbuseFilterView {
// Expression evaluator
$eval = '';
$eval .= $this->buildEditBox(
- $request->getText( 'wpTestExpr' ),
- 'wpTestExpr',
+ $request->getText( 'wpFilterRules' ),
true,
false,
false
@@ -31,32 +30,35 @@ class AbuseFilterViewTools extends AbuseFilterView {
Xml::tags( 'p', null,
new OOUI\ButtonInputWidget( [
'label' => $this->msg( 'abusefilter-tools-submitexpr' )->text(),
- 'id' => 'mw-abusefilter-submitexpr'
+ 'id' => 'mw-abusefilter-submitexpr',
+ 'flags' => [ 'primary', 'progressive' ]
] )
);
- $eval .= Xml::element( 'p', [ 'id' => 'mw-abusefilter-expr-result' ], ' ' );
+ $eval .= Xml::element( 'pre', [ 'id' => 'mw-abusefilter-expr-result' ], ' ' );
$eval = Xml::fieldset( $this->msg( 'abusefilter-tools-expr' )->text(), $eval );
$out->addHTML( $eval );
$out->addModules( 'ext.abuseFilter.tools' );
- // Hacky little box to re-enable autoconfirmed if it got disabled
- $formDescriptor = [
- 'RestoreAutoconfirmed' => [
- 'label-message' => 'abusefilter-tools-reautoconfirm-user',
- 'type' => 'user',
- 'name' => 'wpReAutoconfirmUser',
- 'id' => 'reautoconfirm-user',
- 'infusable' => true
- ],
- ];
- $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
- $htmlForm->setWrapperLegendMsg( 'abusefilter-tools-reautoconfirm' )
- ->setSubmitTextMsg( 'abusefilter-tools-reautoconfirm-submit' )
- ->setSubmitName( 'wpReautoconfirmSubmit' )
- ->setSubmitId( 'mw-abusefilter-reautoconfirmsubmit' )
- ->prepareForm()
- ->displayForm( false );
+ if ( AbuseFilter::canEdit( $this->getUser() ) ) {
+ // Hacky little box to re-enable autoconfirmed if it got disabled
+ $formDescriptor = [
+ 'RestoreAutoconfirmed' => [
+ 'label-message' => 'abusefilter-tools-reautoconfirm-user',
+ 'type' => 'user',
+ 'name' => 'wpReAutoconfirmUser',
+ 'id' => 'reautoconfirm-user',
+ 'infusable' => true
+ ],
+ ];
+ $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
+ $htmlForm->setWrapperLegendMsg( 'abusefilter-tools-reautoconfirm' )
+ ->setSubmitTextMsg( 'abusefilter-tools-reautoconfirm-submit' )
+ ->setSubmitName( 'wpReautoconfirmSubmit' )
+ ->setSubmitId( 'mw-abusefilter-reautoconfirmsubmit' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
}
}
diff --git a/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php b/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php
index 9d4fd8c3..517ea804 100644
--- a/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php
+++ b/AbuseFilter/includes/api/ApiAbuseFilterCheckMatch.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\Extension\AbuseFilter\VariableGenerator\RCVariableGenerator;
+
class ApiAbuseFilterCheckMatch extends ApiBase {
/**
* @see ApiBase::execute
@@ -9,34 +11,25 @@ class ApiAbuseFilterCheckMatch extends ApiBase {
$this->requireOnlyOneParameter( $params, 'vars', 'rcid', 'logid' );
// "Anti-DoS"
- if ( !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) {
+ if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) {
$this->dieWithError( 'apierror-abusefilter-canttest', 'permissiondenied' );
}
$vars = null;
if ( $params['vars'] ) {
- $vars = new AbuseFilterVariableHolder;
$pairs = FormatJson::decode( $params['vars'], true );
- foreach ( $pairs as $name => $value ) {
- $vars->setVar( $name, $value );
- }
+ $vars = AbuseFilterVariableHolder::newFromArray( $pairs );
} elseif ( $params['rcid'] ) {
- $dbr = wfGetDB( DB_REPLICA );
- $rcQuery = RecentChange::getQueryInfo();
- $row = $dbr->selectRow(
- $rcQuery['tables'],
- $rcQuery['fields'],
- [ 'rc_id' => $params['rcid'] ],
- __METHOD__,
- [],
- $rcQuery['joins']
- );
+ $rc = RecentChange::newFromId( $params['rcid'] );
- if ( !$row ) {
+ if ( !$rc ) {
$this->dieWithError( [ 'apierror-nosuchrcid', $params['rcid'] ] );
}
- $vars = AbuseFilter::getVarsFromRCRow( $row );
+ $vars = new AbuseFilterVariableHolder();
+ // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
+ $varGenerator = new RCVariableGenerator( $vars, $rc, $this->getUser() );
+ $vars = $varGenerator->getVars();
} elseif ( $params['logid'] ) {
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
@@ -52,14 +45,19 @@ class ApiAbuseFilterCheckMatch extends ApiBase {
$vars = AbuseFilter::loadVarDump( $row->afl_var_dump );
}
+ if ( $vars === null ) {
+ throw new LogicException( 'Impossible.' );
+ }
- if ( AbuseFilter::checkSyntax( $params[ 'filter' ] ) !== true ) {
+ $parser = AbuseFilter::getDefaultParser();
+ if ( $parser->checkSyntax( $params[ 'filter' ] ) !== true ) {
$this->dieWithError( 'apierror-abusefilter-badsyntax', 'badsyntax' );
}
+ $parser->setVariables( $vars );
$result = [
ApiResult::META_BC_BOOLS => [ 'result' ],
- 'result' => AbuseFilter::checkConditions( $params['filter'], $vars ),
+ 'result' => $parser->checkConditions( $params['filter'] ),
];
$this->getResult()->addValue(
diff --git a/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php b/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php
index 213fb904..819906ac 100644
--- a/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php
+++ b/AbuseFilter/includes/api/ApiAbuseFilterCheckSyntax.php
@@ -7,12 +7,12 @@ class ApiAbuseFilterCheckSyntax extends ApiBase {
*/
public function execute() {
// "Anti-DoS"
- if ( !$this->getUser()->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' ) ) {
+ if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) {
$this->dieWithError( 'apierror-abusefilter-cantcheck', 'permissiondenied' );
}
$params = $this->extractRequestParams();
- $result = AbuseFilter::checkSyntax( $params[ 'filter' ] );
+ $result = AbuseFilter::getDefaultParser()->checkSyntax( $params[ 'filter' ] );
$r = [];
if ( $result === true ) {
diff --git a/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php b/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php
index 18701670..5b707012 100644
--- a/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php
+++ b/AbuseFilter/includes/api/ApiAbuseFilterEvalExpression.php
@@ -1,15 +1,51 @@
<?php
+use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator;
+
class ApiAbuseFilterEvalExpression extends ApiBase {
/**
* @see ApiBase::execute()
*/
public function execute() {
+ // "Anti-DoS"
+ if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) {
+ $this->dieWithError( 'apierror-abusefilter-canteval', 'permissiondenied' );
+ }
+
$params = $this->extractRequestParams();
- $result = AbuseFilter::evaluateExpression( $params['expression'] );
+ $status = $this->evaluateExpression( $params['expression'] );
+ if ( !$status->isGood() ) {
+ $this->dieWithError( $status->getErrors()[0] );
+ } else {
+ $res = $status->getValue();
+ $res = $params['prettyprint'] ? AbuseFilter::formatVar( $res ) : $res;
+ $this->getResult()->addValue(
+ null,
+ $this->getModuleName(),
+ ApiResult::addMetadataToResultVars( [ 'result' => $res ] )
+ );
+ }
+ }
+
+ /**
+ * @param string $expr
+ * @return Status
+ */
+ private function evaluateExpression( string $expr ) : Status {
+ $parser = AbuseFilter::getDefaultParser();
+ if ( $parser->checkSyntax( $expr ) !== true ) {
+ return Status::newFatal( 'abusefilter-tools-syntax-error' );
+ }
+
+ $vars = new AbuseFilterVariableHolder();
+ // Generic vars are the only ones available
+ $generator = new VariableGenerator( $vars );
+ $vars = $generator->addGenericVars()->getVariableHolder();
+ $vars->setVar( 'timestamp', wfTimestamp( TS_UNIX ) );
+ $parser->setVariables( $vars );
- $this->getResult()->addValue( null, $this->getModuleName(), [ 'result' => $result ] );
+ return Status::newGood( $parser->evaluateExpression( $expr ) );
}
/**
@@ -21,6 +57,9 @@ class ApiAbuseFilterEvalExpression extends ApiBase {
'expression' => [
ApiBase::PARAM_REQUIRED => true,
],
+ 'prettyprint' => [
+ ApiBase::PARAM_TYPE => 'boolean'
+ ]
];
}
@@ -32,6 +71,8 @@ class ApiAbuseFilterEvalExpression extends ApiBase {
return [
'action=abusefilterevalexpression&expression=lcase("FOO")'
=> 'apihelp-abusefilterevalexpression-example-1',
+ 'action=abusefilterevalexpression&expression=lcase("FOO")&prettyprint=1'
+ => 'apihelp-abusefilterevalexpression-example-2',
];
}
}
diff --git a/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php b/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php
index 195e72d3..e39da558 100644
--- a/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php
+++ b/AbuseFilter/includes/api/ApiAbuseFilterUnblockAutopromote.php
@@ -8,9 +8,9 @@ class ApiAbuseFilterUnblockAutopromote extends ApiBase {
$this->checkUserRightsAny( 'abusefilter-modify' );
$params = $this->extractRequestParams();
- $user = User::newFromName( $params['user'] );
+ $target = User::newFromName( $params['user'] );
- if ( $user === false ) {
+ if ( $target === false ) {
$encParamName = $this->encodeParamName( 'user' );
$this->dieWithError(
[ 'apierror-baduser', $encParamName, wfEscapeWikiText( $params['user'] ) ],
@@ -18,16 +18,20 @@ class ApiAbuseFilterUnblockAutopromote extends ApiBase {
);
}
- $key = AbuseFilter::autoPromoteBlockKey( $user );
- $stash = ObjectCache::getMainStashInstance();
- if ( !$stash->get( $key ) ) {
- $this->dieWithError( [ 'abusefilter-reautoconfirm-none', $user->getName() ], 'notsuspended' );
+ $block = $this->getUser()->getBlock();
+ if ( $block && $block->isSitewide() ) {
+ $this->dieBlocked( $block );
}
- $stash->delete( $key );
+ $msg = $this->msg( 'abusefilter-tools-restoreautopromote' )->inContentLanguage()->text();
+ $res = AbuseFilter::unblockAutopromote( $target, $this->getUser(), $msg );
- $res = [ 'user' => $params['user'] ];
- $this->getResult()->addValue( null, $this->getModuleName(), $res );
+ if ( $res === false ) {
+ $this->dieWithError( [ 'abusefilter-reautoconfirm-none', $target->getName() ], 'notsuspended' );
+ }
+
+ $finalResult = [ 'user' => $params['user'] ];
+ $this->getResult()->addValue( null, $this->getModuleName(), $finalResult );
}
/**
diff --git a/AbuseFilter/includes/api/ApiAbuseLogPrivateDetails.php b/AbuseFilter/includes/api/ApiAbuseLogPrivateDetails.php
new file mode 100644
index 00000000..0554c942
--- /dev/null
+++ b/AbuseFilter/includes/api/ApiAbuseLogPrivateDetails.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * API module to allow accessing private details (the user's IP) from AbuseLog entries
+ *
+ * @ingroup API
+ * @ingroup Extensions
+ */
+class ApiAbuseLogPrivateDetails extends ApiBase {
+ /**
+ * @inheritDoc
+ */
+ public function mustBePosted() {
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function isWriteMode() {
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function execute() {
+ $user = $this->getUser();
+
+ if ( !SpecialAbuseLog::canSeePrivateDetails( $user ) ) {
+ $this->dieWithError( 'abusefilter-log-cannot-see-privatedetails' );
+ }
+ $params = $this->extractRequestParams();
+
+ if ( !SpecialAbuseLog::checkPrivateDetailsAccessReason( $params['reason'] ) ) {
+ // Double check, in case we add some extra validation
+ $this->dieWithError( 'abusefilter-noreason' );
+ }
+ $status = SpecialAbuseLog::getPrivateDetailsRow( $user, $params['logid'] );
+ if ( !$status->isGood() ) {
+ $this->dieWithError( $status->getErrors()[0] );
+ }
+ $row = $status->getValue();
+ // Log accessing private details
+ if ( $this->getConfig()->get( 'AbuseFilterLogPrivateDetailsAccess' ) ) {
+ SpecialAbuseLog::addPrivateDetailsAccessLogEntry(
+ $params['logid'],
+ $params['reason'],
+ $user
+ );
+ }
+
+ $result = [
+ 'log-id' => $params['logid'],
+ 'user' => $row->afl_user_text,
+ 'filter-id' => $row->af_id,
+ 'filter-description' => $row->af_public_comments,
+ 'ip-address' => $row->afl_ip !== '' ? $row->afl_ip : null
+ ];
+ $this->getResult()->addValue( null, $this->getModuleName(), $result );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getAllowedParams() {
+ return [
+ 'logid' => [
+ ApiBase::PARAM_TYPE => 'integer'
+ ],
+ 'reason' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => $this->getConfig()->get( 'AbuseFilterPrivateDetailsForceReason' ),
+ ]
+ ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getExamplesMessages() {
+ return [
+ 'action=abuselogprivatedetails&logid=1&reason=example&token=ABC123'
+ => 'apihelp-abuselogprivatedetails-example-1'
+ ];
+ }
+}
diff --git a/AbuseFilter/includes/api/ApiQueryAbuseFilters.php b/AbuseFilter/includes/api/ApiQueryAbuseFilters.php
index cafe20d1..54e28997 100644
--- a/AbuseFilter/includes/api/ApiQueryAbuseFilters.php
+++ b/AbuseFilter/includes/api/ApiQueryAbuseFilters.php
@@ -34,7 +34,7 @@ class ApiQueryAbuseFilters extends ApiQueryBase {
* @param ApiQuery $query
* @param string $moduleName
*/
- public function __construct( $query, $moduleName ) {
+ public function __construct( ApiQuery $query, $moduleName ) {
parent::__construct( $query, $moduleName, 'abf' );
}
@@ -79,7 +79,7 @@ class ApiQueryAbuseFilters extends ApiQueryBase {
$this->addWhereRange( 'af_id', $params['dir'], $params['startid'], $params['endid'] );
- if ( !is_null( $params['show'] ) ) {
+ if ( $params['show'] !== null ) {
$show = array_flip( $params['show'] );
/* Check for conflicting parameters. */
@@ -100,18 +100,19 @@ class ApiQueryAbuseFilters extends ApiQueryBase {
$res = $this->select( __METHOD__ );
- $showhidden = $user->isAllowedAny( 'abusefilter-modify', 'abusefilter-view-private' );
+ $showhidden = AbuseFilter::canViewPrivate( $user );
$count = 0;
foreach ( $res as $row ) {
+ $filterId = intval( $row->af_id );
if ( ++$count > $params['limit'] ) {
// We've had enough
- $this->setContinueEnumParameter( 'startid', $row->af_id );
+ $this->setContinueEnumParameter( 'startid', $filterId );
break;
}
$entry = [];
if ( $fld_id ) {
- $entry['id'] = intval( $row->af_id );
+ $entry['id'] = $filterId;
}
if ( $fld_desc ) {
$entry['description'] = $row->af_public_comments;
@@ -149,7 +150,7 @@ class ApiQueryAbuseFilters extends ApiQueryBase {
if ( $entry ) {
$fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry );
if ( !$fit ) {
- $this->setContinueEnumParameter( 'startid', $row->af_id );
+ $this->setContinueEnumParameter( 'startid', $filterId );
break;
}
}
diff --git a/AbuseFilter/includes/api/ApiQueryAbuseLog.php b/AbuseFilter/includes/api/ApiQueryAbuseLog.php
index bf085318..0a088b22 100644
--- a/AbuseFilter/includes/api/ApiQueryAbuseLog.php
+++ b/AbuseFilter/includes/api/ApiQueryAbuseLog.php
@@ -23,6 +23,9 @@
* http://www.gnu.org/copyleft/gpl.html
*/
+use MediaWiki\MediaWikiServices;
+use Wikimedia\IPUtils;
+
/**
* Query module to list abuse log entries.
*
@@ -34,22 +37,18 @@ class ApiQueryAbuseLog extends ApiQueryBase {
* @param ApiQuery $query
* @param string $moduleName
*/
- public function __construct( $query, $moduleName ) {
+ public function __construct( ApiQuery $query, $moduleName ) {
parent::__construct( $query, $moduleName, 'afl' );
}
/**
- * @see ApiQueryBase::execute()
+ * @inheritDoc
*/
public function execute() {
- $user = $this->getUser();
- $errors = $this->getTitle()->getUserPermissionsErrors(
- 'abusefilter-log', $user, true, [ 'ns-specialprotected' ] );
- if ( count( $errors ) ) {
- $this->dieStatus( $this->errorArrayToStatus( $errors ) );
- return;
- }
+ // Same check as in SpecialAbuseLog
+ $this->checkUserRightsAny( 'abusefilter-log' );
+ $user = $this->getUser();
$params = $this->extractRequestParams();
$prop = array_flip( $params['prop'] );
@@ -71,14 +70,16 @@ class ApiQueryAbuseLog extends ApiQueryBase {
}
// Match permissions for viewing events on private filters to SpecialAbuseLog (bug 42814)
if ( $params['filter'] &&
- !( AbuseFilterView::canViewPrivate() || $user->isAllowed( 'abusefilter-log-private' ) )
+ !( AbuseFilter::canViewPrivate( $user ) ||
+ $this->getPermissionManager()->userHasRight( $user, 'abusefilter-log-private' ) )
) {
// A specific filter parameter is set but the user isn't allowed to view all filters
if ( !is_array( $params['filter'] ) ) {
$params['filter'] = [ $params['filter'] ];
}
foreach ( $params['filter'] as $filter ) {
- if ( AbuseFilter::filterHidden( $filter ) ) {
+ list( $filterID, $global ) = AbuseFilter::splitGlobalName( $filter );
+ if ( AbuseFilter::filterHidden( $filterID, $global ) ) {
$this->dieWithError(
[ 'apierror-permissiondenied', $this->msg( 'action-abusefilter-log-private' ) ]
);
@@ -114,18 +115,15 @@ class ApiQueryAbuseLog extends ApiQueryBase {
$this->addWhereRange( 'afl_timestamp', $params['dir'], $params['start'], $params['end'] );
- $db = $this->getDB();
- $notDeletedCond = SpecialAbuseLog::getNotDeletedCond( $db );
-
if ( isset( $params['user'] ) ) {
$u = User::newFromName( $params['user'] );
if ( $u ) {
// Username normalisation
$params['user'] = $u->getName();
$userId = $u->getId();
- } elseif ( IP::isIPAddress( $params['user'] ) ) {
+ } elseif ( IPUtils::isIPAddress( $params['user'] ) ) {
// It's an IP, sanitize it
- $params['user'] = IP::sanitizeIP( $params['user'] );
+ $params['user'] = IPUtils::sanitizeIP( $params['user'] );
$userId = 0;
}
@@ -141,17 +139,19 @@ class ApiQueryAbuseLog extends ApiQueryBase {
}
}
- $this->addWhereIf( [ 'afl_filter' => $params['filter'] ], isset( $params['filter'] ) );
- $this->addWhereIf( $notDeletedCond, !SpecialAbuseLog::canSeeHidden() );
+ if ( isset( $params['filter'] ) && $params['filter'] !== [] ) {
+ $this->addWhere( [ 'afl_filter' => $params['filter'] ] );
+ }
+ $this->addWhereIf( [ 'afl_deleted' => 0 ], !SpecialAbuseLog::canSeeHidden( $user ) );
if ( isset( $params['wiki'] ) ) {
// 'wiki' won't be set if $wgAbuseFilterIsCentral = false
$this->addWhereIf( [ 'afl_wiki' => $params['wiki'] ], $isCentral );
}
$title = $params['title'];
- if ( !is_null( $title ) ) {
+ if ( $title !== null ) {
$titleObj = Title::newFromText( $title );
- if ( is_null( $titleObj ) ) {
+ if ( $titleObj === null ) {
$this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] );
}
$this->addWhereFld( 'afl_namespace', $titleObj->getNamespace() );
@@ -168,15 +168,19 @@ class ApiQueryAbuseLog extends ApiQueryBase {
break;
}
$hidden = SpecialAbuseLog::isHidden( $row );
- if ( $hidden === true && !SpecialAbuseLog::canSeeHidden() ) {
+ if ( $hidden === true && !SpecialAbuseLog::canSeeHidden( $user ) ) {
continue;
- } elseif ( $hidden === 'implicit' ) {
- $rev = Revision::newFromId( $row->afl_rev_id );
- if ( !$rev->userCan( Revision::SUPPRESSED_ALL, $user ) ) {
+ }
+ if ( $hidden === 'implicit' ) {
+ $revRec = MediaWikiServices::getInstance()
+ ->getRevisionLookup()
+ ->getRevisionById( (int)$row->afl_rev_id );
+ if ( !AbuseFilter::userCanViewRev( $revRec, $user ) ) {
continue;
}
}
- $canSeeDetails = SpecialAbuseLog::canSeeDetails( $row->afl_filter );
+ list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter );
+ $canSeeDetails = SpecialAbuseLog::canSeeDetails( $user, $filterID, $global );
$entry = [];
if ( $fld_ids ) {
@@ -184,9 +188,8 @@ class ApiQueryAbuseLog extends ApiQueryBase {
$entry['filter_id'] = $canSeeDetails ? $row->afl_filter : '';
}
if ( $fld_filter ) {
- $globalIndex = AbuseFilter::decodeGlobalName( $row->afl_filter );
- if ( $globalIndex ) {
- $entry['filter'] = AbuseFilter::getGlobalFilterDescription( $globalIndex );
+ if ( $global ) {
+ $entry['filter'] = AbuseFilter::getGlobalFilterDescription( $filterID );
} else {
$entry['filter'] = $row->af_public_comments;
}
@@ -207,7 +210,7 @@ class ApiQueryAbuseLog extends ApiQueryBase {
if ( $fld_result ) {
$entry['result'] = $row->afl_actions;
}
- if ( $fld_revid && !is_null( $row->afl_rev_id ) ) {
+ if ( $fld_revid && $row->afl_rev_id !== null ) {
$entry['revid'] = $canSeeDetails ? $row->afl_rev_id : '';
}
if ( $fld_timestamp ) {
@@ -218,11 +221,7 @@ class ApiQueryAbuseLog extends ApiQueryBase {
$entry['details'] = [];
if ( $canSeeDetails ) {
$vars = AbuseFilter::loadVarDump( $row->afl_var_dump );
- if ( $vars instanceof AbuseFilterVariableHolder ) {
- $entry['details'] = $vars->exportAllVars();
- } else {
- $entry['details'] = array_change_key_case( $vars, CASE_LOWER );
- }
+ $entry['details'] = $vars->exportAllVars();
}
}
@@ -269,7 +268,11 @@ class ApiQueryAbuseLog extends ApiQueryBase {
'title' => null,
'filter' => [
ApiBase::PARAM_TYPE => 'string',
- ApiBase::PARAM_ISMULTI => true
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_HELP_MSG => [
+ 'apihelp-query+abuselog-param-filter',
+ AbuseFilter::GLOBAL_FILTER_PREFIX
+ ]
],
'limit' => [
ApiBase::PARAM_DFLT => 10,
@@ -301,6 +304,7 @@ class ApiQueryAbuseLog extends ApiQueryBase {
];
$params['prop'][ApiBase::PARAM_DFLT] .= '|wiki';
$params['prop'][ApiBase::PARAM_TYPE][] = 'wiki';
+ $params['filter'][ApiBase::PARAM_HELP_MSG] = 'apihelp-query+abuselog-param-filter-central';
}
return $params;
}
diff --git a/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php b/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php
index 02b13755..cbae7a9d 100644
--- a/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php
+++ b/AbuseFilter/includes/pagers/AbuseFilterExaminePager.php
@@ -1,13 +1,20 @@
<?php
class AbuseFilterExaminePager extends ReverseChronologicalPager {
- public $mChangesList, $mPage;
+ /**
+ * @var AbuseFilterChangesList Our changes list
+ */
+ public $mChangesList;
+ /**
+ * @var AbuseFilterViewExamine The associated view
+ */
+ public $mPage;
/**
* @param AbuseFilterViewExamine $page
* @param AbuseFilterChangesList $changesList
*/
- public function __construct( $page, $changesList ) {
+ public function __construct( AbuseFilterViewExamine $page, AbuseFilterChangesList $changesList ) {
parent::__construct();
$this->mChangesList = $changesList;
$this->mPage = $page;
diff --git a/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php b/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php
index 265c97f1..550b6fab 100644
--- a/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php
+++ b/AbuseFilter/includes/pagers/AbuseFilterHistoryPager.php
@@ -3,22 +3,32 @@
use MediaWiki\Linker\LinkRenderer;
class AbuseFilterHistoryPager extends TablePager {
- public $mFilter, $mPage, $mUser;
+ /**
+ * @var int The filter ID
+ */
+ public $mFilter;
+ /**
+ * @var AbuseFilterViewHistory The associated page
+ */
+ public $mPage;
+ /**
+ * @var string The user whose changes we're looking up for
+ */
+ public $mUser;
- protected $linkRenderer;
/**
- * @param string $filter
+ * @param int $filter
* @param AbuseFilterViewHistory $page
* @param string $user User name
* @param LinkRenderer $linkRenderer
*/
- public function __construct( $filter, $page, $user, $linkRenderer ) {
+ public function __construct( int $filter, AbuseFilterViewHistory $page, $user,
+ LinkRenderer $linkRenderer ) {
+ parent::__construct( $page->getContext(), $linkRenderer );
$this->mFilter = $filter;
$this->mPage = $page;
$this->mUser = $user;
$this->mDefaultDirection = true;
- $this->linkRenderer = $linkRenderer;
- parent::__construct( $this->mPage->getContext() );
}
/**
@@ -44,7 +54,6 @@ class AbuseFilterHistoryPager extends TablePager {
if ( !$this->mFilter ) {
// awful hack
$headers = [ 'afh_filter' => 'abusefilter-history-filterid' ] + $headers;
- unset( $headers['afh_comments'] );
}
foreach ( $headers as &$msg ) {
@@ -61,20 +70,21 @@ class AbuseFilterHistoryPager extends TablePager {
*/
public function formatValue( $name, $value ) {
$lang = $this->getLanguage();
+ $linkRenderer = $this->getLinkRenderer();
$row = $this->mCurrentRow;
switch ( $name ) {
case 'afh_filter':
- $formatted = $this->linkRenderer->makeLink(
- SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->afh_filter ) ),
+ $formatted = $linkRenderer->makeLink(
+ SpecialPage::getTitleFor( 'AbuseFilter', $row->afh_filter ),
$lang->formatNum( $row->afh_filter )
);
break;
case 'afh_timestamp':
$title = SpecialPage::getTitleFor( 'AbuseFilter',
'history/' . $row->afh_filter . '/item/' . $row->afh_id );
- $formatted = $this->linkRenderer->makeLink(
+ $formatted = $linkRenderer->makeLink(
$title,
$lang->timeanddate( $row->afh_timestamp, true )
);
@@ -88,7 +98,7 @@ class AbuseFilterHistoryPager extends TablePager {
$formatted = htmlspecialchars( $value, ENT_QUOTES, 'UTF-8', false );
break;
case 'afh_flags':
- $formatted = AbuseFilter::formatFlags( $value );
+ $formatted = AbuseFilter::formatFlags( $value, $lang );
break;
case 'afh_actions':
$actions = unserialize( $value );
@@ -96,7 +106,7 @@ class AbuseFilterHistoryPager extends TablePager {
$display_actions = '';
foreach ( $actions as $action => $parameters ) {
- $displayAction = AbuseFilter::formatAction( $action, $parameters );
+ $displayAction = AbuseFilter::formatAction( $action, $parameters, $lang );
$display_actions .= Xml::tags( 'li', null, $displayAction );
}
$display_actions = Xml::tags( 'ul', null, $display_actions );
@@ -104,15 +114,36 @@ class AbuseFilterHistoryPager extends TablePager {
$formatted = $display_actions;
break;
case 'afh_id':
+ // Set a link to a diff with the previous version if this isn't the first edit to the filter.
+ // Like in AbuseFilterViewDiff, don't show it if the user cannot see private filters and any
+ // of the versions is hidden.
$formatted = '';
if ( AbuseFilter::getFirstFilterChange( $row->afh_filter ) != $value ) {
- // Set a link to a diff with the previous version if this isn't the first edit to the filter
- $title = $this->mPage->getTitle(
- 'history/' . $row->afh_filter . "/diff/prev/$value" );
- $formatted = $this->linkRenderer->makeLink(
- $title,
- new HtmlArmor( $this->msg( 'abusefilter-history-diff' )->parse() )
+ // @todo This is subpar, it should be cached at least. Should we also hide actions?
+ $dbr = wfGetDB( DB_REPLICA );
+ $oldFlags = $dbr->selectField(
+ 'abuse_filter_history',
+ 'afh_flags',
+ [
+ 'afh_filter' => $row->afh_filter,
+ 'afh_id <' . $dbr->addQuotes( $row->afh_id ),
+ ],
+ __METHOD__,
+ [ 'ORDER BY' => 'afh_id DESC' ]
);
+ if ( AbuseFilter::canViewPrivate( $this->getUser() ) ||
+ (
+ !in_array( 'hidden', explode( ',', $row->afh_flags ) ) &&
+ !in_array( 'hidden', explode( ',', $oldFlags ) )
+ )
+ ) {
+ $title = $this->mPage->getTitle(
+ 'history/' . $row->afh_filter . "/diff/prev/$value" );
+ $formatted = $linkRenderer->makeLink(
+ $title,
+ new HtmlArmor( $this->msg( 'abusefilter-history-diff' )->parse() )
+ );
+ }
}
break;
default:
@@ -120,29 +151,6 @@ class AbuseFilterHistoryPager extends TablePager {
break;
}
- $mappings = array_flip( AbuseFilter::$history_mappings ) +
- [ 'afh_actions' => 'actions', 'afh_id' => 'id' ];
- $changed = explode( ',', $row->afh_changed_fields );
-
- $fieldChanged = false;
- if ( $name == 'afh_flags' ) {
- // This is a bit freaky, but it works.
- // Basically, returns true if any of those filters are in the $changed array.
- $filters = [ 'af_enabled', 'af_hidden', 'af_deleted', 'af_global' ];
- if ( count( array_diff( $filters, $changed ) ) < count( $filters ) ) {
- $fieldChanged = true;
- }
- } elseif ( in_array( $mappings[$name], $changed ) ) {
- $fieldChanged = true;
- }
-
- if ( $fieldChanged ) {
- $formatted = Xml::tags( 'div',
- [ 'class' => 'mw-abusefilter-history-changed' ],
- $formatted
- );
- }
-
return $formatted;
}
@@ -185,9 +193,7 @@ class AbuseFilterHistoryPager extends TablePager {
$info['conds']['afh_filter'] = $this->mFilter;
}
- if ( !$this->getUser()->isAllowedAny(
- 'abusefilter-modify', 'abusefilter-view-private' )
- ) {
+ if ( !AbuseFilter::canViewPrivate( $this->getUser() ) ) {
// Hide data the user can't see.
$info['conds']['af_hidden'] = 0;
}
@@ -219,6 +225,36 @@ class AbuseFilterHistoryPager extends TablePager {
}
/**
+ * @see TablePager::getCellAttrs
+ *
+ * @param string $field
+ * @param string $value
+ * @return array
+ */
+ public function getCellAttrs( $field, $value ) {
+ $row = $this->mCurrentRow;
+ $mappings = array_flip( AbuseFilter::HISTORY_MAPPINGS ) +
+ [ 'afh_actions' => 'actions', 'afh_id' => 'id' ];
+ $changed = explode( ',', $row->afh_changed_fields );
+
+ $fieldChanged = false;
+ if ( $field === 'afh_flags' ) {
+ // The field is changed if any of these filters are in the $changed array.
+ $filters = [ 'af_enabled', 'af_hidden', 'af_deleted', 'af_global' ];
+ if ( count( array_intersect( $filters, $changed ) ) ) {
+ $fieldChanged = true;
+ }
+ } elseif ( in_array( $mappings[$field], $changed ) ) {
+ $fieldChanged = true;
+ }
+
+ $class = $fieldChanged ? ' mw-abusefilter-history-changed' : '';
+ $attrs = parent::getCellAttrs( $field, $value );
+ $attrs['class'] .= $class;
+ return $attrs;
+ }
+
+ /**
* Title used for self-links.
*
* @return Title
diff --git a/AbuseFilter/includes/pagers/AbuseFilterPager.php b/AbuseFilter/includes/pagers/AbuseFilterPager.php
index 2092b850..482f5d78 100644
--- a/AbuseFilter/includes/pagers/AbuseFilterPager.php
+++ b/AbuseFilter/includes/pagers/AbuseFilterPager.php
@@ -1,6 +1,7 @@
<?php
use MediaWiki\Linker\LinkRenderer;
+use Wikimedia\AtEase\AtEase;
/**
* Class to build paginated filter list
@@ -8,24 +9,43 @@ use MediaWiki\Linker\LinkRenderer;
class AbuseFilterPager extends TablePager {
/**
- * @var LinkRenderer
+ * @var AbuseFilterViewList The associated page
*/
- protected $linkRenderer;
-
- public $mPage, $mConds, $mQuery;
+ public $mPage;
+ /**
+ * @var array Query WHERE conditions
+ */
+ public $mConds;
+ /**
+ * @var string The pattern being searched
+ */
+ private $mSearchPattern;
+ /**
+ * @var string The pattern search mode (LIKE, RLIKE or IRLIKE)
+ */
+ private $mSearchMode;
/**
* @param AbuseFilterViewList $page
* @param array $conds
* @param LinkRenderer $linkRenderer
- * @param array $query
+ * @param string $searchPattern Empty string if no pattern was specified
+ * @param string $searchMode
*/
- public function __construct( $page, $conds, $linkRenderer, $query ) {
+ public function __construct(
+ AbuseFilterViewList $page,
+ $conds,
+ LinkRenderer $linkRenderer,
+ string $searchPattern,
+ string $searchMode
+ ) {
$this->mPage = $page;
$this->mConds = $conds;
- $this->linkRenderer = $linkRenderer;
- $this->mQuery = $query;
- parent::__construct( $this->mPage->getContext() );
+ $this->mSearchPattern = $searchPattern;
+ $this->mSearchMode = $searchMode;
+ // needs to be at the end, some attributes are needed by methods
+ // called from ancestors' constructors
+ parent::__construct( $page->getContext(), $linkRenderer );
}
/**
@@ -56,6 +76,61 @@ class AbuseFilterPager extends TablePager {
}
/**
+ * @inheritDoc
+ * This is the same as the parent implementation if no search pattern was specified.
+ * Otherwise, it does a query with no limit and then slices the results à la ContribsPager.
+ */
+ public function reallyDoQuery( $offset, $limit, $order ) {
+ if ( !strlen( $this->mSearchPattern ) ) {
+ return parent::reallyDoQuery( $offset, $limit, $order );
+ }
+
+ list( $tables, $fields, $conds, $fname, $options, $join_conds ) =
+ $this->buildQueryInfo( $offset, $limit, $order );
+
+ unset( $options['LIMIT'] );
+ $res = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds );
+
+ $filtered = [];
+ foreach ( $res as $row ) {
+ if ( $this->matchesPattern( $row->af_pattern ) ) {
+ $filtered[ $row->af_id ] = $row;
+ }
+ }
+
+ // sort results and enforce limit like ContribsPager
+ if ( $order === self::QUERY_ASCENDING ) {
+ ksort( $filtered );
+ } else {
+ krsort( $filtered );
+ }
+ $filtered = array_slice( $filtered, 0, $limit );
+ $filtered = array_values( $filtered );
+ return new FakeResultWrapper( $filtered );
+ }
+
+ /**
+ * Check whether $subject matches the given $pattern.
+ *
+ * @param string $subject
+ * @return bool
+ * @throws LogicException
+ */
+ private function matchesPattern( $subject ) {
+ $pattern = $this->mSearchPattern;
+ switch ( $this->mSearchMode ) {
+ case 'RLIKE':
+ return (bool)preg_match( "/$pattern/u", $subject );
+ case 'IRLIKE':
+ return (bool)preg_match( "/$pattern/ui", $subject );
+ case 'LIKE':
+ return mb_stripos( $subject, $pattern ) !== false;
+ default:
+ throw new LogicException( "Unknown search type {$this->mSearchMode}" );
+ }
+ }
+
+ /**
* @see Pager::getFieldNames()
* @return array
*/
@@ -75,11 +150,12 @@ class AbuseFilterPager extends TablePager {
'af_hidden' => 'abusefilter-list-visibility',
];
- if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) {
+ $user = $this->getUser();
+ if ( SpecialAbuseLog::canSeeDetails( $user ) ) {
$headers['af_hit_count'] = 'abusefilter-list-hitcount';
}
- if ( AbuseFilterView::canViewPrivate() && !empty( $this->mQuery[0] ) ) {
+ if ( AbuseFilter::canViewPrivate( $user ) && $this->mSearchPattern !== '' ) {
$headers['af_pattern'] = 'abusefilter-list-pattern';
}
@@ -101,79 +177,29 @@ class AbuseFilterPager extends TablePager {
*/
public function formatValue( $name, $value ) {
$lang = $this->getLanguage();
+ $user = $this->getUser();
+ $linkRenderer = $this->getLinkRenderer();
$row = $this->mCurrentRow;
switch ( $name ) {
case 'af_id':
- return $this->linkRenderer->makeLink(
- SpecialPage::getTitleFor( 'AbuseFilter', intval( $value ) ),
+ return $linkRenderer->makeLink(
+ SpecialPage::getTitleFor( 'AbuseFilter', $value ),
$lang->formatNum( intval( $value ) )
);
case 'af_pattern':
- if ( $this->mQuery[1] === 'LIKE' ) {
- $position = mb_stripos( $row->af_pattern, $this->mQuery[0] );
- if ( $position === false ) {
- // This may happen due to problems with character encoding
- // which aren't easy to solve
- return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) );
- }
- $length = mb_strlen( $this->mQuery[0] );
- } else {
- $regex = '/' . $this->mQuery[0] . '/u';
- if ( $this->mQuery[1] === 'IRLIKE' ) {
- $regex .= 'i';
- }
-
- $matches = [];
- Wikimedia\suppressWarnings();
- $check = preg_match(
- $regex,
- $row->af_pattern,
- $matches
- );
- Wikimedia\restoreWarnings();
- // This may happen in case of catastrophic backtracking
- if ( $check === false ) {
- return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) );
- }
-
- $length = mb_strlen( $matches[0] );
- $position = mb_strpos( $row->af_pattern, $matches[0] );
- }
-
- $remaining = 50 - $length;
- if ( $remaining <= 0 ) {
- // Truncate the filter pattern and only show the first 50 characters of the match
- $pattern = '<b>' .
- htmlspecialchars( mb_substr( $row->af_pattern, $position, 50 ) ) .
- '</b>';
- } else {
- // Center the snippet on the matched string
- $minoffset = max( $position - round( $remaining / 2 ), 0 );
- $pattern = mb_substr( $row->af_pattern, $minoffset, 50 );
- $pattern =
- htmlspecialchars( mb_substr( $pattern, 0, $position - $minoffset ) ) .
- '<b>' .
- htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length ) ) .
- '</b>' .
- htmlspecialchars( mb_substr(
- $pattern,
- $position - $minoffset + $length,
- $remaining - ( $position - $minoffset + $length )
- )
- );
- }
- return $pattern;
+ return $this->getHighlightedPattern( $row );
case 'af_public_comments':
- return $this->linkRenderer->makeLink(
- SpecialPage::getTitleFor( 'AbuseFilter', intval( $row->af_id ) ),
+ return $linkRenderer->makeLink(
+ SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ),
$value
);
case 'af_actions':
$actions = explode( ',', $value );
$displayActions = [];
+ $context = $this->getContext();
foreach ( $actions as $action ) {
- $displayActions[] = AbuseFilter::getActionDisplay( $action );
+ $displayActions[] = AbuseFilter::getActionDisplay( $action, $context );
}
return $lang->commaList( $displayActions );
case 'af_enabled':
@@ -198,10 +224,13 @@ class AbuseFilterPager extends TablePager {
$msg = $value ? 'abusefilter-hidden' : 'abusefilter-unhidden';
return $this->msg( $msg )->parse();
case 'af_hit_count':
- if ( SpecialAbuseLog::canSeeDetails( $row->af_id, $row->af_hidden ) ) {
+ // Global here is used to determine whether the log entry is for an external, global
+ // filter, but all filters shown on Special:AbuseFilter are local.
+ $global = false;
+ if ( SpecialAbuseLog::canSeeDetails( $user, $row->af_id, $global, $row->af_hidden ) ) {
$count_display = $this->msg( 'abusefilter-hitcount' )
->numParams( $value )->text();
- $link = $this->linkRenderer->makeKnownLink(
+ $link = $linkRenderer->makeKnownLink(
SpecialPage::getTitleFor( 'AbuseLog' ),
$count_display,
[],
@@ -239,7 +268,7 @@ class AbuseFilterPager extends TablePager {
)
)->params(
wfEscapeWikiText( $row->af_user_text )
- )->parse();
+ )->parse();
case 'af_group':
return AbuseFilter::nameGroup( $value );
default:
@@ -248,6 +277,65 @@ class AbuseFilterPager extends TablePager {
}
/**
+ * Get the filter pattern with <b> elements surrounding the searched pattern
+ *
+ * @param stdClass $row
+ * @return string
+ */
+ private function getHighlightedPattern( stdClass $row ) {
+ $maxLen = 50;
+ if ( $this->mSearchMode === 'LIKE' ) {
+ $position = mb_stripos( $row->af_pattern, $this->mSearchPattern );
+ $length = mb_strlen( $this->mSearchPattern );
+ } else {
+ $regex = '/' . $this->mSearchPattern . '/u';
+ if ( $this->mSearchMode === 'IRLIKE' ) {
+ $regex .= 'i';
+ }
+
+ $matches = [];
+ AtEase::suppressWarnings();
+ $check = preg_match(
+ $regex,
+ $row->af_pattern,
+ $matches
+ );
+ AtEase::restoreWarnings();
+ // This may happen in case of catastrophic backtracking, or regexps matching
+ // the empty string.
+ if ( $check === false || strlen( $matches[0] ) === 0 ) {
+ return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) );
+ }
+
+ $length = mb_strlen( $matches[0] );
+ $position = mb_strpos( $row->af_pattern, $matches[0] );
+ }
+
+ $remaining = $maxLen - $length;
+ if ( $remaining <= 0 ) {
+ $pattern = '<b>' .
+ htmlspecialchars( mb_substr( $row->af_pattern, $position, $maxLen ) ) .
+ '</b>';
+ } else {
+ // Center the snippet on the matched string
+ $minoffset = max( $position - round( $remaining / 2 ), 0 );
+ $pattern = mb_substr( $row->af_pattern, $minoffset, $maxLen );
+ $pattern =
+ htmlspecialchars( mb_substr( $pattern, 0, $position - $minoffset ) ) .
+ '<b>' .
+ htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length ) ) .
+ '</b>' .
+ htmlspecialchars( mb_substr(
+ $pattern,
+ $position - $minoffset + $length,
+ $remaining - ( $position - $minoffset + $length )
+ )
+ );
+ }
+ return $pattern;
+ }
+
+ /**
* @return string
*/
public function getDefaultSort() {
@@ -258,7 +346,7 @@ class AbuseFilterPager extends TablePager {
* @return string
*/
public function getTableClass() {
- return 'TablePager mw-abusefilter-list-scrollable';
+ return parent::getTableClass() . ' mw-abusefilter-list-scrollable';
}
/**
@@ -288,9 +376,18 @@ class AbuseFilterPager extends TablePager {
'af_hidden',
'af_group',
];
- if ( $this->mPage->getUser()->isAllowed( 'abusefilter-log-detail' ) ) {
+ if ( SpecialAbuseLog::canSeeDetails( $this->getUser() ) ) {
$sortable_fields[] = 'af_hit_count';
+ $sortable_fields[] = 'af_public_comments';
}
return in_array( $name, $sortable_fields );
}
+
+ /**
+ * @see IndexPager::getExtraSortFields
+ * @return array
+ */
+ public function getExtraSortFields() {
+ return [ 'af_enabled' => 'af_deleted' ];
+ }
}
diff --git a/AbuseFilter/includes/pagers/AbuseLogPager.php b/AbuseFilter/includes/pagers/AbuseLogPager.php
index 5c4f1a66..95ae8543 100644
--- a/AbuseFilter/includes/pagers/AbuseLogPager.php
+++ b/AbuseFilter/includes/pagers/AbuseLogPager.php
@@ -1,5 +1,6 @@
<?php
+use MediaWiki\Cache\LinkBatchFactory;
use Wikimedia\Rdbms\IResultWrapper;
class AbuseLogPager extends ReverseChronologicalPager {
@@ -13,14 +14,29 @@ class AbuseLogPager extends ReverseChronologicalPager {
*/
public $mConds;
+ /** @var LinkBatchFactory */
+ private $linkBatchFactory;
+
+ /** @var bool */
+ private $joinWithArchive;
+
/**
* @param SpecialAbuseLog $form
* @param array $conds
+ * @param LinkBatchFactory $linkBatchFactory
+ * @param bool $joinWithArchive
*/
- public function __construct( $form, $conds = [] ) {
+ public function __construct(
+ SpecialAbuseLog $form,
+ array $conds,
+ LinkBatchFactory $linkBatchFactory,
+ bool $joinWithArchive = false
+ ) {
+ parent::__construct( $form->getContext(), $form->getLinkRenderer() );
$this->mForm = $form;
$this->mConds = $conds;
- parent::__construct();
+ $this->linkBatchFactory = $linkBatchFactory;
+ $this->joinWithArchive = $joinWithArchive;
}
/**
@@ -38,21 +54,45 @@ class AbuseLogPager extends ReverseChronologicalPager {
$conds = $this->mConds;
$info = [
- 'tables' => [ 'abuse_filter_log', 'abuse_filter' ],
- 'fields' => '*',
+ 'tables' => [ 'abuse_filter_log', 'abuse_filter', 'revision' ],
+ 'fields' => [
+ $this->mDb->tableName( 'abuse_filter_log' ) . '.*',
+ $this->mDb->tableName( 'abuse_filter' ) . '.*',
+ 'rev_id',
+ ],
'conds' => $conds,
- 'join_conds' =>
- [ 'abuse_filter' =>
+ 'join_conds' => [
+ 'abuse_filter' => [
+ 'LEFT JOIN',
+ 'af_id=afl_filter',
+ ],
+ 'revision' => [
+ 'LEFT JOIN',
[
- 'LEFT JOIN',
- 'af_id=afl_filter',
- ],
+ 'afl_wiki IS NULL',
+ 'afl_rev_id IS NOT NULL',
+ 'rev_id=afl_rev_id',
+ ]
],
+ ],
];
- if ( !$this->mForm->canSeeHidden() ) {
- $db = $this->mDb;
- $info['conds'][] = SpecialAbuseLog::getNotDeletedCond( $db );
+ if ( $this->joinWithArchive ) {
+ $info['tables'][] = 'archive';
+ $info['fields'][] = 'ar_timestamp';
+ $info['join_conds']['archive'] = [
+ 'LEFT JOIN',
+ [
+ 'afl_wiki IS NULL',
+ 'afl_rev_id IS NOT NULL',
+ 'rev_id IS NULL',
+ 'ar_rev_id=afl_rev_id',
+ ]
+ ];
+ }
+
+ if ( !$this->mForm->canSeeHidden( $this->getUser() ) ) {
+ $info['conds']['afl_deleted'] = 0;
}
return $info;
@@ -66,7 +106,7 @@ class AbuseLogPager extends ReverseChronologicalPager {
return;
}
- $lb = new LinkBatch();
+ $lb = $this->linkBatchFactory->newLinkBatch();
$lb->setCaller( __METHOD__ );
foreach ( $result as $row ) {
// Only for local wiki results
diff --git a/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php b/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php
index 30173475..9c8033ce 100644
--- a/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php
+++ b/AbuseFilter/includes/pagers/GlobalAbuseFilterPager.php
@@ -11,8 +11,8 @@ class GlobalAbuseFilterPager extends AbuseFilterPager {
* @param array $conds
* @param LinkRenderer $linkRenderer
*/
- public function __construct( $page, $conds, $linkRenderer ) {
- parent::__construct( $page, $conds, $linkRenderer, [ '', 'LIKE' ] );
+ public function __construct( AbuseFilterViewList $page, $conds, LinkRenderer $linkRenderer ) {
+ parent::__construct( $page, $conds, $linkRenderer, '', 'LIKE' );
$this->mDb = wfGetDB(
DB_REPLICA, [], $this->getConfig()->get( 'AbuseFilterCentralDB' ) );
}
@@ -30,12 +30,13 @@ class GlobalAbuseFilterPager extends AbuseFilterPager {
case 'af_id':
return $lang->formatNum( intval( $value ) );
case 'af_public_comments':
- return $this->getOutput()->parseInline( $value );
+ return $this->getOutput()->parseInlineAsInterface( $value );
case 'af_actions':
$actions = explode( ',', $value );
$displayActions = [];
+ $context = $this->getContext();
foreach ( $actions as $action ) {
- $displayActions[] = AbuseFilter::getActionDisplay( $action );
+ $displayActions[] = AbuseFilter::getActionDisplay( $action, $context );
}
return $lang->commaList( $displayActions );
case 'af_enabled':
diff --git a/AbuseFilter/includes/parser/AFPData.php b/AbuseFilter/includes/parser/AFPData.php
index de8d8270..de7c1978 100644
--- a/AbuseFilter/includes/parser/AFPData.php
+++ b/AbuseFilter/includes/parser/AFPData.php
@@ -2,16 +2,23 @@
class AFPData {
// Datatypes
- const DINT = 'int';
- const DSTRING = 'string';
- const DNULL = 'null';
- const DBOOL = 'bool';
- const DFLOAT = 'float';
- const DARRAY = 'array';
-
- // Translation table mapping shell-style wildcards to PCRE equivalents.
- // Derived from <http://www.php.net/manual/en/function.fnmatch.php#100207>
- private static $wildcardMap = [
+ public const DINT = 'int';
+ public const DSTRING = 'string';
+ public const DNULL = 'null';
+ public const DBOOL = 'bool';
+ public const DFLOAT = 'float';
+ public const DARRAY = 'array';
+ // Special purpose type for non-initialized stuff
+ public const DUNDEFINED = 'undefined';
+ // Special purpose for creating instances that will be populated later
+ public const DEMPTY = 'empty';
+
+ /**
+ * Translation table mapping shell-style wildcards to PCRE equivalents.
+ * Derived from <http://www.php.net/manual/en/function.fnmatch.php#100207>
+ * @internal
+ */
+ public const WILDCARD_MAP = [
'\*' => '.*',
'\+' => '\+',
'\-' => '\-',
@@ -23,14 +30,40 @@ class AFPData {
'\]' => ']',
];
+ /**
+ * @var string One of the D* const from this class
+ * @private Use $this->getType()
+ */
public $type;
+ /**
+ * @var mixed|null|AFPData[] The actual data contained in this object
+ * @private Use $this->getData()
+ */
public $data;
/**
+ * @return string
+ */
+ public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * @return AFPData[]|mixed|null
+ */
+ public function getData() {
+ return $this->data;
+ }
+
+ /**
* @param string $type
- * @param mixed|null $val
+ * @param AFPData[]|mixed|null $val
*/
- public function __construct( $type = self::DNULL, $val = null ) {
+ public function __construct( $type, $val = null ) {
+ if ( ( $type === self::DUNDEFINED || $type === self::DEMPTY ) && $val !== null ) {
+ // Sanity
+ throw new InvalidArgumentException( 'DUNDEFINED and DEMPTY cannot have a non-null value' );
+ }
$this->type = $type;
$this->data = $val;
}
@@ -41,61 +74,57 @@ class AFPData {
* @throws AFPException
*/
public static function newFromPHPVar( $var ) {
- if ( is_string( $var ) ) {
- return new AFPData( self::DSTRING, $var );
- } elseif ( is_int( $var ) ) {
- return new AFPData( self::DINT, $var );
- } elseif ( is_float( $var ) ) {
- return new AFPData( self::DFLOAT, $var );
- } elseif ( is_bool( $var ) ) {
- return new AFPData( self::DBOOL, $var );
- } elseif ( is_array( $var ) ) {
- $result = [];
- foreach ( $var as $item ) {
- $result[] = self::newFromPHPVar( $item );
- }
-
- return new AFPData( self::DARRAY, $result );
- } elseif ( is_null( $var ) ) {
- return new AFPData();
- } else {
- throw new AFPException(
- 'Data type ' . gettype( $var ) . ' is not supported by AbuseFilter'
- );
+ switch ( gettype( $var ) ) {
+ case 'string':
+ return new AFPData( self::DSTRING, $var );
+ case 'integer':
+ return new AFPData( self::DINT, $var );
+ case 'double':
+ return new AFPData( self::DFLOAT, $var );
+ case 'boolean':
+ return new AFPData( self::DBOOL, $var );
+ case 'array':
+ $result = [];
+ foreach ( $var as $item ) {
+ $result[] = self::newFromPHPVar( $item );
+ }
+ return new AFPData( self::DARRAY, $result );
+ case 'NULL':
+ return new AFPData( self::DNULL );
+ default:
+ throw new AFPException(
+ 'Data type ' . gettype( $var ) . ' is not supported by AbuseFilter'
+ );
}
}
/**
- * @return AFPData
- */
- public function dup() {
- return new AFPData( $this->type, $this->data );
- }
-
- /**
* @param AFPData $orig
* @param string $target
* @return AFPData
*/
- public static function castTypes( $orig, $target ) {
- if ( $orig->type == $target ) {
- return $orig->dup();
+ public static function castTypes( AFPData $orig, $target ) {
+ if ( $orig->type === $target ) {
+ return $orig;
+ }
+ if ( $orig->type === self::DUNDEFINED ) {
+ // This case should be handled at a higher level, to avoid implicitly relying on what
+ // this method will do for the specific case.
+ throw new AFPException( 'Refusing to cast DUNDEFINED to something else' );
}
- if ( $target == self::DNULL ) {
- return new AFPData();
+ if ( $target === self::DNULL ) {
+ // We don't expose any method to cast to null. And, actually, should we?
+ return new AFPData( self::DNULL );
}
- if ( $orig->type == self::DARRAY ) {
- if ( $target == self::DBOOL ) {
+ if ( $orig->type === self::DARRAY ) {
+ if ( $target === self::DBOOL ) {
return new AFPData( self::DBOOL, (bool)count( $orig->data ) );
- }
- if ( $target == self::DFLOAT ) {
+ } elseif ( $target === self::DFLOAT ) {
return new AFPData( self::DFLOAT, floatval( count( $orig->data ) ) );
- }
- if ( $target == self::DINT ) {
- return new AFPData( self::DINT, intval( count( $orig->data ) ) );
- }
- if ( $target == self::DSTRING ) {
+ } elseif ( $target === self::DINT ) {
+ return new AFPData( self::DINT, count( $orig->data ) );
+ } elseif ( $target === self::DSTRING ) {
$s = '';
foreach ( $orig->data as $item ) {
$s .= $item->toString() . "\n";
@@ -105,97 +134,72 @@ class AFPData {
}
}
- if ( $target == self::DBOOL ) {
+ if ( $target === self::DBOOL ) {
return new AFPData( self::DBOOL, (bool)$orig->data );
- }
- if ( $target == self::DFLOAT ) {
+ } elseif ( $target === self::DFLOAT ) {
return new AFPData( self::DFLOAT, floatval( $orig->data ) );
- }
- if ( $target == self::DINT ) {
+ } elseif ( $target === self::DINT ) {
return new AFPData( self::DINT, intval( $orig->data ) );
- }
- if ( $target == self::DSTRING ) {
+ } elseif ( $target === self::DSTRING ) {
return new AFPData( self::DSTRING, strval( $orig->data ) );
- }
- if ( $target == self::DARRAY ) {
+ } elseif ( $target === self::DARRAY ) {
+ // We don't expose any method to cast to array
return new AFPData( self::DARRAY, [ $orig ] );
}
+ throw new AFPException( 'Cannot cast ' . $orig->type . " to $target." );
}
/**
- * @param AFPData $value
- * @return AFPData
- */
- public static function boolInvert( $value ) {
- return new AFPData( self::DBOOL, !$value->toBool() );
- }
-
- /**
- * @param AFPData $base
- * @param AFPData $exponent
- * @return AFPData
- */
- public static function pow( $base, $exponent ) {
- $res = pow( $base->toNumber(), $exponent->toNumber() );
- if ( $res === (int)$res ) {
- return new AFPData( self::DINT, $res );
- } else {
- return new AFPData( self::DFLOAT, $res );
- }
- }
-
- /**
- * @param AFPData $a
- * @param AFPData $b
* @return AFPData
*/
- public static function keywordIn( $a, $b ) {
- $a = $a->toString();
- $b = $b->toString();
-
- if ( $a == '' || $b == '' ) {
- return new AFPData( self::DBOOL, false );
+ public function boolInvert() {
+ if ( $this->type === self::DUNDEFINED ) {
+ return new AFPData( self::DUNDEFINED );
}
-
- return new AFPData( self::DBOOL, strpos( $b, $a ) !== false );
+ return new AFPData( self::DBOOL, !$this->toBool() );
}
/**
- * @param AFPData $a
- * @param AFPData $b
+ * @param AFPData $exponent
* @return AFPData
*/
- public static function keywordContains( $a, $b ) {
- $a = $a->toString();
- $b = $b->toString();
-
- if ( $a == '' || $b == '' ) {
- return new AFPData( self::DBOOL, false );
+ public function pow( AFPData $exponent ) {
+ if ( $this->type === self::DUNDEFINED || $exponent->type === self::DUNDEFINED ) {
+ return new AFPData( self::DUNDEFINED );
}
+ $res = pow( $this->toNumber(), $exponent->toNumber() );
+ $type = is_int( $res ) ? self::DINT : self::DFLOAT;
- return new AFPData( self::DBOOL, strpos( $a, $b ) !== false );
+ return new AFPData( $type, $res );
}
/**
- * @param AFPData $d1
* @param AFPData $d2
* @param bool $strict whether to also check types
* @return bool
+ * @throws AFPException if $this or $d2 is a DUNDEFINED. This shouldn't happen, because this method
+ * only returns a boolean, and thus the type of the result has already been decided and cannot
+ * be changed to be a DUNDEFINED from here.
+ * @internal
*/
- public static function equals( $d1, $d2, $strict = false ) {
- if ( $d1->type != self::DARRAY && $d2->type != self::DARRAY ) {
- $typecheck = $d1->type == $d2->type || !$strict;
- return $typecheck && $d1->toString() === $d2->toString();
- } elseif ( $d1->type == self::DARRAY && $d2->type == self::DARRAY ) {
- $data1 = $d1->data;
+ public function equals( AFPData $d2, $strict = false ) {
+ if ( $this->type === self::DUNDEFINED || $d2->type === self::DUNDEFINED ) {
+ throw new AFPException(
+ __METHOD__ . " got a DUNDEFINED. This should be handled at a higher level"
+ );
+ } elseif ( $this->type !== self::DARRAY && $d2->type !== self::DARRAY ) {
+ $typecheck = $this->type === $d2->type || !$strict;
+ return $typecheck && $this->toString() === $d2->toString();
+ } elseif ( $this->type === self::DARRAY && $d2->type === self::DARRAY ) {
+ $data1 = $this->data;
$data2 = $d2->data;
if ( count( $data1 ) !== count( $data2 ) ) {
return false;
}
$length = count( $data1 );
for ( $i = 0; $i < $length; $i++ ) {
- $result = self::equals( $data1[$i], $data2[$i], $strict );
- if ( $result === false ) {
+ // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Array type
+ if ( $data1[$i]->equals( $data2[$i], $strict ) === false ) {
return false;
}
}
@@ -205,10 +209,11 @@ class AFPData {
if ( $strict ) {
return false;
}
- if ( $d1->type == self::DARRAY && count( $d1->data ) === 0 ) {
- return ( $d2->type == self::DBOOL && $d2->toBool() == false ) || $d2->type == self::DNULL;
- } elseif ( $d2->type == self::DARRAY && count( $d2->data ) === 0 ) {
- return ( $d1->type == self::DBOOL && $d1->toBool() == false ) || $d1->type == self::DNULL;
+ if ( $this->type === self::DARRAY && count( $this->data ) === 0 ) {
+ return ( $d2->type === self::DBOOL && $d2->toBool() === false ) || $d2->type === self::DNULL;
+ } elseif ( $d2->type === self::DARRAY && count( $d2->data ) === 0 ) {
+ return ( $this->type === self::DBOOL && $this->toBool() === false ) ||
+ $this->type === self::DNULL;
} else {
return false;
}
@@ -216,138 +221,80 @@ class AFPData {
}
/**
- * @param AFPData $str
- * @param AFPData $pattern
* @return AFPData
*/
- public static function keywordLike( $str, $pattern ) {
- $str = $str->toString();
- $pattern = '#^' . strtr( preg_quote( $pattern->toString(), '#' ), self::$wildcardMap ) . '$#u';
- Wikimedia\suppressWarnings();
- $result = preg_match( $pattern, $str );
- Wikimedia\restoreWarnings();
-
- return new AFPData( self::DBOOL, (bool)$result );
- }
-
- /**
- * @param AFPData $str
- * @param AFPData $regex
- * @param int $pos
- * @param bool $insensitive
- * @return AFPData
- * @throws Exception
- */
- public static function keywordRegex( $str, $regex, $pos, $insensitive = false ) {
- $str = $str->toString();
- $pattern = $regex->toString();
-
- $pattern = preg_replace( '!(\\\\\\\\)*(\\\\)?/!', '$1\/', $pattern );
- $pattern = "/$pattern/u";
-
- if ( $insensitive ) {
- $pattern .= 'i';
- }
-
- Wikimedia\suppressWarnings();
- $result = preg_match( $pattern, $str );
- Wikimedia\restoreWarnings();
- if ( $result === false ) {
- throw new AFPUserVisibleException(
- 'regexfailure',
- $pos,
- [ $pattern ]
- );
- }
-
- return new AFPData( self::DBOOL, (bool)$result );
- }
-
- /**
- * @param AFPData $str
- * @param AFPData $regex
- * @param int $pos
- * @return AFPData
- */
- public static function keywordRegexInsensitive( $str, $regex, $pos ) {
- return self::keywordRegex( $str, $regex, $pos, true );
- }
-
- /**
- * @param AFPData $data
- * @return AFPData
- */
- public static function unaryMinus( $data ) {
- if ( $data->type == self::DINT ) {
- return new AFPData( $data->type, -$data->toInt() );
+ public function unaryMinus() {
+ if ( $this->type === self::DUNDEFINED ) {
+ return new AFPData( self::DUNDEFINED );
+ } elseif ( $this->type === self::DINT ) {
+ return new AFPData( $this->type, -$this->toInt() );
} else {
- return new AFPData( $data->type, -$data->toFloat() );
+ $type = $this->type === self::DEMPTY ? self::DNULL : $this->type;
+ return new AFPData( $type, -$this->toFloat() );
}
}
/**
- * @param AFPData $a
* @param AFPData $b
* @param string $op
* @return AFPData
* @throws AFPException
*/
- public static function boolOp( $a, $b, $op ) {
- $a = $a->toBool();
- $b = $b->toBool();
- if ( $op == '|' ) {
+ public function boolOp( AFPData $b, $op ) {
+ $a = $this->type === self::DUNDEFINED ? false : $this->toBool();
+ $b = $b->type === self::DUNDEFINED ? false : $b->toBool();
+
+ if ( $op === '|' ) {
return new AFPData( self::DBOOL, $a || $b );
- }
- if ( $op == '&' ) {
+ } elseif ( $op === '&' ) {
return new AFPData( self::DBOOL, $a && $b );
- }
- if ( $op == '^' ) {
+ } elseif ( $op === '^' ) {
return new AFPData( self::DBOOL, $a xor $b );
}
// Should never happen.
+ // @codeCoverageIgnoreStart
throw new AFPException( "Invalid boolean operation: {$op}" );
+ // @codeCoverageIgnoreEnd
}
/**
- * @param AFPData $a
* @param AFPData $b
* @param string $op
* @return AFPData
* @throws AFPException
*/
- public static function compareOp( $a, $b, $op ) {
- if ( $op == '==' || $op == '=' ) {
- return new AFPData( self::DBOOL, self::equals( $a, $b ) );
+ public function compareOp( AFPData $b, $op ) {
+ if ( $this->type === self::DUNDEFINED || $b->type === self::DUNDEFINED ) {
+ return new AFPData( self::DUNDEFINED );
}
- if ( $op == '!=' ) {
- return new AFPData( self::DBOOL, !self::equals( $a, $b ) );
+ if ( $op === '==' || $op === '=' ) {
+ return new AFPData( self::DBOOL, $this->equals( $b ) );
+ } elseif ( $op === '!=' ) {
+ return new AFPData( self::DBOOL, !$this->equals( $b ) );
+ } elseif ( $op === '===' ) {
+ return new AFPData( self::DBOOL, $this->equals( $b, true ) );
+ } elseif ( $op === '!==' ) {
+ return new AFPData( self::DBOOL, !$this->equals( $b, true ) );
}
- if ( $op == '===' ) {
- return new AFPData( self::DBOOL, self::equals( $a, $b, true ) );
- }
- if ( $op == '!==' ) {
- return new AFPData( self::DBOOL, !self::equals( $a, $b, true ) );
- }
- $a = $a->toString();
+
+ $a = $this->toString();
$b = $b->toString();
- if ( $op == '>' ) {
+ if ( $op === '>' ) {
return new AFPData( self::DBOOL, $a > $b );
- }
- if ( $op == '<' ) {
+ } elseif ( $op === '<' ) {
return new AFPData( self::DBOOL, $a < $b );
- }
- if ( $op == '>=' ) {
+ } elseif ( $op === '>=' ) {
return new AFPData( self::DBOOL, $a >= $b );
- }
- if ( $op == '<=' ) {
+ } elseif ( $op === '<=' ) {
return new AFPData( self::DBOOL, $a <= $b );
}
// Should never happen
+ // @codeCoverageIgnoreStart
throw new AFPException( "Invalid comparison operation: {$op}" );
+ // @codeCoverageIgnoreEnd
}
/**
- * @param AFPData $a
* @param AFPData $b
* @param string $op
* @param int $pos
@@ -355,68 +302,114 @@ class AFPData {
* @throws AFPUserVisibleException
* @throws AFPException
*/
- public static function mulRel( $a, $b, $op, $pos ) {
- $a = $a->toNumber();
+ public function mulRel( AFPData $b, $op, $pos ) {
+ if ( $b->type === self::DUNDEFINED ) {
+ // The LHS type is checked later, because we first need to ensure we're not
+ // dividing or taking modulo by 0 (and that should throw regardless of whether
+ // the LHS is undefined).
+ return new AFPData( self::DUNDEFINED );
+ }
+
$b = $b->toNumber();
- if ( $op != '*' && $b == 0 ) {
- throw new AFPUserVisibleException( 'dividebyzero', $pos, [ $a ] );
+ if (
+ ( $op === '/' && (float)$b === 0.0 ) ||
+ ( $op === '%' && (int)$b === 0 )
+ ) {
+ $lhs = $this->type === self::DUNDEFINED ? 0 : $this->toNumber();
+ throw new AFPUserVisibleException( 'dividebyzero', $pos, [ $lhs ] );
+ }
+
+ if ( $this->type === self::DUNDEFINED ) {
+ return new AFPData( self::DUNDEFINED );
}
+ $a = $this->toNumber();
- if ( $op == '*' ) {
+ if ( $op === '*' ) {
$data = $a * $b;
- } elseif ( $op == '/' ) {
+ } elseif ( $op === '/' ) {
$data = $a / $b;
- } elseif ( $op == '%' ) {
+ } elseif ( $op === '%' ) {
$data = $a % $b;
} else {
// Should never happen
+ // @codeCoverageIgnoreStart
throw new AFPException( "Invalid multiplication-related operation: {$op}" );
+ // @codeCoverageIgnoreEnd
}
- if ( $data === (int)$data ) {
- $data = intval( $data );
- $type = self::DINT;
- } else {
- $data = floatval( $data );
- $type = self::DFLOAT;
- }
+ $type = is_int( $data ) ? self::DINT : self::DFLOAT;
return new AFPData( $type, $data );
}
/**
- * @param AFPData $a
* @param AFPData $b
* @return AFPData
*/
- public static function sum( $a, $b ) {
- if ( $a->type == self::DSTRING || $b->type == self::DSTRING ) {
- return new AFPData( self::DSTRING, $a->toString() . $b->toString() );
- } elseif ( $a->type == self::DARRAY && $b->type == self::DARRAY ) {
- return new AFPData( self::DARRAY, array_merge( $a->toArray(), $b->toArray() ) );
+ public function sum( AFPData $b ) {
+ if ( $this->type === self::DUNDEFINED || $b->type === self::DUNDEFINED ) {
+ return new AFPData( self::DUNDEFINED );
+ } elseif ( $this->type === self::DSTRING || $b->type === self::DSTRING ) {
+ return new AFPData( self::DSTRING, $this->toString() . $b->toString() );
+ } elseif ( $this->type === self::DARRAY && $b->type === self::DARRAY ) {
+ return new AFPData( self::DARRAY, array_merge( $this->toArray(), $b->toArray() ) );
} else {
- $res = $a->toNumber() + $b->toNumber();
- if ( $res === (int)$res ) {
- return new AFPData( self::DINT, $res );
- } else {
- return new AFPData( self::DFLOAT, $res );
- }
+ $res = $this->toNumber() + $b->toNumber();
+ $type = is_int( $res ) ? self::DINT : self::DFLOAT;
+
+ return new AFPData( $type, $res );
}
}
/**
- * @param AFPData $a
* @param AFPData $b
* @return AFPData
*/
- public static function sub( $a, $b ) {
- $res = $a->toNumber() - $b->toNumber();
- if ( $res === (int)$res ) {
- return new AFPData( self::DINT, $res );
- } else {
- return new AFPData( self::DFLOAT, $res );
+ public function sub( AFPData $b ) {
+ if ( $this->type === self::DUNDEFINED || $b->type === self::DUNDEFINED ) {
+ return new AFPData( self::DUNDEFINED );
+ }
+ $res = $this->toNumber() - $b->toNumber();
+ $type = is_int( $res ) ? self::DINT : self::DFLOAT;
+
+ return new AFPData( $type, $res );
+ }
+
+ /**
+ * Check whether this instance contains the DUNDEFINED type, recursively
+ * @return bool
+ */
+ public function hasUndefined() : bool {
+ if ( $this->type === self::DUNDEFINED ) {
+ return true;
+ }
+ if ( $this->type === self::DARRAY ) {
+ foreach ( $this->data as $el ) {
+ if ( $el->hasUndefined() ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Return a clone of this instance where DUNDEFINED is replaced with DNULL
+ * @return $this
+ */
+ public function cloneAsUndefinedReplacedWithNull() : self {
+ if ( $this->type === self::DUNDEFINED ) {
+ return new self( self::DNULL );
+ }
+ if ( $this->type === self::DARRAY ) {
+ $data = [];
+ foreach ( $this->data as $el ) {
+ $data[] = $el->cloneAsUndefinedReplacedWithNull();
+ }
+ return new self( self::DARRAY, $data );
}
+ return clone $this;
}
/** Convert shorteners */
@@ -444,9 +437,13 @@ class AFPData {
return $output;
case self::DNULL:
+ case self::DUNDEFINED:
+ case self::DEMPTY:
return null;
default:
+ // @codeCoverageIgnoreStart
throw new MWException( "Unknown type" );
+ // @codeCoverageIgnoreEnd
}
}
@@ -482,7 +479,13 @@ class AFPData {
* @return int|float
*/
public function toNumber() {
- return $this->type == self::DINT ? $this->toInt() : $this->toFloat();
+ // Types that can be cast to int
+ $intLikeTypes = [
+ self::DINT,
+ self::DBOOL,
+ self::DNULL
+ ];
+ return in_array( $this->type, $intLikeTypes, true ) ? $this->toInt() : $this->toFloat();
}
/**
diff --git a/AbuseFilter/includes/parser/AFPParserState.php b/AbuseFilter/includes/parser/AFPParserState.php
index 453948d1..ee345d94 100644
--- a/AbuseFilter/includes/parser/AFPParserState.php
+++ b/AbuseFilter/includes/parser/AFPParserState.php
@@ -7,7 +7,7 @@ class AFPParserState {
* @param AFPToken $token
* @param int $pos
*/
- public function __construct( $token, $pos ) {
+ public function __construct( AFPToken $token, $pos ) {
$this->token = $token;
$this->pos = $pos;
}
diff --git a/AbuseFilter/includes/parser/AFPSyntaxTree.php b/AbuseFilter/includes/parser/AFPSyntaxTree.php
new file mode 100644
index 00000000..ffca3eb9
--- /dev/null
+++ b/AbuseFilter/includes/parser/AFPSyntaxTree.php
@@ -0,0 +1,27 @@
+<?php
+
+/**
+ * A class representing a whole AST generated by AFPTreeParser, holding AFPTreeNode's. This wrapper
+ * could be expanded in the future. For now, it's mostly useful for typehints, and to have an
+ * evalTree function in the CachingParser.
+ */
+class AFPSyntaxTree {
+ /**
+ * @var AFPTreeNode|null
+ */
+ private $rootNode;
+
+ /**
+ * @param AFPTreeNode|null $root
+ */
+ public function __construct( AFPTreeNode $root = null ) {
+ $this->rootNode = $root;
+ }
+
+ /**
+ * @return AFPTreeNode|null
+ */
+ public function getRoot() {
+ return $this->rootNode;
+ }
+}
diff --git a/AbuseFilter/includes/parser/AFPToken.php b/AbuseFilter/includes/parser/AFPToken.php
index 897f5ded..78fdd7c3 100644
--- a/AbuseFilter/includes/parser/AFPToken.php
+++ b/AbuseFilter/includes/parser/AFPToken.php
@@ -36,20 +36,29 @@
* * Atom (A) - return value
*/
class AFPToken {
- const TNONE = 'T_NONE';
- const TID = 'T_ID';
- const TKEYWORD = 'T_KEYWORD';
- const TSTRING = 'T_STRING';
- const TINT = 'T_INT';
- const TFLOAT = 'T_FLOAT';
- const TOP = 'T_OP';
- const TBRACE = 'T_BRACE';
- const TSQUAREBRACKET = 'T_SQUARE_BRACKET';
- const TCOMMA = 'T_COMMA';
- const TSTATEMENTSEPARATOR = 'T_STATEMENT_SEPARATOR';
+ public const TNONE = 'T_NONE';
+ public const TID = 'T_ID';
+ public const TKEYWORD = 'T_KEYWORD';
+ public const TSTRING = 'T_STRING';
+ public const TINT = 'T_INT';
+ public const TFLOAT = 'T_FLOAT';
+ public const TOP = 'T_OP';
+ public const TBRACE = 'T_BRACE';
+ public const TSQUAREBRACKET = 'T_SQUARE_BRACKET';
+ public const TCOMMA = 'T_COMMA';
+ public const TSTATEMENTSEPARATOR = 'T_STATEMENT_SEPARATOR';
+ /**
+ * @var string One of the T* constant from this class
+ */
public $type;
+ /**
+ * @var mixed|null The actual value of the token
+ */
public $value;
+ /**
+ * @var int The code offset where this token is found
+ */
public $pos;
/**
diff --git a/AbuseFilter/includes/parser/AFPTransitionBase.php b/AbuseFilter/includes/parser/AFPTransitionBase.php
new file mode 100644
index 00000000..e784a2a5
--- /dev/null
+++ b/AbuseFilter/includes/parser/AFPTransitionBase.php
@@ -0,0 +1,143 @@
+<?php
+
+use MediaWiki\Extension\AbuseFilter\KeywordsManager;
+
+/**
+ * Base parse-related class to be used while the old parser is being phased out
+ *
+ * @internal This is a temporary class until things are settled down
+ * @property KeywordsManager $keywordsManager
+ */
+abstract class AFPTransitionBase {
+ public const FUNCTIONS = [
+ 'lcase' => 'funcLc',
+ 'ucase' => 'funcUc',
+ 'length' => 'funcLen',
+ 'string' => 'castString',
+ 'int' => 'castInt',
+ 'float' => 'castFloat',
+ 'bool' => 'castBool',
+ 'norm' => 'funcNorm',
+ 'ccnorm' => 'funcCCNorm',
+ 'ccnorm_contains_any' => 'funcCCNormContainsAny',
+ 'ccnorm_contains_all' => 'funcCCNormContainsAll',
+ 'specialratio' => 'funcSpecialRatio',
+ 'rmspecials' => 'funcRMSpecials',
+ 'rmdoubles' => 'funcRMDoubles',
+ 'rmwhitespace' => 'funcRMWhitespace',
+ 'count' => 'funcCount',
+ 'rcount' => 'funcRCount',
+ 'get_matches' => 'funcGetMatches',
+ 'ip_in_range' => 'funcIPInRange',
+ 'contains_any' => 'funcContainsAny',
+ 'contains_all' => 'funcContainsAll',
+ 'equals_to_any' => 'funcEqualsToAny',
+ 'substr' => 'funcSubstr',
+ 'strlen' => 'funcLen',
+ 'strpos' => 'funcStrPos',
+ 'str_replace' => 'funcStrReplace',
+ 'rescape' => 'funcStrRegexEscape',
+ 'set' => 'funcSetVar',
+ 'set_var' => 'funcSetVar',
+ 'sanitize' => 'funcSanitize',
+ ];
+
+ /**
+ * The minimum and maximum amount of arguments required by each function.
+ * @var int[][]
+ */
+ public const FUNC_ARG_COUNT = [
+ 'lcase' => [ 1, 1 ],
+ 'ucase' => [ 1, 1 ],
+ 'length' => [ 1, 1 ],
+ 'string' => [ 1, 1 ],
+ 'int' => [ 1, 1 ],
+ 'float' => [ 1, 1 ],
+ 'bool' => [ 1, 1 ],
+ 'norm' => [ 1, 1 ],
+ 'ccnorm' => [ 1, 1 ],
+ 'ccnorm_contains_any' => [ 2, INF ],
+ 'ccnorm_contains_all' => [ 2, INF ],
+ 'specialratio' => [ 1, 1 ],
+ 'rmspecials' => [ 1, 1 ],
+ 'rmdoubles' => [ 1, 1 ],
+ 'rmwhitespace' => [ 1, 1 ],
+ 'count' => [ 1, 2 ],
+ 'rcount' => [ 1, 2 ],
+ 'get_matches' => [ 2, 2 ],
+ 'ip_in_range' => [ 2, 2 ],
+ 'contains_any' => [ 2, INF ],
+ 'contains_all' => [ 2, INF ],
+ 'equals_to_any' => [ 2, INF ],
+ 'substr' => [ 2, 3 ],
+ 'strlen' => [ 1, 1 ],
+ 'strpos' => [ 2, 3 ],
+ 'str_replace' => [ 3, 3 ],
+ 'rescape' => [ 1, 1 ],
+ 'set' => [ 2, 2 ],
+ 'set_var' => [ 2, 2 ],
+ 'sanitize' => [ 1, 1 ],
+ ];
+
+ /**
+ * @var int The position of the current token
+ */
+ protected $mPos;
+
+ /**
+ * Check that a built-in function has been provided the right amount of arguments
+ *
+ * @param array $args The arguments supplied to the function
+ * @param string $func The function name
+ * @throws AFPUserVisibleException
+ */
+ protected function checkArgCount( $args, $func ) {
+ if ( !array_key_exists( $func, self::FUNC_ARG_COUNT ) ) {
+ // @codeCoverageIgnoreStart
+ throw new InvalidArgumentException( "$func is not a valid function." );
+ // @codeCoverageIgnoreEnd
+ }
+ list( $min, $max ) = self::FUNC_ARG_COUNT[ $func ];
+ if ( count( $args ) < $min ) {
+ throw new AFPUserVisibleException(
+ $min === 1 ? 'noparams' : 'notenoughargs',
+ $this->mPos,
+ [ $func, $min, count( $args ) ]
+ );
+ } elseif ( count( $args ) > $max ) {
+ throw new AFPUserVisibleException(
+ 'toomanyargs',
+ $this->mPos,
+ [ $func, $max, count( $args ) ]
+ );
+ }
+ }
+
+ /**
+ * Check whether the given name is a reserved identifier, e.g. the name of a built-in variable,
+ * function, or keyword.
+ *
+ * @param string $name
+ * @return bool
+ */
+ protected function isReservedIdentifier( $name ) {
+ return $this->keywordsManager->varExists( $name ) ||
+ array_key_exists( $name, self::FUNCTIONS ) ||
+ // We need to check for true, false, if/then/else etc. because, even if they have a different
+ // AFPToken type, they may be used inside set/set_var()
+ in_array( $name, AbuseFilterTokenizer::KEYWORDS, true );
+ }
+
+ /**
+ * @param string $fname
+ * @return bool
+ */
+ protected function functionIsVariadic( $fname ) {
+ if ( !array_key_exists( $fname, self::FUNC_ARG_COUNT ) ) {
+ // @codeCoverageIgnoreStart
+ throw new InvalidArgumentException( "Function $fname is not valid" );
+ // @codeCoverageIgnoreEnd
+ }
+ return self::FUNC_ARG_COUNT[$fname][1] === INF;
+ }
+}
diff --git a/AbuseFilter/includes/parser/AFPTreeNode.php b/AbuseFilter/includes/parser/AFPTreeNode.php
index a3c2a063..65dc5a7f 100644
--- a/AbuseFilter/includes/parser/AFPTreeNode.php
+++ b/AbuseFilter/includes/parser/AFPTreeNode.php
@@ -10,7 +10,7 @@ class AFPTreeNode {
// SEMICOLON is a many-children node, denoting that the nodes have to be
// evaluated in order and the last value has to be returned.
- const SEMICOLON = 'SEMICOLON';
+ public const SEMICOLON = 'SEMICOLON';
// ASSIGNMENT (formerly known as SET) is a node which is responsible for
// assigning values to variables. ASSIGNMENT is a (variable name [string],
@@ -18,47 +18,47 @@ class AFPTreeNode {
// values at array offsets) is a (variable name [string], index [tree node],
// value [tree node]) tuple, and ARRAY_APPEND has the form of (variable name
// [string], value [tree node]).
- const ASSIGNMENT = 'ASSIGNMENT';
- const INDEX_ASSIGNMENT = 'INDEX_ASSIGNMENT';
- const ARRAY_APPEND = 'ARRAY_APPEND';
+ public const ASSIGNMENT = 'ASSIGNMENT';
+ public const INDEX_ASSIGNMENT = 'INDEX_ASSIGNMENT';
+ public const ARRAY_APPEND = 'ARRAY_APPEND';
// CONDITIONAL represents both a ternary operator and an if-then-else-end
- // construct. The format is (condition, evaluated-if-true,
- // evaluated-in-false), all tree nodes.
- const CONDITIONAL = 'CONDITIONAL';
+ // construct. The format is (condition, evaluated-if-true, evaluated-in-false).
+ // The first two are tree nodes, the last one can be a node, or null if there's no else.
+ public const CONDITIONAL = 'CONDITIONAL';
// LOGIC is a logic operator accepted by AFPData::boolOp. The format is
// (operation, left operand, right operand).
- const LOGIC = 'LOGIC';
+ public const LOGIC = 'LOGIC';
// COMPARE is a comparison operator accepted by AFPData::boolOp. The format is
// (operation, left operand, right operand).
- const COMPARE = 'COMPARE';
+ public const COMPARE = 'COMPARE';
// SUM_REL is either '+' or '-'. The format is (operation, left operand,
// right operand).
- const SUM_REL = 'SUM_REL';
+ public const SUM_REL = 'SUM_REL';
// MUL_REL is a multiplication-related operation accepted by AFPData::mulRel.
// The format is (operation, left operand, right operand).
- const MUL_REL = 'MUL_REL';
+ public const MUL_REL = 'MUL_REL';
// POW is an exponentiation operator. The format is (base, exponent).
- const POW = 'POW';
+ public const POW = 'POW';
// BOOL_INVERT is a boolean inversion operator. The format is (operand).
- const BOOL_INVERT = 'BOOL_INVERT';
+ public const BOOL_INVERT = 'BOOL_INVERT';
// KEYWORD_OPERATOR is one of the binary keyword operators supported by the
// filter language. The format is (keyword, left operand, right operand).
- const KEYWORD_OPERATOR = 'KEYWORD_OPERATOR';
+ public const KEYWORD_OPERATOR = 'KEYWORD_OPERATOR';
// UNARY is either unary minus or unary plus. The format is (operator, operand).
- const UNARY = 'UNARY';
+ public const UNARY = 'UNARY';
// ARRAY_INDEX is an operation of accessing an array by an offset. The format
// is (array, offset).
- const ARRAY_INDEX = 'ARRAY_INDEX';
+ public const ARRAY_INDEX = 'ARRAY_INDEX';
// Since parenthesis only manipulate precedence of the operators, they are
// not explicitly represented in the tree.
@@ -66,15 +66,15 @@ class AFPTreeNode {
// FUNCTION_CALL is an invocation of built-in function. The format is a
// tuple where the first element is a function name, and all subsequent
// elements are the arguments.
- const FUNCTION_CALL = 'FUNCTION_CALL';
+ public const FUNCTION_CALL = 'FUNCTION_CALL';
// ARRAY_DEFINITION is an array literal. The $children field contains tree
// nodes for the values of each of the array element used.
- const ARRAY_DEFINITION = 'ARRAY_DEFINITION';
+ public const ARRAY_DEFINITION = 'ARRAY_DEFINITION';
// ATOM is a node representing a literal. The only element of $children is a
// token corresponding to the literal.
- const ATOM = 'ATOM';
+ public const ATOM = 'ATOM';
/** @var string Type of the node, one of the constants above */
public $type;
@@ -86,29 +86,92 @@ class AFPTreeNode {
*/
public $children;
- // Position used for error reporting.
+ /** @var int Position used for error reporting. */
public $position;
/**
+ * @var string[] Names of the variables assigned in this node or any of its descendants
+ * @todo We could change this to be an instance of a new AFPScope class (holding a var map)
+ * if we'll have the need to store other scope-specific data,
+ * see <https://phabricator.wikimedia.org/T230982#5475400>.
+ */
+ private $innerAssignments = [];
+
+ /**
* @param string $type
- * @param AFPTreeNode[]|string[]|AFPToken $children
+ * @param (AFPTreeNode|null)[]|string[]|AFPToken $children
* @param int $position
*/
public function __construct( $type, $children, $position ) {
$this->type = $type;
$this->children = $children;
$this->position = $position;
+ $this->populateInnerAssignments();
+ }
+
+ /**
+ * Save in this node all the variable names used in the children, and in this node if it's an
+ * assignment-related node. Note that this doesn't check whether the variable is custom or builtin:
+ * this is already checked when calling setUserVariable.
+ * In case we'll ever need to store other data in a node, or maybe even a Scope object, this could
+ * be moved to a different class which could also re-visit the whole AST.
+ */
+ private function populateInnerAssignments() {
+ if ( $this->type === self::ATOM ) {
+ return;
+ }
+
+ if (
+ $this->type === self::ASSIGNMENT ||
+ $this->type === self::INDEX_ASSIGNMENT ||
+ $this->type === self::ARRAY_APPEND
+ ) {
+ $this->innerAssignments = [ $this->children[0] ];
+ } elseif (
+ $this->type === self::FUNCTION_CALL &&
+ in_array( $this->children[0], [ 'set', 'set_var' ] ) &&
+ // If unset, parsing will fail when checking arguments
+ isset( $this->children[1] )
+ ) {
+ $varnameNode = $this->children[1];
+ if ( $varnameNode->type !== self::ATOM ) {
+ // Shouldn't happen since variable variables are not allowed
+ // @codeCoverageIgnoreStart
+ throw new AFPException( "Got non-atom type {$varnameNode->type} for set_var" );
+ // @codeCoverageIgnoreEnd
+ }
+ $this->innerAssignments = [ $varnameNode->children->value ];
+ }
+
+ // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach ATOM excluded above
+ foreach ( $this->children as $child ) {
+ if ( $child instanceof self ) {
+ $this->innerAssignments = array_merge( $this->innerAssignments, $child->getInnerAssignments() );
+ }
+ }
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getInnerAssignments() : array {
+ return $this->innerAssignments;
}
/**
* @return string
+ * @codeCoverageIgnore
*/
public function toDebugString() {
return implode( "\n", $this->toDebugStringInner() );
}
+ /**
+ * @return array
+ * @codeCoverageIgnore
+ */
private function toDebugStringInner() {
- if ( $this->type == self::ATOM ) {
+ if ( $this->type === self::ATOM ) {
return [ "ATOM({$this->children->type} {$this->children->value})" ];
}
@@ -117,6 +180,7 @@ class AFPTreeNode {
};
$lines = [ "{$this->type}" ];
+ // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here
foreach ( $this->children as $subnode ) {
if ( $subnode instanceof AFPTreeNode ) {
$sublines = array_map( $align, $subnode->toDebugStringInner() );
diff --git a/AbuseFilter/includes/parser/AFPTreeParser.php b/AbuseFilter/includes/parser/AFPTreeParser.php
index 4fa35356..b4befd0f 100644
--- a/AbuseFilter/includes/parser/AFPTreeParser.php
+++ b/AbuseFilter/includes/parser/AFPTreeParser.php
@@ -5,33 +5,82 @@
* evaluating it into different passes, allowing the parse tree to be cached.
*
* @file
+ * @phan-file-suppress PhanPossiblyInfiniteRecursionSameParams Recursion controlled by class props
*/
+use MediaWiki\Extension\AbuseFilter\KeywordsManager;
+use Psr\Log\LoggerInterface;
+
/**
* A parser that transforms the text of the filter into a parse tree.
*/
-class AFPTreeParser {
- // The tokenized representation of the filter parsed.
+class AFPTreeParser extends AFPTransitionBase {
+ /**
+ * @var array[] Contains the AFPTokens for the code being parsed
+ */
public $mTokens;
+ /**
+ * @var AFPToken The current token
+ */
+ public $mCur;
+ /**
+ * @var string|null The ID of the filter being parsed, if available. Can also be "global-$ID"
+ */
+ protected $mFilter;
- // Current token handled by the parser and its position.
- public $mCur, $mPos;
+ public const CACHE_VERSION = 2;
- const CACHE_VERSION = 2;
+ /**
+ * @var BagOStuff Used to cache tokens
+ */
+ protected $cache;
/**
- * Create a new instance
+ * @var LoggerInterface Used for debugging
*/
- public function __construct() {
+ protected $logger;
+
+ /**
+ * @var IBufferingStatsdDataFactory
+ */
+ protected $statsd;
+
+ /** @var KeywordsManager */
+ protected $keywordsManager;
+
+ /**
+ * @param BagOStuff $cache
+ * @param LoggerInterface $logger Used for debugging
+ * @param IBufferingStatsdDataFactory $statsd
+ * @param KeywordsManager $keywordsManager
+ */
+ public function __construct(
+ BagOStuff $cache,
+ LoggerInterface $logger,
+ IBufferingStatsdDataFactory $statsd,
+ KeywordsManager $keywordsManager
+ ) {
+ $this->cache = $cache;
+ $this->logger = $logger;
+ $this->statsd = $statsd;
+ $this->keywordsManager = $keywordsManager;
$this->resetState();
}
/**
+ * @param string $filter
+ */
+ public function setFilter( $filter ) {
+ $this->mFilter = $filter;
+ }
+
+ /**
* Resets the state
*/
public function resetState() {
$this->mTokens = [];
$this->mPos = 0;
+ $this->mFilter = null;
}
/**
@@ -42,6 +91,16 @@ class AFPTreeParser {
}
/**
+ * Get the next token. This is similar to move() but doesn't change class members,
+ * allowing to look ahead without rolling back the state.
+ *
+ * @return AFPToken
+ */
+ protected function getNextToken() {
+ return $this->mTokens[$this->mPos][0];
+ }
+
+ /**
* getState() function allows parser state to be rollbacked to several tokens
* back.
*
@@ -67,26 +126,37 @@ class AFPTreeParser {
*
* @param string $code
* @throws AFPUserVisibleException
- * @return AFPTreeNode|null
+ * @return AFPSyntaxTree
*/
- public function parse( $code ) {
- $this->mTokens = AbuseFilterTokenizer::tokenize( $code );
+ public function parse( $code ) : AFPSyntaxTree {
+ $tokenizer = new AbuseFilterTokenizer( $this->cache, $this->logger );
+ $this->mTokens = $tokenizer->getTokens( $code );
$this->mPos = 0;
- return $this->doLevelEntry();
+ return $this->buildSyntaxTree();
+ }
+
+ /**
+ * @return AFPSyntaxTree
+ */
+ public function buildSyntaxTree() : AFPSyntaxTree {
+ $startTime = microtime( true );
+ $root = $this->doLevelEntry();
+ $this->statsd->timing( 'abusefilter_cachingParser_buildtree', microtime( true ) - $startTime );
+ return new AFPSyntaxTree( $root );
}
/* Levels */
/**
* Handles unexpected characters after the expression.
- * @return AFPTreeNode|null
+ * @return AFPTreeNode|null Null only if no statements
* @throws AFPUserVisibleException
*/
protected function doLevelEntry() {
$result = $this->doLevelSemicolon();
- if ( $this->mCur->type != AFPToken::TNONE ) {
+ if ( $this->mCur->type !== AFPToken::TNONE ) {
throw new AFPUserVisibleException(
'unexpectedatend',
$this->mPos, [ $this->mCur->type ]
@@ -108,23 +178,27 @@ class AFPTreeParser {
$this->move();
$position = $this->mPos;
- if ( $this->mCur->type == AFPToken::TNONE ) {
+ if (
+ $this->mCur->type === AFPToken::TNONE ||
+ ( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value == ')' )
+ ) {
+ // Handle special cases which the other parser handled in doLevelAtom
break;
}
// Allow empty statements.
- if ( $this->mCur->type == AFPToken::TSTATEMENTSEPARATOR ) {
+ if ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR ) {
continue;
}
$statements[] = $this->doLevelSet();
$position = $this->mPos;
- } while ( $this->mCur->type == AFPToken::TSTATEMENTSEPARATOR );
+ } while ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR );
// Flatten the tree if possible.
- if ( count( $statements ) == 0 ) {
+ if ( count( $statements ) === 0 ) {
return null;
- } elseif ( count( $statements ) == 1 ) {
+ } elseif ( count( $statements ) === 1 ) {
return $statements[0];
} else {
return new AFPTreeNode( AFPTreeNode::SEMICOLON, $statements, $position );
@@ -138,15 +212,16 @@ class AFPTreeParser {
* @throws AFPUserVisibleException
*/
protected function doLevelSet() {
- if ( $this->mCur->type == AFPToken::TID ) {
- $varname = $this->mCur->value;
+ if ( $this->mCur->type === AFPToken::TID ) {
+ $varname = (string)$this->mCur->value;
// Speculatively parse the assignment statement assuming it can
// potentially be an assignment, but roll back if it isn't.
+ // @todo Use $this->getNextToken for clearer code
$initialState = $this->getState();
$this->move();
- if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) {
+ if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) {
$position = $this->mPos;
$this->move();
$value = $this->doLevelSet();
@@ -154,24 +229,24 @@ class AFPTreeParser {
return new AFPTreeNode( AFPTreeNode::ASSIGNMENT, [ $varname, $value ], $position );
}
- if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) {
+ if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) {
$this->move();
- if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) {
+ if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
$index = 'append';
} else {
// Parse index offset.
$this->setState( $initialState );
$this->move();
$index = $this->doLevelSemicolon();
- if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) {
throw new AFPUserVisibleException( 'expectednotfound', $this->mPos,
[ ']', $this->mCur->type, $this->mCur->value ] );
}
}
$this->move();
- if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) {
+ if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) {
$position = $this->mPos;
$this->move();
$value = $this->doLevelSet();
@@ -203,12 +278,12 @@ class AFPTreeParser {
* @throws AFPUserVisibleException
*/
protected function doLevelConditions() {
- if ( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'if' ) {
+ if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'if' ) {
$position = $this->mPos;
$this->move();
$condition = $this->doLevelBoolOps();
- if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'then' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'then' ) ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mPos,
[
@@ -222,21 +297,14 @@ class AFPTreeParser {
$valueIfTrue = $this->doLevelConditions();
- if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'else' ) ) {
- throw new AFPUserVisibleException( 'expectednotfound',
- $this->mPos,
- [
- 'else',
- $this->mCur->type,
- $this->mCur->value
- ]
- );
+ if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'else' ) {
+ $this->move();
+ $valueIfFalse = $this->doLevelConditions();
+ } else {
+ $valueIfFalse = null;
}
- $this->move();
-
- $valueIfFalse = $this->doLevelConditions();
- if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'end' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'end' ) ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mPos,
[
@@ -256,12 +324,12 @@ class AFPTreeParser {
}
$condition = $this->doLevelBoolOps();
- if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '?' ) {
+ if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '?' ) {
$position = $this->mPos;
$this->move();
$valueIfTrue = $this->doLevelConditions();
- if ( !( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':' ) ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mPos,
[
@@ -292,7 +360,7 @@ class AFPTreeParser {
protected function doLevelBoolOps() {
$leftOperand = $this->doLevelCompares();
$ops = [ '&', '|', '^' ];
- while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
+ while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
$op = $this->mCur->value;
$position = $this->mPos;
$this->move();
@@ -315,9 +383,16 @@ class AFPTreeParser {
*/
protected function doLevelCompares() {
$leftOperand = $this->doLevelSumRels();
- $ops = [ '==', '===', '!=', '!==', '<', '>', '<=', '>=', '=' ];
- while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
+ $equalityOps = [ '==', '===', '!=', '!==', '=' ];
+ $orderOps = [ '<', '>', '<=', '>=' ];
+ // Only allow either a single operation, or a combination of a single equalityOps and a single
+ // orderOps. This resembles what PHP does, and allows `a < b == c` while rejecting `a < b < c`
+ $allowedOps = array_merge( $equalityOps, $orderOps );
+ while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $allowedOps ) ) {
$op = $this->mCur->value;
+ $allowedOps = in_array( $op, $equalityOps ) ?
+ array_diff( $allowedOps, $equalityOps ) :
+ array_diff( $allowedOps, $orderOps );
$position = $this->mPos;
$this->move();
$rightOperand = $this->doLevelSumRels();
@@ -338,7 +413,7 @@ class AFPTreeParser {
protected function doLevelSumRels() {
$leftOperand = $this->doLevelMulRels();
$ops = [ '+', '-' ];
- while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
+ while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
$op = $this->mCur->value;
$position = $this->mPos;
$this->move();
@@ -360,7 +435,7 @@ class AFPTreeParser {
protected function doLevelMulRels() {
$leftOperand = $this->doLevelPow();
$ops = [ '*', '/', '%' ];
- while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
+ while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
$op = $this->mCur->value;
$position = $this->mPos;
$this->move();
@@ -381,7 +456,7 @@ class AFPTreeParser {
*/
protected function doLevelPow() {
$base = $this->doLevelBoolInvert();
- while ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '**' ) {
+ while ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '**' ) {
$position = $this->mPos;
$this->move();
$exponent = $this->doLevelBoolInvert();
@@ -396,7 +471,7 @@ class AFPTreeParser {
* @return AFPTreeNode
*/
protected function doLevelBoolInvert() {
- if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '!' ) {
+ if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '!' ) {
$position = $this->mPos;
$this->move();
$argument = $this->doLevelKeywordOperators();
@@ -414,8 +489,8 @@ class AFPTreeParser {
protected function doLevelKeywordOperators() {
$leftOperand = $this->doLevelUnarys();
$keyword = strtolower( $this->mCur->value );
- if ( $this->mCur->type == AFPToken::TKEYWORD &&
- isset( AbuseFilterParser::$mKeywords[$keyword] )
+ if ( $this->mCur->type === AFPToken::TKEYWORD &&
+ isset( AbuseFilterParser::KEYWORDS[$keyword] )
) {
$position = $this->mPos;
$this->move();
@@ -438,7 +513,7 @@ class AFPTreeParser {
*/
protected function doLevelUnarys() {
$op = $this->mCur->value;
- if ( $this->mCur->type == AFPToken::TOP && ( $op == "+" || $op == "-" ) ) {
+ if ( $this->mCur->type === AFPToken::TOP && ( $op === "+" || $op === "-" ) ) {
$position = $this->mPos;
$this->move();
$argument = $this->doLevelArrayElements();
@@ -455,12 +530,12 @@ class AFPTreeParser {
*/
protected function doLevelArrayElements() {
$array = $this->doLevelParenthesis();
- while ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) {
+ while ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) {
$position = $this->mPos;
$index = $this->doLevelSemicolon();
$array = new AFPTreeNode( AFPTreeNode::ARRAY_INDEX, [ $array, $index ], $position );
- if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) {
throw new AFPUserVisibleException( 'expectednotfound', $this->mPos,
[ ']', $this->mCur->type, $this->mCur->value ] );
}
@@ -477,10 +552,22 @@ class AFPTreeParser {
* @throws AFPUserVisibleException
*/
protected function doLevelParenthesis() {
- if ( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == '(' ) {
+ if ( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === '(' ) {
+ $next = $this->getNextToken();
+ if ( $next->type === AFPToken::TBRACE && $next->value === ')' ) {
+ // Empty parentheses are never allowed
+ throw new AFPUserVisibleException(
+ 'unexpectedtoken',
+ $this->mPos,
+ [
+ $this->mCur->type,
+ $this->mCur->value
+ ]
+ );
+ }
$result = $this->doLevelSemicolon();
- if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == ')' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === ')' ) ) {
throw new AFPUserVisibleException(
'expectednotfound',
$this->mPos,
@@ -502,13 +589,13 @@ class AFPTreeParser {
* @throws AFPUserVisibleException
*/
protected function doLevelFunction() {
- if ( $this->mCur->type == AFPToken::TID &&
- isset( AbuseFilterParser::$mFunctions[$this->mCur->value] )
+ if ( $this->mCur->type === AFPToken::TID &&
+ isset( AbuseFilterParser::FUNCTIONS[$this->mCur->value] )
) {
$func = $this->mCur->value;
$position = $this->mPos;
$this->move();
- if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != '(' ) {
+ if ( $this->mCur->type !== AFPToken::TBRACE || $this->mCur->value !== '(' ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mPos,
[
@@ -519,12 +606,47 @@ class AFPTreeParser {
);
}
+ if ( ( $func === 'set' || $func === 'set_var' ) ) {
+ $state = $this->getState();
+ $this->move();
+ $next = $this->getNextToken();
+ if (
+ $this->mCur->type !== AFPToken::TSTRING ||
+ (
+ $next->type !== AFPToken::TCOMMA &&
+ // Let this fail later, when checking parameters count
+ !( $next->type === AFPToken::TBRACE && $next->value === ')' )
+ )
+ ) {
+ throw new AFPUserVisibleException( 'variablevariable', $this->mPos, [] );
+ } else {
+ $this->setState( $state );
+ }
+ }
+
$args = [];
- do {
- $args[] = $this->doLevelSemicolon();
- } while ( $this->mCur->type == AFPToken::TCOMMA );
+ $next = $this->getNextToken();
+ if ( $next->type !== AFPToken::TBRACE || $next->value !== ')' ) {
+ do {
+ $thisArg = $this->doLevelSemicolon();
+ if ( $thisArg === null && !$this->functionIsVariadic( $func ) ) {
+ throw new AFPUserVisibleException(
+ 'unexpectedtoken',
+ $this->mPos,
+ [
+ $this->mCur->type,
+ $this->mCur->value
+ ]
+ );
+ } elseif ( $thisArg !== null ) {
+ $args[] = $thisArg;
+ }
+ } while ( $this->mCur->type === AFPToken::TCOMMA );
+ } else {
+ $this->move();
+ }
- if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) {
+ if ( $this->mCur->type !== AFPToken::TBRACE || $this->mCur->value !== ')' ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mPos,
[
@@ -534,6 +656,11 @@ class AFPTreeParser {
]
);
}
+ // Giving too few arguments to a function is a pretty common error. If we check it here
+ // (as well as at runtime, for OCD), we can make checkSyntax only try to build the AST, as
+ // there would be way less runtime errors. Moreover, this check will also be performed inside
+ // skipped branches, e.g. the discarded if/else branch.
+ $this->checkArgCount( $args, $func );
$this->move();
array_unshift( $args, $func );
@@ -552,6 +679,8 @@ class AFPTreeParser {
$tok = $this->mCur->value;
switch ( $this->mCur->type ) {
case AFPToken::TID:
+ $this->checkLogDeprecatedVar( strtolower( $tok ) );
+ // Fallthrough intended
case AFPToken::TSTRING:
case AFPToken::TFLOAT:
case AFPToken::TINT:
@@ -570,20 +699,20 @@ class AFPTreeParser {
);
/** @noinspection PhpMissingBreakStatementInspection */
case AFPToken::TSQUAREBRACKET:
- if ( $this->mCur->value == '[' ) {
+ if ( $this->mCur->value === '[' ) {
$array = [];
while ( true ) {
$this->move();
- if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) {
+ if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
break;
}
$array[] = $this->doLevelSet();
- if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) {
+ if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
break;
}
- if ( $this->mCur->type != AFPToken::TCOMMA ) {
+ if ( $this->mCur->type !== AFPToken::TCOMMA ) {
throw new AFPUserVisibleException(
'expectednotfound',
$this->mPos,
@@ -609,6 +738,20 @@ class AFPTreeParser {
}
$this->move();
+ // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
+ // @phan-suppress-next-line PhanTypeMismatchReturnNullable Until phan can understand the switch
return $result;
}
+
+ /**
+ * Given a variable name, check if the variable is deprecated. If it is, log the use.
+ * Do that here, and not every time the AST is eval'ed. This means less logging, but more
+ * performance.
+ * @param string $varname
+ */
+ protected function checkLogDeprecatedVar( $varname ) {
+ if ( $this->keywordsManager->isVarDeprecated( $varname ) ) {
+ $this->logger->debug( "Deprecated variable $varname used in filter {$this->mFilter}." );
+ }
+ }
}
diff --git a/AbuseFilter/includes/parser/AFPUserVisibleException.php b/AbuseFilter/includes/parser/AFPUserVisibleException.php
index ab4b2264..0ffce7b1 100644
--- a/AbuseFilter/includes/parser/AFPUserVisibleException.php
+++ b/AbuseFilter/includes/parser/AFPUserVisibleException.php
@@ -20,9 +20,23 @@ class AFPUserVisibleException extends AFPException {
$this->mPosition = $position;
$this->mParams = $params;
- // Exception message text for logs should be in English.
- $msg = $this->getMessageObj()->inLanguage( 'en' )->useDatabase( false )->text();
- parent::__construct( $msg );
+ parent::__construct( $exception_id );
+ }
+
+ /**
+ * Change the message of the exception to a localized version
+ */
+ public function setLocalizedMessage() {
+ $this->message = $this->getMessageObj()->text();
+ }
+
+ /**
+ * Returns the error message in English for use in logs
+ *
+ * @return string
+ */
+ public function getMessageForLogs() {
+ return $this->getMessageObj()->inLanguage( 'en' )->useDatabase( false )->text();
}
/**
@@ -39,6 +53,8 @@ class AFPUserVisibleException extends AFPException {
// abusefilter-exception-overridebuiltin, abusefilter-exception-outofbounds
// abusefilter-exception-notarray, abusefilter-exception-unclosedcomment
// abusefilter-exception-invalidiprange, abusefilter-exception-disabledvar
+ // abusefilter-exception-variablevariable, abusefilter-exception-toomanyargs
+ // abusefilter-exception-negativeoffset
return wfMessage(
'abusefilter-exception-' . $this->mExceptionID,
$this->mPosition, ...$this->mParams
diff --git a/AbuseFilter/includes/parser/AbuseFilterCachingParser.php b/AbuseFilter/includes/parser/AbuseFilterCachingParser.php
index ef634fd4..b5bc0cb9 100644
--- a/AbuseFilter/includes/parser/AbuseFilterCachingParser.php
+++ b/AbuseFilter/includes/parser/AbuseFilterCachingParser.php
@@ -6,8 +6,15 @@
*
* It currently inherits AbuseFilterParser in order to avoid code duplication.
* In future, this code will replace current AbuseFilterParser entirely.
+ *
+ * @todo Override checkSyntax and make it only try to build the AST. That would mean faster results,
+ * and no need to mess with DUNDEFINED and the like. However, we must first try to reduce the
+ * amount of runtime-only exceptions, and try to detect them in the AFPTreeParser instead.
+ * Otherwise, people may be able to save a broken filter without the syntax check reporting that.
*/
class AbuseFilterCachingParser extends AbuseFilterParser {
+ private const CACHE_VERSION = 1;
+
/**
* Return the generated version of the parser for cache invalidation
* purposes. Automatically tracks list of all functions and invalidates the
@@ -21,10 +28,11 @@ class AbuseFilterCachingParser extends AbuseFilterParser {
}
$versionKey = [
+ self::CACHE_VERSION,
AFPTreeParser::CACHE_VERSION,
AbuseFilterTokenizer::CACHE_VERSION,
- array_keys( AbuseFilterParser::$mFunctions ),
- array_keys( AbuseFilterParser::$mKeywords ),
+ array_keys( AbuseFilterParser::FUNCTIONS ),
+ array_keys( AbuseFilterParser::KEYWORDS ),
];
$version = hash( 'sha256', serialize( $versionKey ) );
@@ -35,36 +43,64 @@ class AbuseFilterCachingParser extends AbuseFilterParser {
* Resets the state of the parser
*/
public function resetState() {
- $this->mVars = new AbuseFilterVariableHolder;
+ $this->mVariables = new AbuseFilterVariableHolder( $this->keywordsManager );
$this->mCur = new AFPToken();
+ $this->mCondCount = 0;
+ $this->mAllowShort = true;
}
/**
* @param string $code
* @return AFPData
*/
- public function intEval( $code ) {
- static $cache = null;
- if ( !$cache ) {
- $cache = ObjectCache::getLocalServerInstance( 'hash' );
+ public function intEval( $code ) : AFPData {
+ $startTime = microtime( true );
+ $tree = $this->getTree( $code );
+
+ $res = $this->evalTree( $tree );
+
+ if ( $res->getType() === AFPData::DUNDEFINED ) {
+ $res = new AFPData( AFPData::DBOOL, false );
}
+ $this->statsd->timing( 'abusefilter_cachingParser_full', microtime( true ) - $startTime );
+ return $res;
+ }
- $tree = $cache->getWithSetCallback(
- $cache->makeGlobalKey(
+ /**
+ * @param string $code
+ * @return AFPSyntaxTree
+ */
+ private function getTree( $code ) : AFPSyntaxTree {
+ return $this->cache->getWithSetCallback(
+ $this->cache->makeGlobalKey(
__CLASS__,
self::getCacheVersion(),
hash( 'sha256', $code )
),
- $cache::TTL_DAY,
+ BagOStuff::TTL_DAY,
function () use ( $code ) {
- $parser = new AFPTreeParser();
- return $parser->parse( $code ) ?: false;
+ $parser = new AFPTreeParser( $this->cache, $this->logger, $this->statsd, $this->keywordsManager );
+ $parser->setFilter( $this->mFilter );
+ return $parser->parse( $code );
}
);
+ }
+
+ /**
+ * @param AFPSyntaxTree $tree
+ * @return AFPData
+ */
+ private function evalTree( AFPSyntaxTree $tree ) : AFPData {
+ $startTime = microtime( true );
+ $root = $tree->getRoot();
+
+ if ( !$root ) {
+ return new AFPData( AFPData::DNULL );
+ }
- return $tree
- ? $this->evalNode( $tree )
- : new AFPData( AFPData::DNULL, null );
+ $ret = $this->evalNode( $root );
+ $this->statsd->timing( 'abusefilter_cachingParser_eval', microtime( true ) - $startTime );
+ return $ret;
}
/**
@@ -76,7 +112,7 @@ class AbuseFilterCachingParser extends AbuseFilterParser {
* @throws AFPUserVisibleException
* @throws MWException
*/
- public function evalNode( AFPTreeNode $node ) {
+ private function evalNode( AFPTreeNode $node ) {
// A lot of AbuseFilterParser features rely on $this->mCur->pos or
// $this->mPos for error reporting.
// FIXME: this is a hack which needs to be removed when the parsers are merged.
@@ -103,55 +139,56 @@ class AbuseFilterCachingParser extends AbuseFilterParser {
case "false":
return new AFPData( AFPData::DBOOL, false );
case "null":
- return new AFPData();
+ return new AFPData( AFPData::DNULL );
}
// Fallthrough intended
default:
+ // @codeCoverageIgnoreStart
throw new AFPException( "Unknown token provided in the ATOM node" );
+ // @codeCoverageIgnoreEnd
}
case AFPTreeNode::ARRAY_DEFINITION:
- $items = array_map( [ $this, 'evalNode' ], $node->children );
+ $items = [];
+ // Foreach is usually faster than array_map
+ // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here
+ foreach ( $node->children as $el ) {
+ $items[] = $this->evalNode( $el );
+ }
return new AFPData( AFPData::DARRAY, $items );
case AFPTreeNode::FUNCTION_CALL:
$functionName = $node->children[0];
$args = array_slice( $node->children, 1 );
- $func = self::$mFunctions[$functionName];
- $dataArgs = array_map( [ $this, 'evalNode' ], $args );
-
- /** @noinspection PhpToStringImplementationInspection */
- $funcHash = md5( $func . serialize( $dataArgs ) );
-
- if ( isset( self::$funcCache[$funcHash] ) &&
- !in_array( $func, self::$ActiveFunctions )
- ) {
- $result = self::$funcCache[$funcHash];
- } else {
- AbuseFilter::triggerLimiter();
- $result = self::$funcCache[$funcHash] = $this->$func( $dataArgs );
- }
-
- if ( count( self::$funcCache ) > 1000 ) {
- self::$funcCache = [];
+ $dataArgs = [];
+ // Foreach is usually faster than array_map
+ foreach ( $args as $arg ) {
+ $dataArgs[] = $this->evalNode( $arg );
}
- return $result;
-
+ return $this->callFunc( $functionName, $dataArgs );
case AFPTreeNode::ARRAY_INDEX:
list( $array, $offset ) = $node->children;
$array = $this->evalNode( $array );
- if ( $array->type != AFPData::DARRAY ) {
- throw new AFPUserVisibleException( 'notarray', $node->position, [] );
+ // Note: we MUST evaluate the offset to ensure it is valid, regardless
+ // of $array!
+ $offset = $this->evalNode( $offset )->toInt();
+
+ if ( $array->getType() === AFPData::DUNDEFINED ) {
+ return new AFPData( AFPData::DUNDEFINED );
}
- $offset = $this->evalNode( $offset )->toInt();
+ if ( $array->getType() !== AFPData::DARRAY ) {
+ throw new AFPUserVisibleException( 'notarray', $node->position, [] );
+ }
$array = $array->toArray();
if ( count( $array ) <= $offset ) {
throw new AFPUserVisibleException( 'outofbounds', $node->position,
[ $offset, count( $array ) ] );
+ } elseif ( $offset < 0 ) {
+ throw new AFPUserVisibleException( 'negativeindex', $node->position, [ $offset ] );
}
return $array[$offset];
@@ -159,38 +196,33 @@ class AbuseFilterCachingParser extends AbuseFilterParser {
case AFPTreeNode::UNARY:
list( $operation, $argument ) = $node->children;
$argument = $this->evalNode( $argument );
- if ( $operation == '-' ) {
- return AFPData::unaryMinus( $argument );
+ if ( $operation === '-' ) {
+ return $argument->unaryMinus();
}
return $argument;
case AFPTreeNode::KEYWORD_OPERATOR:
list( $keyword, $leftOperand, $rightOperand ) = $node->children;
- $func = self::$mKeywords[$keyword];
$leftOperand = $this->evalNode( $leftOperand );
$rightOperand = $this->evalNode( $rightOperand );
- AbuseFilter::triggerLimiter();
- $result = AFPData::$func( $leftOperand, $rightOperand, $node->position );
-
- return $result;
+ return $this->callKeyword( $keyword, $leftOperand, $rightOperand );
case AFPTreeNode::BOOL_INVERT:
list( $argument ) = $node->children;
$argument = $this->evalNode( $argument );
- return AFPData::boolInvert( $argument );
+ return $argument->boolInvert();
case AFPTreeNode::POW:
list( $base, $exponent ) = $node->children;
$base = $this->evalNode( $base );
$exponent = $this->evalNode( $exponent );
- return AFPData::pow( $base, $exponent );
+ return $base->pow( $exponent );
case AFPTreeNode::MUL_REL:
list( $op, $leftOperand, $rightOperand ) = $node->children;
$leftOperand = $this->evalNode( $leftOperand );
$rightOperand = $this->evalNode( $rightOperand );
- // FIXME
- return AFPData::mulRel( $leftOperand, $rightOperand, $op, 0 );
+ return $leftOperand->mulRel( $rightOperand, $op, $node->position );
case AFPTreeNode::SUM_REL:
list( $op, $leftOperand, $rightOperand ) = $node->children;
@@ -198,38 +230,51 @@ class AbuseFilterCachingParser extends AbuseFilterParser {
$rightOperand = $this->evalNode( $rightOperand );
switch ( $op ) {
case '+':
- return AFPData::sum( $leftOperand, $rightOperand );
+ return $leftOperand->sum( $rightOperand );
case '-':
- return AFPData::sub( $leftOperand, $rightOperand );
+ return $leftOperand->sub( $rightOperand );
default:
+ // @codeCoverageIgnoreStart
throw new AFPException( "Unknown sum-related operator: {$op}" );
+ // @codeCoverageIgnoreEnd
}
case AFPTreeNode::COMPARE:
list( $op, $leftOperand, $rightOperand ) = $node->children;
$leftOperand = $this->evalNode( $leftOperand );
$rightOperand = $this->evalNode( $rightOperand );
- AbuseFilter::triggerLimiter();
- return AFPData::compareOp( $leftOperand, $rightOperand, $op );
+ $this->raiseCondCount();
+ return $leftOperand->compareOp( $rightOperand, $op );
case AFPTreeNode::LOGIC:
list( $op, $leftOperand, $rightOperand ) = $node->children;
$leftOperand = $this->evalNode( $leftOperand );
- $value = $leftOperand->toBool();
+ $value = $leftOperand->getType() === AFPData::DUNDEFINED ? false : $leftOperand->toBool();
// Short-circuit.
- if ( ( !$value && $op == '&' ) || ( $value && $op == '|' ) ) {
+ if ( ( !$value && $op === '&' ) || ( $value && $op === '|' ) ) {
+ if ( $rightOperand instanceof AFPTreeNode ) {
+ $this->maybeDiscardNode( $rightOperand );
+ }
return $leftOperand;
}
$rightOperand = $this->evalNode( $rightOperand );
- return AFPData::boolOp( $leftOperand, $rightOperand, $op );
+ return $leftOperand->boolOp( $rightOperand, $op );
case AFPTreeNode::CONDITIONAL:
list( $condition, $valueIfTrue, $valueIfFalse ) = $node->children;
$condition = $this->evalNode( $condition );
- if ( $condition->toBool() ) {
+ $isTrue = $condition->getType() === AFPData::DUNDEFINED ? false : $condition->toBool();
+ if ( $isTrue ) {
+ if ( $valueIfFalse !== null ) {
+ $this->maybeDiscardNode( $valueIfFalse );
+ }
return $this->evalNode( $valueIfTrue );
} else {
- return $this->evalNode( $valueIfFalse );
+ $this->maybeDiscardNode( $valueIfTrue );
+ return $valueIfFalse !== null
+ ? $this->evalNode( $valueIfFalse )
+ // We assume null as default if the else is missing
+ : new AFPData( AFPData::DNULL );
}
case AFPTreeNode::ASSIGNMENT:
@@ -241,45 +286,120 @@ class AbuseFilterCachingParser extends AbuseFilterParser {
case AFPTreeNode::INDEX_ASSIGNMENT:
list( $varName, $offset, $value ) = $node->children;
- $array = $this->mVars->getVar( $varName );
- if ( $array->type != AFPData::DARRAY ) {
- throw new AFPUserVisibleException( 'notarray', $node->position, [] );
+ if ( $this->isReservedIdentifier( $varName ) ) {
+ throw new AFPUserVisibleException( 'overridebuiltin', $node->position, [ $varName ] );
}
+ $array = $this->getVarValue( $varName );
- $offset = $this->evalNode( $offset )->toInt();
-
- $array = $array->toArray();
- if ( count( $array ) <= $offset ) {
- throw new AFPUserVisibleException( 'outofbounds', $node->position,
- [ $offset, count( $array ) ] );
+ $value = $this->evalNode( $value );
+ if ( $array->getType() !== AFPData::DUNDEFINED ) {
+ // If it's a DUNDEFINED, leave it as is
+ if ( $array->getType() !== AFPData::DARRAY ) {
+ throw new AFPUserVisibleException( 'notarray', $node->position, [] );
+ }
+
+ $offset = $this->evalNode( $offset )->toInt();
+
+ $array = $array->toArray();
+ if ( count( $array ) <= $offset ) {
+ throw new AFPUserVisibleException( 'outofbounds', $node->position,
+ [ $offset, count( $array ) ] );
+ } elseif ( $offset < 0 ) {
+ throw new AFPUserVisibleException( 'negativeindex', $node->position, [ $offset ] );
+ }
+
+ $array[$offset] = $value;
+ $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) );
}
- $array[$offset] = $this->evalNode( $value );
- $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) );
return $value;
case AFPTreeNode::ARRAY_APPEND:
list( $varName, $value ) = $node->children;
- $array = $this->mVars->getVar( $varName );
- if ( $array->type != AFPData::DARRAY ) {
- throw new AFPUserVisibleException( 'notarray', $node->position, [] );
+ if ( $this->isReservedIdentifier( $varName ) ) {
+ throw new AFPUserVisibleException( 'overridebuiltin', $node->position, [ $varName ] );
}
- $array = $array->toArray();
- $array[] = $this->evalNode( $value );
- $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) );
+ $array = $this->getVarValue( $varName );
+ $value = $this->evalNode( $value );
+ if ( $array->getType() !== AFPData::DUNDEFINED ) {
+ // If it's a DUNDEFINED, leave it as is
+ if ( $array->getType() !== AFPData::DARRAY ) {
+ throw new AFPUserVisibleException( 'notarray', $node->position, [] );
+ }
+
+ $array = $array->toArray();
+ $array[] = $value;
+ $this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) );
+ }
return $value;
case AFPTreeNode::SEMICOLON:
$lastValue = null;
+ // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here
foreach ( $node->children as $statement ) {
$lastValue = $this->evalNode( $statement );
}
+ // @phan-suppress-next-next-line PhanTypeMismatchReturnNullable Can never be null because
+ // empty statements are discarded in AFPTreeParser
return $lastValue;
default:
+ // @codeCoverageIgnoreStart
throw new AFPException( "Unknown node type passed: {$node->type}" );
+ // @codeCoverageIgnoreEnd
}
}
+
+ /**
+ * Given a node that we don't need to evaluate, decide what to do with it. The nodes passed in
+ * will usually be discarded by short-circuit evaluation. If we allow it, then we just hoist
+ * the variables assigned in any descendant of the node. Otherwise, we fully evaluate the node.
+ *
+ * @param AFPTreeNode $node
+ */
+ private function maybeDiscardNode( AFPTreeNode $node ) {
+ if ( $this->mAllowShort ) {
+ $this->discardWithHoisting( $node );
+ } else {
+ $this->evalNode( $node );
+ }
+ }
+
+ /**
+ * Intended to be used for short-circuit as a solution for T214674.
+ * Given a node, check it and its children; if there are assignments of non-existing variables,
+ * hoist them. In case of index assignment or array append, the old value is always erased and
+ * overwritten with a DUNDEFINED. This is used to allow stuff like:
+ * false & ( var := 'foo' ); var == 2
+ * or
+ * if ( false ) then ( var := 'foo' ) else ( 1 ) end; var == 2
+ * where `false` is something evaluated as false at runtime.
+ *
+ * @note This method doesn't check whether the variable exists in case of index assignments.
+ * Hence, in `false & (nonexistent[] := 2)`, `nonexistent` would be hoisted without errors.
+ * However, that would by caught by checkSyntax, so we can avoid checking here: we'd need
+ * way more context than we currently have.
+ *
+ * @param AFPTreeNode $node
+ */
+ private function discardWithHoisting( AFPTreeNode $node ) {
+ foreach ( $node->getInnerAssignments() as $name ) {
+ if (
+ !$this->mVariables->varIsSet( $name ) ||
+ $this->mVariables->getVar( $name )->getType() === AFPData::DARRAY
+ ) {
+ $this->setUserVariable( $name, new AFPData( AFPData::DUNDEFINED ) );
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ * This parser should not log, because that's handled in AFPTreeParser
+ */
+ protected function logsDeprecatedVars() {
+ return false;
+ }
}
diff --git a/AbuseFilter/includes/parser/AbuseFilterParser.php b/AbuseFilter/includes/parser/AbuseFilterParser.php
index 89ddea0f..aed48007 100644
--- a/AbuseFilter/includes/parser/AbuseFilterParser.php
+++ b/AbuseFilter/includes/parser/AbuseFilterParser.php
@@ -1,58 +1,78 @@
<?php
+use MediaWiki\Extension\AbuseFilter\KeywordsManager;
+use Psr\Log\LoggerInterface;
+use Wikimedia\AtEase\AtEase;
use Wikimedia\Equivset\Equivset;
-use MediaWiki\Logger\LoggerFactory;
+use Wikimedia\IPUtils;
-class AbuseFilterParser {
- public $mCode, $mTokens, $mPos, $mShortCircuit, $mAllowShort, $mLen;
- /** @var AFPToken The current token */
+class AbuseFilterParser extends AFPTransitionBase {
+ /**
+ * @var array[] Contains the AFPTokens for the code being parsed
+ */
+ public $mTokens;
+ /**
+ * @var bool Are we inside a short circuit evaluation?
+ */
+ public $mShortCircuit;
+ /**
+ * @var bool Are we allowed to use short-circuit evaluation?
+ */
+ public $mAllowShort;
+ /**
+ * @var AFPToken The current token
+ */
public $mCur;
-
/**
* @var AbuseFilterVariableHolder
*/
- public $mVars;
-
- // length,lcase,ucase,ccnorm,rmdoubles,specialratio,rmspecials,norm,count,get_matches
- public static $mFunctions = [
- 'lcase' => 'funcLc',
- 'ucase' => 'funcUc',
- 'length' => 'funcLen',
- 'string' => 'castString',
- 'int' => 'castInt',
- 'float' => 'castFloat',
- 'bool' => 'castBool',
- 'norm' => 'funcNorm',
- 'ccnorm' => 'funcCCNorm',
- 'ccnorm_contains_any' => 'funcCCNormContainsAny',
- 'ccnorm_contains_all' => 'funcCCNormContainsAll',
- 'specialratio' => 'funcSpecialRatio',
- 'rmspecials' => 'funcRMSpecials',
- 'rmdoubles' => 'funcRMDoubles',
- 'rmwhitespace' => 'funcRMWhitespace',
- 'count' => 'funcCount',
- 'rcount' => 'funcRCount',
- 'get_matches' => 'funcGetMatches',
- 'ip_in_range' => 'funcIPInRange',
- 'contains_any' => 'funcContainsAny',
- 'contains_all' => 'funcContainsAll',
- 'equals_to_any' => 'funcEqualsToAny',
- 'substr' => 'funcSubstr',
- 'strlen' => 'funcLen',
- 'strpos' => 'funcStrPos',
- 'str_replace' => 'funcStrReplace',
- 'rescape' => 'funcStrRegexEscape',
- 'set' => 'funcSetVar',
- 'set_var' => 'funcSetVar',
- 'sanitize' => 'funcSanitize',
- ];
+ public $mVariables;
+
+ /**
+ * @var int The current amount of conditions being consumed
+ */
+ protected $mCondCount;
+
+ /**
+ * @var bool Whether the condition limit is enabled.
+ */
+ protected $condLimitEnabled = true;
+
+ /**
+ * @var string|null The ID of the filter being parsed, if available. Can also be "global-$ID"
+ */
+ protected $mFilter;
+ /**
+ * @var bool Whether we can allow retrieving _builtin_ variables not included in $this->mVariables
+ */
+ protected $allowMissingVariables = false;
+
+ /**
+ * @var BagOStuff Used to cache the AST (in CachingParser) and the tokens
+ */
+ protected $cache;
+ /**
+ * @var LoggerInterface Used for debugging
+ */
+ protected $logger;
+ /**
+ * @var Language Content language, used for language-dependent functions
+ */
+ protected $contLang;
+ /**
+ * @var IBufferingStatsdDataFactory
+ */
+ protected $statsd;
+
+ /** @var KeywordsManager */
+ protected $keywordsManager;
// Functions that affect parser state, and shouldn't be cached.
- public static $ActiveFunctions = [
+ public const ACTIVE_FUNCTIONS = [
'funcSetVar',
];
- public static $mKeywords = [
+ public const KEYWORDS = [
'in' => 'keywordIn',
'like' => 'keywordLike',
'matches' => 'keywordLike',
@@ -62,7 +82,10 @@ class AbuseFilterParser {
'regex' => 'keywordRegex',
];
- public static $funcCache = [];
+ /**
+ * @var array Cached results of functions
+ */
+ protected $funcCache = [];
/**
* @var Equivset
@@ -72,12 +95,93 @@ class AbuseFilterParser {
/**
* Create a new instance
*
+ * @param Language $contLang Content language, used for language-dependent function
+ * @param BagOStuff $cache Used to cache the AST (in CachingParser) and the tokens
+ * @param LoggerInterface $logger Used for debugging
+ * @param KeywordsManager $keywordsManager
* @param AbuseFilterVariableHolder|null $vars
*/
- public function __construct( $vars = null ) {
+ public function __construct(
+ Language $contLang,
+ BagOStuff $cache,
+ LoggerInterface $logger,
+ KeywordsManager $keywordsManager,
+ AbuseFilterVariableHolder $vars = null
+ ) {
+ $this->contLang = $contLang;
+ $this->cache = $cache;
+ $this->logger = $logger;
+ $this->statsd = new NullStatsdDataFactory;
+ $this->keywordsManager = $keywordsManager;
$this->resetState();
- if ( $vars instanceof AbuseFilterVariableHolder ) {
- $this->mVars = $vars;
+ if ( $vars ) {
+ $this->mVariables = $vars;
+ }
+ $this->mVariables->setLogger( $logger );
+ }
+
+ /**
+ * @param string $filter
+ */
+ public function setFilter( $filter ) {
+ $this->mFilter = $filter;
+ }
+
+ /**
+ * @param BagOStuff $cache
+ */
+ public function setCache( BagOStuff $cache ) {
+ $this->cache = $cache;
+ }
+
+ /**
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @param IBufferingStatsdDataFactory $statsd
+ */
+ public function setStatsd( IBufferingStatsdDataFactory $statsd ) {
+ $this->statsd = $statsd;
+ }
+
+ /**
+ * @return int
+ */
+ public function getCondCount() {
+ return $this->mCondCount;
+ }
+
+ /**
+ * Reset the conditions counter
+ */
+ public function resetCondCount() {
+ $this->mCondCount = 0;
+ }
+
+ /**
+ * For use in batch scripts and the like
+ *
+ * @param bool $enable True to enable the limit, false to disable it
+ */
+ public function toggleConditionLimit( $enable ) {
+ $this->condLimitEnabled = $enable;
+ }
+
+ /**
+ * @param int $val The amount to increase the conditions count of.
+ * @throws MWException
+ */
+ protected function raiseCondCount( $val = 1 ) {
+ global $wgAbuseFilterConditionLimit;
+
+ $this->mCondCount += $val;
+
+ if ( $this->condLimitEnabled && $this->mCondCount > $wgAbuseFilterConditionLimit ) {
+ throw new MWException( 'Condition limit reached.' );
}
}
@@ -85,35 +189,101 @@ class AbuseFilterParser {
* Resets the state of the parser.
*/
public function resetState() {
- $this->mCode = '';
$this->mTokens = [];
- $this->mVars = new AbuseFilterVariableHolder;
+ $this->mVariables = new AbuseFilterVariableHolder( $this->keywordsManager );
$this->mPos = 0;
$this->mShortCircuit = false;
$this->mAllowShort = true;
+ $this->mCondCount = 0;
+ $this->mFilter = null;
+ }
+
+ /**
+ * Clears the array of cached function results
+ */
+ public function clearFuncCache() {
+ $this->funcCache = [];
}
/**
+ * @param AbuseFilterVariableHolder $vars
+ */
+ public function setVariables( AbuseFilterVariableHolder $vars ) {
+ $this->mVariables = $vars;
+ }
+
+ /**
+ * Check the syntax of $filter, throwing an exception if invalid
* @param string $filter
- * @return true|array True when successful, otherwise a two-element array with exception message
- * and character position of the syntax error
+ * @return true When successful
+ * @throws AFPUserVisibleException
*/
- public function checkSyntax( $filter ) {
+ public function checkSyntaxThrow( string $filter ) {
+ $this->allowMissingVariables = true;
$origAS = $this->mAllowShort;
try {
$this->mAllowShort = false;
- $this->parse( $filter );
- } catch ( AFPUserVisibleException $excep ) {
+ $this->intEval( $filter );
+ } finally {
$this->mAllowShort = $origAS;
-
- return [ $excep->getMessageObj()->text(), $excep->mPosition ];
+ $this->allowMissingVariables = false;
}
- $this->mAllowShort = $origAS;
return true;
}
/**
+ * Check the syntax of $filter, without throwing
+ *
+ * @param string $filter
+ * @return true|array True when successful, otherwise a two-element array with exception message
+ * and character position of the syntax error
+ */
+ public function checkSyntax( string $filter ) {
+ try {
+ $res = $this->checkSyntaxThrow( $filter );
+ } catch ( AFPUserVisibleException $excep ) {
+ $res = [ $excep->getMessageObj()->text(), $excep->mPosition ];
+ }
+ return $res;
+ }
+
+ /**
+ * This is the main entry point. It checks the given conditions and returns whether
+ * they match. In case of bad syntax, this is always logged, and $ignoreError can
+ * be used to determine whether this method should throw.
+ *
+ * @param string $conds
+ * @param bool $ignoreError
+ * @param string|null $filter The ID of the filter being parsed
+ * @return bool
+ * @throws Exception
+ */
+ public function checkConditions( string $conds, $ignoreError = true, $filter = null ) : bool {
+ try {
+ $result = $this->parse( $conds );
+ } catch ( Exception $excep ) {
+ $result = false;
+
+ if ( $excep instanceof AFPUserVisibleException ) {
+ $msg = $excep->getMessageForLogs();
+ $excep->setLocalizedMessage();
+ } else {
+ $msg = $excep->getMessage();
+ }
+
+ $extraInfo = $filter !== null ? " for filter $filter" : '';
+ $this->logger->warning( "AbuseFilter parser error$extraInfo: $msg" );
+
+ if ( !$ignoreError ) {
+ throw $excep;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
* Move to the next token
*/
protected function move() {
@@ -121,6 +291,16 @@ class AbuseFilterParser {
}
/**
+ * Get the next token. This is similar to move() but doesn't change class members,
+ * allowing to look ahead without rolling back the state.
+ *
+ * @return AFPToken
+ */
+ protected function getNextToken() {
+ return $this->mTokens[$this->mPos][0];
+ }
+
+ /**
* getState() function allows parser state to be rollbacked to several tokens back
* @return AFPParserState
*/
@@ -142,17 +322,52 @@ class AbuseFilterParser {
*/
protected function skipOverBraces() {
$braces = 1;
- while ( $this->mCur->type != AFPToken::TNONE && $braces > 0 ) {
+ while ( $this->mCur->type !== AFPToken::TNONE && $braces > 0 ) {
$this->move();
- if ( $this->mCur->type == AFPToken::TBRACE ) {
- if ( $this->mCur->value == '(' ) {
+ if ( $this->mCur->type === AFPToken::TBRACE ) {
+ if ( $this->mCur->value === '(' ) {
$braces++;
- } elseif ( $this->mCur->value == ')' ) {
+ } elseif ( $this->mCur->value === ')' ) {
$braces--;
}
+ } elseif ( $this->mCur->type === AFPToken::TID ) {
+ // T214674, define non-existing variables. @see docs of
+ // AbuseFilterCachingParser::discardWithHoisting for a detailed explanation of this branch
+ $next = $this->getNextToken();
+ if (
+ in_array( $this->mCur->value, [ 'set', 'set_var' ] ) &&
+ $next->type === AFPToken::TBRACE && $next->value === '('
+ ) {
+ // This is for setter functions.
+ $this->move();
+ $braces++;
+ $next = $this->getNextToken();
+ if ( $next->type === AFPToken::TSTRING ) {
+ if ( !$this->mVariables->varIsSet( $next->value ) ) {
+ $this->setUserVariable( $next->value, new AFPData( AFPData::DUNDEFINED ) );
+ }
+ }
+ } else {
+ // Simple assignment with :=
+ $varname = $this->mCur->value;
+ $next = $this->getNextToken();
+ if ( $next->type === AFPToken::TOP && $next->value === ':=' ) {
+ if ( !$this->mVariables->varIsSet( $varname ) ) {
+ $this->setUserVariable( $varname, new AFPData( AFPData::DUNDEFINED ) );
+ }
+ } elseif ( $next->type === AFPToken::TSQUAREBRACKET && $next->value === '[' ) {
+ if ( !$this->mVariables->varIsSet( $varname ) ) {
+ throw new AFPUserVisibleException( 'unrecognisedvar',
+ $next->pos,
+ [ $varname ]
+ );
+ }
+ $this->setUserVariable( $varname, new AFPData( AFPData::DUNDEFINED ) );
+ }
+ }
}
}
- if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == ')' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === ')' ) ) {
throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos, [ ')' ] );
}
}
@@ -170,7 +385,7 @@ class AbuseFilterParser {
* @return string
*/
public function evaluateExpression( $filter ) {
- return $this->intEval( $filter )->toString();
+ return $this->intEval( $filter )->toNative();
}
/**
@@ -178,16 +393,21 @@ class AbuseFilterParser {
* @return AFPData
*/
public function intEval( $code ) {
+ $startTime = microtime( true );
// Reset all class members to their default value
- $this->mCode = $code;
- $this->mTokens = AbuseFilterTokenizer::tokenize( $code );
+ $tokenizer = new AbuseFilterTokenizer( $this->cache, $this->logger );
+ $this->mTokens = $tokenizer->getTokens( $code );
$this->mPos = 0;
- $this->mLen = strlen( $code );
$this->mShortCircuit = false;
- $result = new AFPData();
+ $result = new AFPData( AFPData::DEMPTY );
$this->doLevelEntry( $result );
+ if ( $result->getType() === AFPData::DUNDEFINED ) {
+ $result = new AFPData( AFPData::DBOOL, false );
+ }
+
+ $this->statsd->timing( 'abusefilter_oldparser_full', microtime( true ) - $startTime );
return $result;
}
@@ -202,7 +422,7 @@ class AbuseFilterParser {
protected function doLevelEntry( &$result ) {
$this->doLevelSemicolon( $result );
- if ( $this->mCur->type != AFPToken::TNONE ) {
+ if ( $this->mCur->type !== AFPToken::TNONE ) {
throw new AFPUserVisibleException(
'unexpectedatend',
$this->mCur->pos, [ $this->mCur->type ]
@@ -218,10 +438,10 @@ class AbuseFilterParser {
protected function doLevelSemicolon( &$result ) {
do {
$this->move();
- if ( $this->mCur->type != AFPToken::TSTATEMENTSEPARATOR ) {
+ if ( $this->mCur->type !== AFPToken::TSTATEMENTSEPARATOR ) {
$this->doLevelSet( $result );
}
- } while ( $this->mCur->type == AFPToken::TSTATEMENTSEPARATOR );
+ } while ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR );
}
/**
@@ -231,57 +451,76 @@ class AbuseFilterParser {
* @throws AFPUserVisibleException
*/
protected function doLevelSet( &$result ) {
- if ( $this->mCur->type == AFPToken::TID ) {
+ if ( $this->mCur->type === AFPToken::TID ) {
$varname = $this->mCur->value;
$prev = $this->getState();
$this->move();
- if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) {
+ if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) {
$this->move();
+ $checkEmpty = $result->getType() === AFPData::DEMPTY;
$this->doLevelSet( $result );
+ if ( $checkEmpty && $result->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'var assignment' );
+ }
$this->setUserVariable( $varname, $result );
return;
- } elseif ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) {
- if ( !$this->mVars->varIsSet( $varname ) ) {
- throw new AFPUserVisibleException( 'unrecognisedvar',
- $this->mCur->pos,
- [ $varname ]
- );
- }
- $array = $this->mVars->getVar( $varname );
- if ( $array->type != AFPData::DARRAY ) {
+ } elseif ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) {
+ // We allow builtin variables to both check for override (e.g. added_lines[] :='x')
+ // and for T198531
+ $array = $this->getVarValue( $varname );
+ if (
+ $array->getType() !== AFPData::DARRAY && $array->getType() !== AFPData::DUNDEFINED
+ // NOTE: we cannot throw for overridebuiltin yet, in case this turns out not to be an assignment.
+ && !$this->keywordsManager->varExists( $varname )
+ ) {
throw new AFPUserVisibleException( 'notarray', $this->mCur->pos, [] );
}
- $array = $array->toArray();
+
$this->move();
- if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) {
+ if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
$idx = 'new';
} else {
$this->setState( $prev );
$this->move();
- $idx = new AFPData();
+ $idx = new AFPData( AFPData::DEMPTY );
$this->doLevelSemicolon( $idx );
$idx = $idx->toInt();
- if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) {
throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos,
[ ']', $this->mCur->type, $this->mCur->value ] );
}
- if ( count( $array ) <= $idx ) {
- throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos,
- [ $idx, count( $result->data ) ] );
+ if ( $array->getType() === AFPData::DARRAY ) {
+ if ( count( $array->toArray() ) <= $idx ) {
+ throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos,
+ [ $idx, count( $array->getData() ) ] );
+ } elseif ( $idx < 0 ) {
+ throw new AFPUserVisibleException( 'negativeindex', $this->mCur->pos, [ $idx ] );
+ }
}
}
$this->move();
- if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':=' ) {
+ if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) {
+ if ( $this->isReservedIdentifier( $varname ) ) {
+ // Ideally we should've aborted before trying to parse the index
+ throw new AFPUserVisibleException( 'overridebuiltin', $this->mCur->pos, [ $varname ] );
+ }
$this->move();
+ if ( $this->mCur->type === AFPToken::TNONE ) {
+ $this->logEmptyOperand( 'array assignment' );
+ }
$this->doLevelSet( $result );
- if ( $idx === 'new' ) {
- $array[] = $result;
- } else {
- $array[$idx] = $result;
+ if ( $array->getType() === AFPData::DARRAY ) {
+ // If it's a DUNDEFINED, leave it as is
+ $array = $array->toArray();
+ if ( $idx === 'new' ) {
+ $array[] = $result;
+ } else {
+ $array[$idx] = $result;
+ }
+ $this->setUserVariable( $varname, new AFPData( AFPData::DARRAY, $array ) );
}
- $this->setUserVariable( $varname, new AFPData( AFPData::DARRAY, $array ) );
return;
} else {
@@ -299,13 +538,14 @@ class AbuseFilterParser {
*
* @param AFPData &$result
* @throws AFPUserVisibleException
+ * @suppress PhanPossiblyUndeclaredVariable
*/
protected function doLevelConditions( &$result ) {
- if ( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'if' ) {
+ if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'if' ) {
$this->move();
$this->doLevelBoolOps( $result );
- if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'then' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'then' ) ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mCur->pos,
[
@@ -317,42 +557,35 @@ class AbuseFilterParser {
}
$this->move();
- $r1 = new AFPData();
- $r2 = new AFPData();
+ $r1 = new AFPData( AFPData::DEMPTY );
+ $r2 = new AFPData( AFPData::DEMPTY );
- $isTrue = $result->toBool();
+ $isTrue = $result->getType() === AFPData::DUNDEFINED ? false : $result->toBool();
if ( !$isTrue ) {
- $scOrig = $this->mShortCircuit;
- $this->mShortCircuit = $this->mAllowShort;
+ $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true );
}
$this->doLevelConditions( $r1 );
if ( !$isTrue ) {
$this->mShortCircuit = $scOrig;
}
- if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'else' ) ) {
- throw new AFPUserVisibleException( 'expectednotfound',
- $this->mCur->pos,
- [
- 'else',
- $this->mCur->type,
- $this->mCur->value
- ]
- );
- }
- $this->move();
+ if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'else' ) {
+ $this->move();
- if ( $isTrue ) {
- $scOrig = $this->mShortCircuit;
- $this->mShortCircuit = $this->mAllowShort;
- }
- $this->doLevelConditions( $r2 );
- if ( $isTrue ) {
- $this->mShortCircuit = $scOrig;
+ if ( $isTrue ) {
+ $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true );
+ }
+ $this->doLevelConditions( $r2 );
+ if ( $isTrue ) {
+ $this->mShortCircuit = $scOrig;
+ }
+ } else {
+ // DNULL is assumed as default in case of a missing else
+ $r2 = new AFPData( AFPData::DNULL );
}
- if ( !( $this->mCur->type == AFPToken::TKEYWORD && $this->mCur->value == 'end' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'end' ) ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mCur->pos,
[
@@ -364,30 +597,30 @@ class AbuseFilterParser {
}
$this->move();
- if ( $result->toBool() ) {
+ $isTrue = $result->getType() === AFPData::DUNDEFINED ? false : $result->toBool();
+ if ( $isTrue ) {
$result = $r1;
} else {
$result = $r2;
}
} else {
$this->doLevelBoolOps( $result );
- if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '?' ) {
+ if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '?' ) {
$this->move();
- $r1 = new AFPData();
- $r2 = new AFPData();
+ $r1 = new AFPData( AFPData::DEMPTY );
+ $r2 = new AFPData( AFPData::DEMPTY );
- $isTrue = $result->toBool();
+ $isTrue = $result->getType() === AFPData::DUNDEFINED ? false : $result->toBool();
if ( !$isTrue ) {
- $scOrig = $this->mShortCircuit;
- $this->mShortCircuit = $this->mAllowShort;
+ $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true );
}
$this->doLevelConditions( $r1 );
if ( !$isTrue ) {
$this->mShortCircuit = $scOrig;
}
- if ( !( $this->mCur->type == AFPToken::TOP && $this->mCur->value == ':' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':' ) ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mCur->pos,
[
@@ -400,10 +633,12 @@ class AbuseFilterParser {
$this->move();
if ( $isTrue ) {
- $scOrig = $this->mShortCircuit;
- $this->mShortCircuit = $this->mAllowShort;
+ $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true );
}
$this->doLevelConditions( $r2 );
+ if ( $r2->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'ternary else' );
+ }
if ( $isTrue ) {
$this->mShortCircuit = $scOrig;
}
@@ -425,24 +660,29 @@ class AbuseFilterParser {
protected function doLevelBoolOps( &$result ) {
$this->doLevelCompares( $result );
$ops = [ '&', '|', '^' ];
- while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
+ while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
$op = $this->mCur->value;
$this->move();
- $r2 = new AFPData();
+ $r2 = new AFPData( AFPData::DEMPTY );
+ $curVal = $result->getType() === AFPData::DUNDEFINED ? false : $result->toBool();
// We can go on quickly as either one statement with | is true or one with & is false
- if ( ( $op == '&' && !$result->toBool() ) || ( $op == '|' && $result->toBool() ) ) {
- $orig = $this->mShortCircuit;
- $this->mShortCircuit = $this->mAllowShort;
+ if ( ( $op === '&' && !$curVal ) || ( $op === '|' && $curVal ) ) {
+ $scOrig = wfSetVar( $this->mShortCircuit, $this->mAllowShort, true );
$this->doLevelCompares( $r2 );
- $this->mShortCircuit = $orig;
- $result = new AFPData( AFPData::DBOOL, $result->toBool() );
+ if ( $r2->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'bool operand' );
+ }
+ $this->mShortCircuit = $scOrig;
+ $result = new AFPData( AFPData::DBOOL, $curVal );
continue;
}
$this->doLevelCompares( $r2 );
-
- $result = AFPData::boolOp( $result, $r2, $op );
+ if ( $r2->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'bool operand' );
+ }
+ $result = $result->boolOp( $r2, $op );
}
}
@@ -453,18 +693,28 @@ class AbuseFilterParser {
*/
protected function doLevelCompares( &$result ) {
$this->doLevelSumRels( $result );
- $ops = [ '==', '===', '!=', '!==', '<', '>', '<=', '>=', '=' ];
- while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
+ $equalityOps = [ '==', '===', '!=', '!==', '=' ];
+ $orderOps = [ '<', '>', '<=', '>=' ];
+ // Only allow either a single operation, or a combination of a single equalityOps and a single
+ // orderOps. This resembles what PHP does, and allows `a < b == c` while rejecting `a < b < c`
+ $allowedOps = array_merge( $equalityOps, $orderOps );
+ while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $allowedOps ) ) {
+ $allowedOps = in_array( $this->mCur->value, $equalityOps ) ?
+ array_diff( $allowedOps, $equalityOps ) :
+ array_diff( $allowedOps, $orderOps );
$op = $this->mCur->value;
$this->move();
- $r2 = new AFPData();
+ $r2 = new AFPData( AFPData::DEMPTY );
$this->doLevelSumRels( $r2 );
+ if ( $r2->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'compare operand' );
+ }
if ( $this->mShortCircuit ) {
// The result doesn't matter.
- break;
+ continue;
}
- AbuseFilter::triggerLimiter();
- $result = AFPData::compareOp( $result, $r2, $op );
+ $this->raiseCondCount();
+ $result = $result->compareOp( $r2, $op );
}
}
@@ -476,20 +726,23 @@ class AbuseFilterParser {
protected function doLevelSumRels( &$result ) {
$this->doLevelMulRels( $result );
$ops = [ '+', '-' ];
- while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
+ while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
$op = $this->mCur->value;
$this->move();
- $r2 = new AFPData();
+ $r2 = new AFPData( AFPData::DEMPTY );
$this->doLevelMulRels( $r2 );
+ if ( $r2->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'sum operand' );
+ }
if ( $this->mShortCircuit ) {
// The result doesn't matter.
- break;
+ continue;
}
- if ( $op == '+' ) {
- $result = AFPData::sum( $result, $r2 );
+ if ( $op === '+' ) {
+ $result = $result->sum( $r2 );
}
- if ( $op == '-' ) {
- $result = AFPData::sub( $result, $r2 );
+ if ( $op === '-' ) {
+ $result = $result->sub( $r2 );
}
}
}
@@ -502,16 +755,19 @@ class AbuseFilterParser {
protected function doLevelMulRels( &$result ) {
$this->doLevelPow( $result );
$ops = [ '*', '/', '%' ];
- while ( $this->mCur->type == AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
+ while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
$op = $this->mCur->value;
$this->move();
- $r2 = new AFPData();
+ $r2 = new AFPData( AFPData::DEMPTY );
$this->doLevelPow( $r2 );
+ if ( $r2->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'multiplication operand' );
+ }
if ( $this->mShortCircuit ) {
// The result doesn't matter.
- break;
+ continue;
}
- $result = AFPData::mulRel( $result, $r2, $op, $this->mCur->pos );
+ $result = $result->mulRel( $r2, $op, $this->mCur->pos );
}
}
@@ -522,15 +778,18 @@ class AbuseFilterParser {
*/
protected function doLevelPow( &$result ) {
$this->doLevelBoolInvert( $result );
- while ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '**' ) {
+ while ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '**' ) {
$this->move();
- $expanent = new AFPData();
+ $expanent = new AFPData( AFPData::DEMPTY );
$this->doLevelBoolInvert( $expanent );
+ if ( $expanent->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'power operand' );
+ }
if ( $this->mShortCircuit ) {
// The result doesn't matter.
- break;
+ continue;
}
- $result = AFPData::pow( $result, $expanent );
+ $result = $result->pow( $expanent );
}
}
@@ -540,14 +799,18 @@ class AbuseFilterParser {
* @param AFPData &$result
*/
protected function doLevelBoolInvert( &$result ) {
- if ( $this->mCur->type == AFPToken::TOP && $this->mCur->value == '!' ) {
+ if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '!' ) {
$this->move();
+ $checkEmpty = $result->getType() === AFPData::DEMPTY;
$this->doLevelSpecialWords( $result );
+ if ( $checkEmpty && $result->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'bool inversion' );
+ }
if ( $this->mShortCircuit ) {
// The result doesn't matter.
return;
}
- $result = AFPData::boolInvert( $result );
+ $result = $result->boolInvert();
} else {
$this->doLevelSpecialWords( $result );
}
@@ -561,22 +824,23 @@ class AbuseFilterParser {
protected function doLevelSpecialWords( &$result ) {
$this->doLevelUnarys( $result );
$keyword = strtolower( $this->mCur->value );
- if ( $this->mCur->type == AFPToken::TKEYWORD
- && isset( self::$mKeywords[$keyword] )
+ if ( $this->mCur->type === AFPToken::TKEYWORD
+ && isset( self::KEYWORDS[$keyword] )
) {
- $func = self::$mKeywords[$keyword];
$this->move();
- $r2 = new AFPData();
+ $r2 = new AFPData( AFPData::DEMPTY );
$this->doLevelUnarys( $r2 );
+ if ( $r2->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'keyword operand' );
+ }
+
if ( $this->mShortCircuit ) {
// The result doesn't matter.
return;
}
- AbuseFilter::triggerLimiter();
-
- $result = AFPData::$func( $result, $r2, $this->mCur->pos );
+ $result = $this->callKeyword( $keyword, $result, $r2 );
}
}
@@ -587,15 +851,19 @@ class AbuseFilterParser {
*/
protected function doLevelUnarys( &$result ) {
$op = $this->mCur->value;
- if ( $this->mCur->type == AFPToken::TOP && ( $op == "+" || $op == "-" ) ) {
+ if ( $this->mCur->type === AFPToken::TOP && ( $op === "+" || $op === "-" ) ) {
$this->move();
+ $checkEmpty = $result->getType() === AFPData::DEMPTY;
$this->doLevelArrayElements( $result );
+ if ( $checkEmpty && $result->getType() === AFPData::DEMPTY ) {
+ $this->logEmptyOperand( 'unary operand' );
+ }
if ( $this->mShortCircuit ) {
// The result doesn't matter.
return;
}
- if ( $op == '-' ) {
- $result = AFPData::unaryMinus( $result );
+ if ( $op === '-' ) {
+ $result = $result->unaryMinus();
}
} else {
$this->doLevelArrayElements( $result );
@@ -610,20 +878,25 @@ class AbuseFilterParser {
*/
protected function doLevelArrayElements( &$result ) {
$this->doLevelBraces( $result );
- while ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == '[' ) {
- $idx = new AFPData();
+ while ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) {
+ $idx = new AFPData( AFPData::DEMPTY );
$this->doLevelSemicolon( $idx );
- if ( !( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) ) {
+ if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) {
throw new AFPUserVisibleException( 'expectednotfound', $this->mCur->pos,
[ ']', $this->mCur->type, $this->mCur->value ] );
}
$idx = $idx->toInt();
- if ( $result->type == AFPData::DARRAY ) {
- if ( count( $result->data ) <= $idx ) {
+ if ( $result->getType() === AFPData::DARRAY ) {
+ if ( count( $result->getData() ) <= $idx ) {
throw new AFPUserVisibleException( 'outofbounds', $this->mCur->pos,
- [ $idx, count( $result->data ) ] );
+ [ $idx, count( $result->getData() ) ] );
+ } elseif ( $idx < 0 ) {
+ throw new AFPUserVisibleException( 'negativeindex', $this->mCur->pos, [ $idx ] );
}
- $result = $result->data[$idx];
+ // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Guaranteed to be array
+ $result = $result->getData()[$idx];
+ } elseif ( $result->getType() === AFPData::DUNDEFINED ) {
+ $result = new AFPData( AFPData::DUNDEFINED );
} else {
throw new AFPUserVisibleException( 'notarray', $this->mCur->pos, [] );
}
@@ -638,20 +911,29 @@ class AbuseFilterParser {
* @throws AFPUserVisibleException
*/
protected function doLevelBraces( &$result ) {
- if ( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == '(' ) {
- if ( $this->mShortCircuit ) {
- $this->skipOverBraces();
+ if ( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === '(' ) {
+ $next = $this->getNextToken();
+ if ( $next->type === AFPToken::TBRACE && $next->value === ')' ) {
+ $this->logEmptyOperand( 'parenthesized expression' );
+ // We don't need DUNDEFINED here
+ $this->move();
+ $this->move();
} else {
- $this->doLevelSemicolon( $result );
- }
- if ( !( $this->mCur->type == AFPToken::TBRACE && $this->mCur->value == ')' ) ) {
- throw new AFPUserVisibleException(
- 'expectednotfound',
- $this->mCur->pos,
- [ ')', $this->mCur->type, $this->mCur->value ]
- );
+ if ( $this->mShortCircuit ) {
+ $result = new AFPData( AFPData::DUNDEFINED );
+ $this->skipOverBraces();
+ } else {
+ $this->doLevelSemicolon( $result );
+ }
+ if ( !( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === ')' ) ) {
+ throw new AFPUserVisibleException(
+ 'expectednotfound',
+ $this->mCur->pos,
+ [ ')', $this->mCur->type, $this->mCur->value ]
+ );
+ }
+ $this->move();
}
- $this->move();
} else {
$this->doLevelFunction( $result );
}
@@ -664,10 +946,10 @@ class AbuseFilterParser {
* @throws AFPUserVisibleException
*/
protected function doLevelFunction( &$result ) {
- if ( $this->mCur->type == AFPToken::TID && isset( self::$mFunctions[$this->mCur->value] ) ) {
- $func = self::$mFunctions[$this->mCur->value];
+ if ( $this->mCur->type === AFPToken::TID && isset( self::FUNCTIONS[$this->mCur->value] ) ) {
+ $fname = $this->mCur->value;
$this->move();
- if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != '(' ) {
+ if ( $this->mCur->type !== AFPToken::TBRACE || $this->mCur->value !== '(' ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mCur->pos,
[
@@ -679,6 +961,7 @@ class AbuseFilterParser {
}
if ( $this->mShortCircuit ) {
+ $result = new AFPData( AFPData::DUNDEFINED );
$this->skipOverBraces();
$this->move();
@@ -687,18 +970,38 @@ class AbuseFilterParser {
}
$args = [];
- $state = $this->getState();
- $this->move();
- if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) {
- $this->setState( $state );
+ $next = $this->getNextToken();
+ if ( $next->type !== AFPToken::TBRACE || $next->value !== ')' ) {
+ if ( ( $fname === 'set' || $fname === 'set_var' ) ) {
+ $state = $this->getState();
+ $this->move();
+ $next = $this->getNextToken();
+ if (
+ $this->mCur->type !== AFPToken::TSTRING ||
+ (
+ $next->type !== AFPToken::TCOMMA &&
+ // Let this fail later, when checking parameters count
+ !( $next->type === AFPToken::TBRACE && $next->value === ')' )
+ )
+ ) {
+ throw new AFPUserVisibleException( 'variablevariable', $this->mCur->pos, [] );
+ } else {
+ $this->setState( $state );
+ }
+ }
do {
- $r = new AFPData();
+ $r = new AFPData( AFPData::DEMPTY );
$this->doLevelSemicolon( $r );
+ if ( $r->getType() === AFPData::DEMPTY && !$this->functionIsVariadic( $fname ) ) {
+ $this->logEmptyOperand( 'non-variadic function argument' );
+ }
$args[] = $r;
- } while ( $this->mCur->type == AFPToken::TCOMMA );
+ } while ( $this->mCur->type === AFPToken::TCOMMA );
+ } else {
+ $this->move();
}
- if ( $this->mCur->type != AFPToken::TBRACE || $this->mCur->value != ')' ) {
+ if ( $this->mCur->type !== AFPToken::TBRACE || $this->mCur->value !== ')' ) {
throw new AFPUserVisibleException( 'expectednotfound',
$this->mCur->pos,
[
@@ -710,20 +1013,7 @@ class AbuseFilterParser {
}
$this->move();
- $funcHash = md5( $func . serialize( $args ) );
-
- if ( isset( self::$funcCache[$funcHash] ) &&
- !in_array( $func, self::$ActiveFunctions )
- ) {
- $result = self::$funcCache[$funcHash];
- } else {
- AbuseFilter::triggerLimiter();
- $result = self::$funcCache[$funcHash] = $this->$func( $args );
- }
-
- if ( count( self::$funcCache ) > 1000 ) {
- self::$funcCache = [];
- }
+ $result = $this->callFunc( $fname, $args );
} else {
$this->doLevelAtom( $result );
}
@@ -740,10 +1030,11 @@ class AbuseFilterParser {
switch ( $this->mCur->type ) {
case AFPToken::TID:
if ( $this->mShortCircuit ) {
- break;
+ $result = new AFPData( AFPData::DUNDEFINED );
+ } else {
+ $var = strtolower( $tok );
+ $result = $this->getVarValue( $var );
}
- $var = strtolower( $tok );
- $result = $this->getVarValue( $var );
break;
case AFPToken::TSTRING:
$result = new AFPData( AFPData::DSTRING, $tok );
@@ -755,12 +1046,12 @@ class AbuseFilterParser {
$result = new AFPData( AFPData::DINT, $tok );
break;
case AFPToken::TKEYWORD:
- if ( $tok == "true" ) {
+ if ( $tok === "true" ) {
$result = new AFPData( AFPData::DBOOL, true );
- } elseif ( $tok == "false" ) {
+ } elseif ( $tok === "false" ) {
$result = new AFPData( AFPData::DBOOL, false );
- } elseif ( $tok == "null" ) {
- $result = new AFPData();
+ } elseif ( $tok === "null" ) {
+ $result = new AFPData( AFPData::DNULL );
} else {
throw new AFPUserVisibleException(
'unrecognisedkeyword',
@@ -773,25 +1064,25 @@ class AbuseFilterParser {
// Handled at entry level
return;
case AFPToken::TBRACE:
- if ( $this->mCur->value == ')' ) {
+ if ( $this->mCur->value === ')' ) {
// Handled at the entry level
return;
}
case AFPToken::TSQUAREBRACKET:
- if ( $this->mCur->value == '[' ) {
+ if ( $this->mCur->value === '[' ) {
$array = [];
while ( true ) {
$this->move();
- if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) {
+ if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
break;
}
- $item = new AFPData();
+ $item = new AFPData( AFPData::DEMPTY );
$this->doLevelSet( $item );
$array[] = $item;
- if ( $this->mCur->type == AFPToken::TSQUAREBRACKET && $this->mCur->value == ']' ) {
+ if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
break;
}
- if ( $this->mCur->type != AFPToken::TCOMMA ) {
+ if ( $this->mCur->type !== AFPToken::TCOMMA ) {
throw new AFPUserVisibleException(
'expectednotfound',
$this->mCur->pos,
@@ -818,34 +1109,61 @@ class AbuseFilterParser {
/* End of levels */
/**
+ * Check whether a variable exists, being either built-in or user-defined. Doesn't include
+ * disabled variables.
+ *
+ * @param string $varname
+ * @return bool
+ */
+ protected function varExists( $varname ) {
+ return $this->keywordsManager->isVarInUse( $varname ) ||
+ $this->mVariables->varIsSet( $varname );
+ }
+
+ /**
* @param string $var
* @return AFPData
* @throws AFPUserVisibleException
*/
protected function getVarValue( $var ) {
$var = strtolower( $var );
- $builderValues = AbuseFilter::getBuilderValues();
- $deprecatedVars = AbuseFilter::getDeprecatedVariables();
+ $deprecatedVars = $this->keywordsManager->getDeprecatedVariables();
+
if ( array_key_exists( $var, $deprecatedVars ) ) {
- $logger = LoggerFactory::getInstance( 'AbuseFilterDeprecatedVars' );
- $logger->debug( "AbuseFilter: deprecated variable $var used." );
- $var = $deprecatedVars[$var];
+ if ( $this->logsDeprecatedVars() ) {
+ $this->logger->debug( "Deprecated variable $var used in filter {$this->mFilter}." );
+ }
+ $var = $deprecatedVars[ $var ];
}
- if ( !( array_key_exists( $var, $builderValues['vars'] )
- || $this->mVars->varIsSet( $var ) )
- ) {
- $msg = array_key_exists( $var, AbuseFilter::$disabledVars ) ?
- 'disabledvar' :
- 'unrecognisedvar';
- // If the variable is invalid, throw an exception
+ if ( $this->keywordsManager->isVarDisabled( $var ) ) {
throw new AFPUserVisibleException(
- $msg,
+ 'disabledvar',
+ $this->mCur->pos,
+ [ $var ]
+ );
+ }
+ if ( !$this->varExists( $var ) ) {
+ throw new AFPUserVisibleException(
+ 'unrecognisedvar',
$this->mCur->pos,
[ $var ]
);
- } else {
- return $this->mVars->getVar( $var );
}
+
+ // It's a built-in, non-disabled variable (either set or unset), or a set custom variable
+ $flags = $this->allowMissingVariables
+ ? AbuseFilterVariableHolder::GET_LAX
+ // TODO: This should be GET_STRICT, but that's going to be very hard (see T230256)
+ : AbuseFilterVariableHolder::GET_BC;
+ return $this->mVariables->getVar( $var, $flags, $this->mFilter );
+ }
+
+ /**
+ * Whether this parser should log deprecated vars use.
+ * @return bool
+ */
+ protected function logsDeprecatedVars() {
+ return true;
}
/**
@@ -854,16 +1172,99 @@ class AbuseFilterParser {
* @throws AFPUserVisibleException
*/
protected function setUserVariable( $name, $value ) {
- $builderValues = AbuseFilter::getBuilderValues();
- $deprecatedVars = AbuseFilter::getDeprecatedVariables();
- $blacklistedValues = AbuseFilterVariableHolder::$varBlacklist;
- if ( array_key_exists( $name, $builderValues['vars'] ) ||
- array_key_exists( $name, AbuseFilter::$disabledVars ) ||
- array_key_exists( $name, $deprecatedVars ) ||
- in_array( $name, $blacklistedValues ) ) {
+ if ( $this->isReservedIdentifier( $name ) ) {
throw new AFPUserVisibleException( 'overridebuiltin', $this->mCur->pos, [ $name ] );
}
- $this->mVars->setVar( $name, $value );
+ $this->mVariables->setVar( $name, $value );
+ }
+
+ /**
+ * Helper to call a built-in function.
+ *
+ * @param string $fname The name of the function as found in the filter code
+ * @param AFPData[] $args Arguments for the function
+ * @return AFPData The return value of the function
+ * @throws InvalidArgumentException if given an invalid func
+ */
+ protected function callFunc( $fname, array $args ) : AFPData {
+ if ( !array_key_exists( $fname, self::FUNCTIONS ) ) {
+ // @codeCoverageIgnoreStart
+ throw new InvalidArgumentException( "$fname is not a valid function." );
+ // @codeCoverageIgnoreEnd
+ }
+
+ $funcHandler = self::FUNCTIONS[$fname];
+ $funcHash = md5( $funcHandler . serialize( $args ) );
+
+ if ( isset( $this->funcCache[$funcHash] ) &&
+ !in_array( $funcHandler, self::ACTIVE_FUNCTIONS )
+ ) {
+ $result = $this->funcCache[$funcHash];
+ } else {
+ $this->checkArgCount( $args, $fname );
+ $this->raiseCondCount();
+
+ // Any undefined argument should be special-cased by the function, but that would be too
+ // much overhead. We also cannot skip calling the handler in case it's making further
+ // validation (T234339). So temporarily replace the DUNDEFINED with a DNULL.
+ // @todo This is subpar.
+ $hasUndefinedArg = false;
+ foreach ( $args as $i => $arg ) {
+ if ( $arg->hasUndefined() ) {
+ $args[$i] = $arg->cloneAsUndefinedReplacedWithNull();
+ $hasUndefinedArg = true;
+ }
+ }
+ if ( $hasUndefinedArg ) {
+ $this->$funcHandler( $args );
+ $result = new AFPData( AFPData::DUNDEFINED );
+ } else {
+ $result = $this->$funcHandler( $args );
+ }
+ $this->funcCache[$funcHash] = $result;
+ }
+
+ if ( count( $this->funcCache ) > 1000 ) {
+ // @codeCoverageIgnoreStart
+ $this->clearFuncCache();
+ // @codeCoverageIgnoreEnd
+ }
+ return $result;
+ }
+
+ /**
+ * Helper to invoke a built-in keyword. Note that this assumes that $kname is
+ * a valid keyword name.
+ *
+ * @param string $kname
+ * @param AFPData $lhs
+ * @param AFPData $rhs
+ * @return AFPData
+ */
+ protected function callKeyword( $kname, AFPData $lhs, AFPData $rhs ) : AFPData {
+ $func = self::KEYWORDS[$kname];
+ $this->raiseCondCount();
+
+ $hasUndefinedOperand = false;
+ if ( $lhs->hasUndefined() ) {
+ $lhs = $lhs->cloneAsUndefinedReplacedWithNull();
+ $hasUndefinedOperand = true;
+ }
+ if ( $rhs->hasUndefined() ) {
+ $rhs = $rhs->cloneAsUndefinedReplacedWithNull();
+ $hasUndefinedOperand = true;
+ }
+ if ( $hasUndefinedOperand ) {
+ // We need to run the handler with bogus args, see the comment in self::callFunc (T234339)
+ // @todo Likewise, this is subpar.
+ // @phan-suppress-next-line PhanParamTooMany Not every function needs the position
+ $this->$func( $lhs, $rhs, $this->mCur->pos );
+ $result = new AFPData( AFPData::DUNDEFINED );
+ } else {
+ // @phan-suppress-next-line PhanParamTooMany Not every function needs the position
+ $result = $this->$func( $lhs, $rhs, $this->mCur->pos );
+ }
+ return $result;
}
// Built-in functions
@@ -871,76 +1272,43 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcLc( $args ) {
- global $wgContLang;
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'lc', 1 ]
- );
- }
$s = $args[0]->toString();
- return new AFPData( AFPData::DSTRING, $wgContLang->lc( $s ) );
+ return new AFPData( AFPData::DSTRING, $this->contLang->lc( $s ) );
}
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcUc( $args ) {
- global $wgContLang;
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'uc', 1 ]
- );
- }
$s = $args[0]->toString();
- return new AFPData( AFPData::DSTRING, $wgContLang->uc( $s ) );
+ return new AFPData( AFPData::DSTRING, $this->contLang->uc( $s ) );
}
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcLen( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'len', 1 ]
- );
- }
- if ( $args[0]->type == AFPData::DARRAY ) {
+ if ( $args[0]->type === AFPData::DARRAY ) {
// Don't use toString on arrays, but count
- return new AFPData( AFPData::DINT, count( $args[0]->data ) );
+ $val = count( $args[0]->data );
+ } else {
+ $val = mb_strlen( $args[0]->toString(), 'utf-8' );
}
- $s = $args[0]->toString();
- return new AFPData( AFPData::DINT, mb_strlen( $s, 'utf-8' ) );
+ return new AFPData( AFPData::DINT, $val );
}
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcSpecialRatio( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'specialratio', 1 ]
- );
- }
$s = $args[0]->toString();
if ( !strlen( $s ) ) {
@@ -957,22 +1325,13 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcCount( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'count', 1 ]
- );
- }
-
- if ( $args[0]->type == AFPData::DARRAY && count( $args ) == 1 ) {
+ if ( $args[0]->type === AFPData::DARRAY && count( $args ) === 1 ) {
return new AFPData( AFPData::DINT, count( $args[0]->data ) );
}
- if ( count( $args ) == 1 ) {
+ if ( count( $args ) === 1 ) {
$count = count( explode( ',', $args[0]->toString() ) );
} else {
$needle = $args[0]->toString();
@@ -993,18 +1352,9 @@ class AbuseFilterParser {
* @param array $args
* @return AFPData
* @throws AFPUserVisibleException
- * @throws Exception
*/
protected function funcRCount( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'rcount', 1 ]
- );
- }
-
- if ( count( $args ) == 1 ) {
+ if ( count( $args ) === 1 ) {
$count = count( explode( ',', $args[0]->toString() ) );
} else {
$needle = $args[0]->toString();
@@ -1015,9 +1365,9 @@ class AbuseFilterParser {
$needle = "/$needle/u";
// Suppress and restore needed per T177744
- Wikimedia\suppressWarnings();
+ AtEase::suppressWarnings();
$count = preg_match_all( $needle, $haystack );
- Wikimedia\restoreWarnings();
+ AtEase::restoreWarnings();
if ( $count === false ) {
throw new AFPUserVisibleException(
@@ -1040,23 +1390,19 @@ class AbuseFilterParser {
* @throws AFPUserVisibleException
*/
protected function funcGetMatches( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'get_matches', 2, count( $args ) ]
- );
- }
$needle = $args[0]->toString();
$haystack = $args[1]->toString();
// Count the amount of capturing groups in the submitted pattern.
// This way we can return a fixed-dimension array, much easier to manage.
+ // ToDo: Find a better way to do this.
// First, strip away escaped parentheses
$sanitized = preg_replace( '/(\\\\\\\\)*\\\\\(/', '', $needle );
- // Then strip starting parentheses of non-capturing groups
- // (also atomics, lookahead and so on, even if not every of them is supported)
- $sanitized = preg_replace( '/\(\?/', '', $sanitized );
+ // Then strip starting parentheses of non-capturing groups, including
+ // atomics, lookaheads and so on, even if not every of them is supported.
+ $sanitized = str_replace( '(?', '', $sanitized );
+ // And also strip "(*", used with backtracking verbs like (*FAIL)
+ $sanitized = str_replace( '(*', '', $sanitized );
// Finally create an array of falses with dimension = # of capturing groups
$groupscount = substr_count( $sanitized, '(' ) + 1;
$falsy = array_fill( 0, $groupscount, false );
@@ -1066,9 +1412,9 @@ class AbuseFilterParser {
$needle = "/$needle/u";
// Suppress and restore are here for the same reason as T177744
- Wikimedia\suppressWarnings();
+ AtEase::suppressWarnings();
$check = preg_match( $needle, $haystack, $matches );
- Wikimedia\restoreWarnings();
+ AtEase::restoreWarnings();
if ( $check === false ) {
throw new AFPUserVisibleException(
@@ -1090,18 +1436,10 @@ class AbuseFilterParser {
* @throws AFPUserVisibleException
*/
protected function funcIPInRange( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'ip_in_range', 2, count( $args ) ]
- );
- }
-
$ip = $args[0]->toString();
$range = $args[1]->toString();
- if ( !IP::isValidRange( $range ) ) {
+ if ( !IPUtils::isValidRange( $range ) ) {
throw new AFPUserVisibleException(
'invalidiprange',
$this->mCur->pos,
@@ -1109,7 +1447,7 @@ class AbuseFilterParser {
);
}
- $result = IP::isInRange( $ip, $range );
+ $result = IPUtils::isInRange( $ip, $range );
return new AFPData( AFPData::DBOOL, $result );
}
@@ -1117,16 +1455,8 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcCCNorm( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'ccnorm', 1 ]
- );
- }
$s = $args[0]->toString();
$s = html_entity_decode( $s, ENT_QUOTES, 'UTF-8' );
@@ -1138,16 +1468,8 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcSanitize( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'sanitize', 1 ]
- );
- }
$s = $args[0]->toString();
$s = html_entity_decode( $s, ENT_QUOTES, 'UTF-8' );
@@ -1159,17 +1481,8 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcContainsAny( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'contains_any', 2, count( $args ) ]
- );
- }
-
$s = array_shift( $args );
return new AFPData( AFPData::DBOOL, self::contains( $s, $args, true ) );
@@ -1178,17 +1491,8 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcContainsAll( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'contains_all', 2, count( $args ) ]
- );
- }
-
$s = array_shift( $args );
return new AFPData( AFPData::DBOOL, self::contains( $s, $args, false, false ) );
@@ -1199,17 +1503,8 @@ class AbuseFilterParser {
*
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcCCNormContainsAny( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'ccnorm_contains_any', 2, count( $args ) ]
- );
- }
-
$s = array_shift( $args );
return new AFPData( AFPData::DBOOL, self::contains( $s, $args, true, true ) );
@@ -1220,17 +1515,8 @@ class AbuseFilterParser {
*
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcCCNormContainsAll( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'ccnorm_contains_all', 2, count( $args ) ]
- );
- }
-
$s = array_shift( $args );
return new AFPData( AFPData::DBOOL, self::contains( $s, $args, false, true ) );
@@ -1283,23 +1569,14 @@ class AbuseFilterParser {
// If I'm here and it's ANY (OR) => nothing was found: return false ($is_any is true)
// If I'm here and it's ALL (AND) => everything was found: return true ($is_any is false)
- return ! $is_any;
+ return !$is_any;
}
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcEqualsToAny( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'equals_to_any', 2, count( $args ) ]
- );
- }
-
$s = array_shift( $args );
return new AFPData( AFPData::DBOOL, self::equalsToAny( $s, $args ) );
@@ -1314,12 +1591,8 @@ class AbuseFilterParser {
* @return bool
*/
protected static function equalsToAny( $string, $values ) {
- $string = $string->toString();
-
foreach ( $values as $needle ) {
- $needle = $needle->toString();
-
- if ( $string === $needle ) {
+ if ( $string->equals( $needle, true ) ) {
return true;
}
}
@@ -1367,76 +1640,38 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcRMSpecials( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'rmspecials', 1 ]
- );
- }
$s = $args[0]->toString();
- $s = $this->rmspecials( $s );
-
- return new AFPData( AFPData::DSTRING, $s );
+ return new AFPData( AFPData::DSTRING, $this->rmspecials( $s ) );
}
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcRMWhitespace( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'rmwhitespace', 1 ]
- );
- }
$s = $args[0]->toString();
- $s = $this->rmwhitespace( $s );
-
- return new AFPData( AFPData::DSTRING, $s );
+ return new AFPData( AFPData::DSTRING, $this->rmwhitespace( $s ) );
}
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcRMDoubles( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'rmdoubles', 1 ]
- );
- }
$s = $args[0]->toString();
- $s = $this->rmdoubles( $s );
-
- return new AFPData( AFPData::DSTRING, $s );
+ return new AFPData( AFPData::DSTRING, $this->rmdoubles( $s ) );
}
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcNorm( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'norm', 1 ]
- );
- }
$s = $args[0]->toString();
$s = $this->ccnorm( $s );
@@ -1450,27 +1685,13 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcSubstr( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'substr', 2, count( $args ) ]
- );
- }
-
$s = $args[0]->toString();
$offset = $args[1]->toInt();
+ $length = isset( $args[2] ) ? $args[2]->toInt() : null;
- if ( isset( $args[2] ) ) {
- $length = $args[2]->toInt();
-
- $result = mb_substr( $s, $offset, $length );
- } else {
- $result = mb_substr( $s, $offset );
- }
+ $result = mb_substr( $s, $offset, $length );
return new AFPData( AFPData::DSTRING, $result );
}
@@ -1478,32 +1699,18 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcStrPos( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'strpos', 2, count( $args ) ]
- );
- }
-
$haystack = $args[0]->toString();
$needle = $args[1]->toString();
+ $offset = isset( $args[2] ) ? $args[2]->toInt() : 0;
// T62203: Keep empty parameters from causing PHP warnings
if ( $needle === '' ) {
return new AFPData( AFPData::DINT, -1 );
}
- if ( isset( $args[2] ) ) {
- $offset = $args[2]->toInt();
-
- $result = mb_strpos( $haystack, $needle, $offset );
- } else {
- $result = mb_strpos( $haystack, $needle );
- }
+ $result = mb_strpos( $haystack, $needle, $offset );
if ( $result === false ) {
$result = -1;
@@ -1515,17 +1722,8 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcStrReplace( $args ) {
- if ( count( $args ) < 3 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'str_replace', 3, count( $args ) ]
- );
- }
-
$subject = $args[0]->toString();
$search = $args[1]->toString();
$replace = $args[2]->toString();
@@ -1536,17 +1734,8 @@ class AbuseFilterParser {
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function funcStrRegexEscape( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'rescape', 1 ]
- );
- }
-
$string = $args[0]->toString();
// preg_quote does not need the second parameter, since rlike takes
@@ -1557,17 +1746,8 @@ class AbuseFilterParser {
/**
* @param array $args
* @return mixed
- * @throws AFPUserVisibleException
*/
protected function funcSetVar( $args ) {
- if ( count( $args ) < 2 ) {
- throw new AFPUserVisibleException(
- 'notenoughargs',
- $this->mCur->pos,
- [ 'set_var', 2, count( $args ) ]
- );
- }
-
$varName = $args[0]->toString();
$value = $args[1];
@@ -1577,74 +1757,151 @@ class AbuseFilterParser {
}
/**
- * @param array $args
+ * Checks if $a contains $b
+ *
+ * @param AFPData $a
+ * @param AFPData $b
* @return AFPData
- * @throws AFPUserVisibleException
*/
- protected function castString( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'string', 1 ]
- );
+ protected function containmentKeyword( AFPData $a, AFPData $b ) {
+ $a = $a->toString();
+ $b = $b->toString();
+
+ if ( $a === '' || $b === '' ) {
+ return new AFPData( AFPData::DBOOL, false );
}
- $val = $args[0];
- return AFPData::castTypes( $val, AFPData::DSTRING );
+ return new AFPData( AFPData::DBOOL, strpos( $a, $b ) !== false );
}
/**
- * @param array $args
+ * @param AFPData $a
+ * @param AFPData $b
* @return AFPData
- * @throws AFPUserVisibleException
*/
- protected function castInt( $args ) {
- if ( count( $args ) === 0 ) {
+ protected function keywordIn( AFPData $a, AFPData $b ) {
+ return $this->containmentKeyword( $b, $a );
+ }
+
+ /**
+ * @param AFPData $a
+ * @param AFPData $b
+ * @return AFPData
+ */
+ protected function keywordContains( AFPData $a, AFPData $b ) {
+ return $this->containmentKeyword( $a, $b );
+ }
+
+ /**
+ * @param AFPData $str
+ * @param AFPData $pattern
+ * @return AFPData
+ */
+ protected function keywordLike( AFPData $str, AFPData $pattern ) {
+ $str = $str->toString();
+ $pattern = '#^' . strtr( preg_quote( $pattern->toString(), '#' ), AFPData::WILDCARD_MAP ) . '$#u';
+ AtEase::suppressWarnings();
+ $result = preg_match( $pattern, $str );
+ AtEase::restoreWarnings();
+
+ return new AFPData( AFPData::DBOOL, (bool)$result );
+ }
+
+ /**
+ * @param AFPData $str
+ * @param AFPData $regex
+ * @param int $pos
+ * @param bool $insensitive
+ * @return AFPData
+ * @throws Exception
+ */
+ protected function keywordRegex( AFPData $str, AFPData $regex, $pos, $insensitive = false ) {
+ $str = $str->toString();
+ $pattern = $regex->toString();
+
+ $pattern = preg_replace( '!(\\\\\\\\)*(\\\\)?/!', '$1\/', $pattern );
+ $pattern = "/$pattern/u";
+
+ if ( $insensitive ) {
+ $pattern .= 'i';
+ }
+
+ AtEase::suppressWarnings();
+ $result = preg_match( $pattern, $str );
+ AtEase::restoreWarnings();
+ if ( $result === false ) {
throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'int', 1 ]
+ 'regexfailure',
+ // Coverage bug
+ // @codeCoverageIgnoreStart
+ $pos,
+ // @codeCoverageIgnoreEnd
+ [ $pattern ]
);
}
- $val = $args[0];
- return AFPData::castTypes( $val, AFPData::DINT );
+ return new AFPData( AFPData::DBOOL, (bool)$result );
+ }
+
+ /**
+ * @param AFPData $str
+ * @param AFPData $regex
+ * @param int $pos
+ * @return AFPData
+ */
+ protected function keywordRegexInsensitive( AFPData $str, AFPData $regex, $pos ) {
+ return $this->keywordRegex( $str, $regex, $pos, true );
+ }
+
+ /**
+ * @param array $args
+ * @return AFPData
+ */
+ protected function castString( $args ) {
+ return AFPData::castTypes( $args[0], AFPData::DSTRING );
}
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
- protected function castFloat( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'float', 1 ]
- );
- }
- $val = $args[0];
+ protected function castInt( $args ) {
+ return AFPData::castTypes( $args[0], AFPData::DINT );
+ }
- return AFPData::castTypes( $val, AFPData::DFLOAT );
+ /**
+ * @param array $args
+ * @return AFPData
+ */
+ protected function castFloat( $args ) {
+ return AFPData::castTypes( $args[0], AFPData::DFLOAT );
}
/**
* @param array $args
* @return AFPData
- * @throws AFPUserVisibleException
*/
protected function castBool( $args ) {
- if ( count( $args ) === 0 ) {
- throw new AFPUserVisibleException(
- 'noparams',
- $this->mCur->pos,
- [ 'bool', 1 ]
+ return AFPData::castTypes( $args[0], AFPData::DBOOL );
+ }
+
+ /**
+ * Log empty operands for T156096
+ *
+ * @param string $type Type of the empty operand
+ */
+ protected function logEmptyOperand( $type ) {
+ if ( $this->mFilter !== null ) {
+ $this->logger->warning(
+ 'DEPRECATED! Found empty operand of type `{op_type}` when parsing filter: {filter}. ' .
+ 'This is deprecated since 1.34 and support will be discontinued soon. Please fix ' .
+ 'the affected filter!',
+ [
+ 'op_type' => $type,
+ 'filter' => $this->mFilter
+ ]
);
}
- $val = $args[0];
-
- return AFPData::castTypes( $val, AFPData::DBOOL );
}
+
}
diff --git a/AbuseFilter/includes/parser/AbuseFilterTokenizer.php b/AbuseFilter/includes/parser/AbuseFilterTokenizer.php
index d95a6864..b814b670 100644
--- a/AbuseFilter/includes/parser/AbuseFilterTokenizer.php
+++ b/AbuseFilter/includes/parser/AbuseFilterTokenizer.php
@@ -1,24 +1,27 @@
<?php
-use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerInterface;
/**
* Tokenizer for AbuseFilter rules.
*/
class AbuseFilterTokenizer {
- /** @var int Tokenizer cache version. Increment this when changing the syntax. **/
- const CACHE_VERSION = 1;
- const COMMENT_START_RE = '/\s*\/\*/A';
- const ID_SYMBOL_RE = '/[0-9A-Za-z_]+/A';
- const OPERATOR_RE =
+ /** @var int Tokenizer cache version. Increment this when changing the syntax. */
+ public const CACHE_VERSION = 4;
+ private const COMMENT_START_RE = '/\s*\/\*/A';
+ private const ID_SYMBOL_RE = '/[0-9A-Za-z_]+/A';
+ public const OPERATOR_RE =
'/(\!\=\=|\!\=|\!|\*\*|\*|\/|\+|\-|%|&|\||\^|\:\=|\?|\:|\<\=|\<|\>\=|\>|\=\=\=|\=\=|\=)/A';
- const RADIX_RE = '/([0-9A-Fa-f]+(?:\.\d*)?|\.\d+)([bxo])?/Au';
- const WHITESPACE = "\011\012\013\014\015\040";
+ private const BASE = '0(?<base>[xbo])';
+ private const DIGIT = '[0-9A-Fa-f]';
+ private const DIGITS = self::DIGIT . '+' . '(?:\.\d*)?|\.\d+';
+ private const RADIX_RE = '/(?:' . self::BASE . ')?(?<input>' . self::DIGITS . ')(?!\w)/Au';
+ private const WHITESPACE = "\011\012\013\014\015\040";
// Order is important. The punctuation-matching regex requires that
// ** comes before *, etc. They are sorted to make it easy to spot
// such errors.
- public static $operators = [
+ public const OPERATORS = [
// Inequality
'!==', '!=', '!',
// Multiplication/exponentiation
@@ -39,7 +42,7 @@ class AbuseFilterTokenizer {
'===', '==', '=',
];
- public static $punctuation = [
+ public const PUNCTUATION = [
',' => AFPToken::TCOMMA,
'(' => AFPToken::TBRACE,
')' => AFPToken::TBRACE,
@@ -48,64 +51,86 @@ class AbuseFilterTokenizer {
';' => AFPToken::TSTATEMENTSEPARATOR,
];
- public static $bases = [
+ public const BASES = [
'b' => 2,
'x' => 16,
'o' => 8
];
- public static $baseCharsRe = [
+ public const BASE_CHARS_RES = [
2 => '/^[01]+$/',
- 8 => '/^[0-8]+$/',
+ 8 => '/^[0-7]+$/',
16 => '/^[0-9A-Fa-f]+$/',
10 => '/^[0-9.]+$/',
];
- public static $keywords = [
+ public const KEYWORDS = [
'in', 'like', 'true', 'false', 'null', 'contains', 'matches',
'rlike', 'irlike', 'regex', 'if', 'then', 'else', 'end',
];
/**
- * @param string $code
- * @return array
- * @throws AFPException
- * @throws AFPUserVisibleException
+ * @var BagOStuff
*/
- public static function tokenize( $code ) {
- static $tokenizerCache = null;
-
- if ( !$tokenizerCache ) {
- $tokenizerCache = ObjectCache::getLocalServerInstance( 'hash' );
- }
+ private $cache;
- static $stats = null;
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
- if ( !$stats ) {
- $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
- }
+ /**
+ * @param BagOStuff $cache
+ * @param LoggerInterface $logger
+ */
+ public function __construct( BagOStuff $cache, LoggerInterface $logger ) {
+ $this->cache = $cache;
+ $this->logger = $logger;
+ }
- $cacheKey = wfGlobalCacheKey( __CLASS__, self::CACHE_VERSION, crc32( $code ) );
+ /**
+ * Get a cache key used to store the tokenized code
+ *
+ * @param string $code Not yet tokenized
+ * @return string
+ * @internal
+ */
+ public function getCacheKey( $code ) {
+ return $this->cache->makeGlobalKey( __CLASS__, self::CACHE_VERSION, crc32( $code ) );
+ }
- $tokens = $tokenizerCache->get( $cacheKey );
+ /**
+ * Get the tokens for the given code.
+ *
+ * @param string $code
+ * @return array[]
+ */
+ public function getTokens( $code ) {
+ $tokens = $this->cache->getWithSetCallback(
+ $this->getCacheKey( $code ),
+ BagOStuff::TTL_DAY,
+ function () use ( $code ) {
+ return $this->tokenize( $code );
+ }
+ );
- if ( $tokens ) {
- $stats->increment( 'AbuseFilter.tokenizerCache.hit' );
- return $tokens;
- }
+ return $tokens;
+ }
- $stats->increment( 'AbuseFilter.tokenizerCache.miss' );
+ /**
+ * @param string $code
+ * @return array[]
+ */
+ private function tokenize( $code ) {
$tokens = [];
$curPos = 0;
do {
$prevPos = $curPos;
- $token = self::nextToken( $code, $curPos );
+ $token = $this->nextToken( $code, $curPos );
$tokens[ $token->pos ] = [ $token, $curPos ];
} while ( $curPos !== $prevPos );
- $tokenizerCache->set( $cacheKey, $tokens, 60 * 60 * 24 );
-
return $tokens;
}
@@ -116,7 +141,7 @@ class AbuseFilterTokenizer {
* @throws AFPException
* @throws AFPUserVisibleException
*/
- protected static function nextToken( $code, &$offset ) {
+ private function nextToken( $code, &$offset ) {
$matches = [];
$start = $offset;
@@ -138,9 +163,9 @@ class AbuseFilterTokenizer {
$chr = $code[$offset];
// Punctuation
- if ( isset( self::$punctuation[$chr] ) ) {
+ if ( isset( self::PUNCTUATION[$chr] ) ) {
$offset++;
- return new AFPToken( self::$punctuation[$chr], $chr, $start );
+ return new AFPToken( self::PUNCTUATION[$chr], $chr, $start );
}
// String literal
@@ -158,23 +183,13 @@ class AbuseFilterTokenizer {
}
// Numbers
- if ( preg_match( self::RADIX_RE, $code, $matches, 0, $offset ) ) {
- $token = $matches[0];
- $input = $matches[1];
- $baseChar = $matches[2] ?? null;
- // Sometimes the base char gets mixed in with the rest of it because
- // the regex targets hex, too.
- // This mostly happens with binary
- if ( !$baseChar && !empty( self::$bases[ substr( $input, - 1 ) ] ) ) {
- $baseChar = substr( $input, - 1, 1 );
- $input = substr( $input, 0, - 1 );
- }
-
- $base = $baseChar ? self::$bases[$baseChar] : 10;
-
- // Check against the appropriate character class for input validation
-
- if ( preg_match( self::$baseCharsRe[$base], $input ) ) {
+ $matchesv2 = [];
+ if ( preg_match( self::RADIX_RE, $code, $matchesv2, 0, $offset ) ) {
+ $token = $matchesv2[0];
+ $baseChar = $matchesv2['base'];
+ $input = $matchesv2['input'];
+ $base = $baseChar ? self::BASES[$baseChar] : 10;
+ if ( preg_match( self::BASE_CHARS_RES[$base], $input ) ) {
$num = $base !== 10 ? base_convert( $input, $base, 10 ) : $input;
$offset += strlen( $token );
return ( strpos( $input, '.' ) !== false )
@@ -188,7 +203,7 @@ class AbuseFilterTokenizer {
if ( preg_match( self::ID_SYMBOL_RE, $code, $matches, 0, $offset ) ) {
$token = $matches[0];
$offset += strlen( $token );
- $type = in_array( $token, self::$keywords )
+ $type = in_array( $token, self::KEYWORDS )
? AFPToken::TKEYWORD
: AFPToken::TID;
return new AFPToken( $type, $token, $start );
@@ -206,7 +221,7 @@ class AbuseFilterTokenizer {
* @throws AFPException
* @throws AFPUserVisibleException
*/
- protected static function readStringLiteral( $code, &$offset, $start ) {
+ private static function readStringLiteral( $code, &$offset, $start ) {
$type = $code[$offset];
$offset++;
$length = strlen( $code );
@@ -223,7 +238,7 @@ class AbuseFilterTokenizer {
if ( $addLength ) {
$token .= substr( $code, $offset, $addLength );
$offset += $addLength;
- } elseif ( $code[$offset] == '\\' ) {
+ } elseif ( $code[$offset] === '\\' ) {
switch ( $code[$offset + 1] ) {
case '\\':
$token .= '\\';
@@ -244,12 +259,11 @@ class AbuseFilterTokenizer {
$chr = substr( $code, $offset + 2, 2 );
if ( preg_match( '/^[0-9A-Fa-f]{2}$/', $chr ) ) {
- $chr = base_convert( $chr, 16, 10 );
- $token .= chr( $chr );
+ $token .= chr( hexdec( $chr ) );
// \xXX -- 2 done later
$offset += 2;
} else {
- $token .= 'x';
+ $token .= '\\x';
}
break;
default:
@@ -260,8 +274,10 @@ class AbuseFilterTokenizer {
} else {
// Should never happen
+ // @codeCoverageIgnoreStart
$token .= $code[$offset];
$offset++;
+ // @codeCoverageIgnoreEnd
}
}
throw new AFPUserVisibleException( 'unclosedstring', $offset, [] );
diff --git a/AbuseFilter/includes/special/AbuseFilterSpecialPage.php b/AbuseFilter/includes/special/AbuseFilterSpecialPage.php
new file mode 100644
index 00000000..0c1b4171
--- /dev/null
+++ b/AbuseFilter/includes/special/AbuseFilterSpecialPage.php
@@ -0,0 +1,65 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Parent class for AbuseFilter special pages.
+ */
+abstract class AbuseFilterSpecialPage extends SpecialPage {
+ /**
+ * Add topbar navigation links
+ *
+ * @param string $pageType
+ */
+ protected function addNavigationLinks( $pageType ) {
+ $user = $this->getUser();
+
+ $linkDefs = [
+ 'home' => 'Special:AbuseFilter',
+ 'recentchanges' => 'Special:AbuseFilter/history',
+ 'examine' => 'Special:AbuseFilter/examine',
+ ];
+
+ if ( MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $user, 'abusefilter-log' )
+ ) {
+ $linkDefs = array_merge( $linkDefs, [
+ 'log' => 'Special:AbuseLog'
+ ] );
+ }
+
+ if ( AbuseFilter::canViewPrivate( $user ) ) {
+ $linkDefs = array_merge( $linkDefs, [
+ 'test' => 'Special:AbuseFilter/test',
+ 'tools' => 'Special:AbuseFilter/tools'
+ ] );
+ }
+
+ $links = [];
+
+ foreach ( $linkDefs as $name => $page ) {
+ // Give grep a chance to find the usages:
+ // abusefilter-topnav-home, abusefilter-topnav-recentchanges, abusefilter-topnav-test,
+ // abusefilter-topnav-log, abusefilter-topnav-tools, abusefilter-topnav-examine
+ $msgName = "abusefilter-topnav-$name";
+
+ $msg = $this->msg( $msgName )->parse();
+ $title = Title::newFromText( $page );
+
+ if ( $name === $pageType ) {
+ $links[] = Xml::tags( 'strong', null, $msg );
+ } else {
+ $links[] = $this->getLinkRenderer()->makeLink( $title, new HtmlArmor( $msg ) );
+ }
+ }
+
+ $linkStr = $this->msg( 'parentheses' )
+ ->rawParams( $this->getLanguage()->pipeList( $links ) )
+ ->text();
+ $linkStr = $this->msg( 'abusefilter-topnav' )->parse() . " $linkStr";
+
+ $linkStr = Xml::tags( 'div', [ 'class' => 'mw-abusefilter-navigation' ], $linkStr );
+
+ $this->getOutput()->setSubtitle( $linkStr );
+ }
+}
diff --git a/AbuseFilter/includes/special/SpecialAbuseFilter.php b/AbuseFilter/includes/special/SpecialAbuseFilter.php
index ccd2f8ee..03fc9d6b 100644
--- a/AbuseFilter/includes/special/SpecialAbuseFilter.php
+++ b/AbuseFilter/includes/special/SpecialAbuseFilter.php
@@ -1,20 +1,38 @@
<?php
-class SpecialAbuseFilter extends SpecialPage {
- public $mFilter, $mHistoryID;
+class SpecialAbuseFilter extends AbuseFilterSpecialPage {
+ public const PAGE_NAME = 'AbuseFilter';
+ /**
+ * @var int|string|null The current filter
+ */
+ public $mFilter;
+ /**
+ * @var int|null The history ID of the current version
+ */
+ public $mHistoryID;
+ /**
+ * @inheritDoc
+ */
public function __construct() {
- parent::__construct( 'AbuseFilter', 'abusefilter-view' );
+ parent::__construct( self::PAGE_NAME, 'abusefilter-view' );
}
/**
- * @return bool
+ * @inheritDoc
*/
public function doesWrites() {
return true;
}
/**
+ * @inheritDoc
+ */
+ protected function getGroupName() {
+ return 'wiki';
+ }
+
+ /**
* @param string|null $subpage
*/
public function execute( $subpage ) {
@@ -25,13 +43,13 @@ class SpecialAbuseFilter extends SpecialPage {
$view = 'AbuseFilterViewList';
$this->setHeaders();
+ $this->addHelpLink( 'Extension:AbuseFilter' );
$this->loadParameters( $subpage );
- $out->setPageTitle( $this->msg( 'abusefilter-management' ) );
$this->checkPermissions();
- if ( $request->getVal( 'result' ) == 'success' ) {
+ if ( $request->getVal( 'result' ) === 'success' ) {
$out->setSubtitle( $this->msg( 'abusefilter-edit-done-subtitle' ) );
$changedFilter = intval( $request->getVal( 'changedfilter' ) );
$changeId = intval( $request->getVal( 'changeid' ) );
@@ -58,64 +76,63 @@ class SpecialAbuseFilter extends SpecialPage {
}
$params = array_values( $params );
- if ( $subpage == 'tools' ) {
+ if ( $subpage === 'tools' ) {
$view = 'AbuseFilterViewTools';
$pageType = 'tools';
$out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
}
- if ( count( $params ) == 2 && $params[0] == 'revert' && is_numeric( $params[1] ) ) {
+ if ( count( $params ) === 2 && $params[0] === 'revert' && is_numeric( $params[1] ) ) {
$this->mFilter = $params[1];
$view = 'AbuseFilterViewRevert';
$pageType = 'revert';
}
- if ( count( $params ) && $params[0] == 'test' ) {
+ if ( count( $params ) && $params[0] === 'test' ) {
$view = 'AbuseFilterViewTestBatch';
$pageType = 'test';
$out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
}
- if ( count( $params ) && $params[0] == 'examine' ) {
+ if ( count( $params ) && $params[0] === 'examine' ) {
$view = 'AbuseFilterViewExamine';
$pageType = 'examine';
$out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
}
- if ( !empty( $params[0] ) && ( $params[0] == 'history' || $params[0] == 'log' ) ) {
+ if ( !empty( $params[0] ) && ( $params[0] === 'history' || $params[0] === 'log' ) ) {
$pageType = '';
- if ( count( $params ) == 1 ) {
+ if ( count( $params ) === 1 ) {
$view = 'AbuseFilterViewHistory';
$pageType = 'recentchanges';
- } elseif ( count( $params ) == 2 ) {
+ } elseif ( count( $params ) === 2 ) {
// Second param is a filter ID
$view = 'AbuseFilterViewHistory';
$pageType = 'recentchanges';
$this->mFilter = $params[1];
- } elseif ( count( $params ) == 4 && $params[2] == 'item' ) {
+ } elseif ( count( $params ) === 4 && $params[2] === 'item' ) {
$this->mFilter = $params[1];
- $this->mHistoryID = $params[3];
+ $this->mHistoryID = (int)$params[3];
$view = 'AbuseFilterViewEdit';
- } elseif ( count( $params ) == 5 && $params[2] == 'diff' ) {
+ } elseif ( count( $params ) === 5 && $params[2] === 'diff' ) {
// Special:AbuseFilter/history/<filter>/diff/<oldid>/<newid>
$view = 'AbuseFilterViewDiff';
}
}
- if ( is_numeric( $subpage ) || $subpage == 'new' ) {
+ if ( is_numeric( $subpage ) || $subpage === 'new' ) {
$this->mFilter = $subpage;
$view = 'AbuseFilterViewEdit';
$pageType = 'edit';
}
- if ( $subpage == 'import' ) {
+ if ( $subpage === 'import' ) {
$view = 'AbuseFilterViewImport';
$pageType = 'import';
}
// Links at the top
- AbuseFilter::addNavigationLinks(
- $this->getContext(), $pageType, $this->getLinkRenderer() );
+ $this->addNavigationLinks( $pageType );
/** @var AbuseFilterView $v */
$v = new $view( $this, $params );
@@ -123,21 +140,12 @@ class SpecialAbuseFilter extends SpecialPage {
}
/**
- * @param string|null $subpage
+ * @param string|null $filter
*/
- public function loadParameters( $subpage ) {
- $filter = $subpage;
-
- if ( !is_numeric( $filter ) && $filter != 'new' ) {
+ public function loadParameters( $filter ) {
+ if ( !is_numeric( $filter ) && $filter !== 'new' ) {
$filter = $this->getRequest()->getIntOrNull( 'wpFilter' );
}
$this->mFilter = $filter;
}
-
- /**
- * @return string
- */
- protected function getGroupName() {
- return 'wiki';
- }
}
diff --git a/AbuseFilter/includes/special/SpecialAbuseLog.php b/AbuseFilter/includes/special/SpecialAbuseLog.php
index ab2e2cbc..31d85699 100644
--- a/AbuseFilter/includes/special/SpecialAbuseLog.php
+++ b/AbuseFilter/includes/special/SpecialAbuseLog.php
@@ -1,50 +1,97 @@
<?php
-class SpecialAbuseLog extends SpecialPage {
+use MediaWiki\Cache\LinkBatchFactory;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Permissions\PermissionManager;
+use MediaWiki\User\UserIdentity;
+
+class SpecialAbuseLog extends AbuseFilterSpecialPage {
/**
- * @var User
+ * @var User The user whose AbuseLog entries are being searched
*/
protected $mSearchUser;
+ /**
+ * @var string The start time of the search period
+ */
protected $mSearchPeriodStart;
+ /**
+ * @var string The end time of the search period
+ */
protected $mSearchPeriodEnd;
/**
- * @var Title
+ * @var Title The page of which AbuseLog entries are being searched
*/
protected $mSearchTitle;
/**
- * @var string
+ * @var string The action performed by the user
*/
protected $mSearchAction;
/**
- * @var string
+ * @var string The action taken by AbuseFilter
*/
protected $mSearchActionTaken;
+ /**
+ * @var string The wiki name where we're performing the search
+ */
protected $mSearchWiki;
+ /**
+ * @var string|null The filter IDs we're looking for. Either a single one, or a pipe-separated list
+ */
protected $mSearchFilter;
+ /**
+ * @var string The visibility of entries we're interested in
+ */
protected $mSearchEntries;
+ /**
+ * @var string The impact of the user action, i.e. if the change has been saved
+ */
protected $mSearchImpact;
- public function __construct() {
+ /** @var string The filter group to search, as defined in $wgAbuseFilterValidGroups */
+ protected $mSearchGroup;
+
+ /** @var LinkBatchFactory */
+ private $linkBatchFactory;
+
+ /** @var PermissionManager */
+ private $permissionManager;
+
+ /**
+ * @param LinkBatchFactory $linkBatchFactory
+ * @param PermissionManager $permissionManager
+ */
+ public function __construct( LinkBatchFactory $linkBatchFactory, PermissionManager $permissionManager ) {
parent::__construct( 'AbuseLog', 'abusefilter-log' );
+ $this->linkBatchFactory = $linkBatchFactory;
+ $this->permissionManager = $permissionManager;
}
/**
- * @return bool
+ * @inheritDoc
*/
public function doesWrites() {
return true;
}
/**
+ * @inheritDoc
+ */
+ protected function getGroupName() {
+ return 'changes';
+ }
+
+ /**
* Main routine
*
* $parameter string is converted into the $args array, which can come in
@@ -66,41 +113,29 @@ class SpecialAbuseLog extends SpecialPage {
* used as the identifier of the log entry that we want to hide; otherwise,
* the abuse logs are shown as a list, with a search form above the list.
*
- * @param string $parameter URL parameters
+ * @param string|null $parameter URL parameters
*/
public function execute( $parameter ) {
$out = $this->getOutput();
$request = $this->getRequest();
- AbuseFilter::addNavigationLinks(
- $this->getContext(), 'log', $this->getLinkRenderer() );
+ $this->addNavigationLinks( 'log' );
$this->setHeaders();
- $this->outputHeader( 'abusefilter-log-summary' );
+ $this->addHelpLink( 'Extension:AbuseFilter' );
$this->loadParameters();
- $out->setPageTitle( $this->msg( 'abusefilter-log' ) );
- $out->setRobotPolicy( "noindex,nofollow" );
- $out->setArticleRelated( false );
$out->enableClientCache( false );
$out->addModuleStyles( 'ext.abuseFilter' );
- // Are we allowed?
- $errors = $this->getPageTitle()->getUserPermissionsErrors(
- 'abusefilter-log', $this->getUser(), true, [ 'ns-specialprotected' ] );
- if ( count( $errors ) ) {
- $out->showPermissionsErrorPage( $errors, 'abusefilter-log' );
-
- return;
- }
+ $this->checkPermissions();
- $detailsid = $request->getIntOrNull( 'details' );
$hideid = $request->getIntOrNull( 'hide' );
$args = explode( '/', $parameter );
if ( count( $args ) === 2 && $args[0] === 'private' ) {
- $this->showPrivateDetails( $args[1] );
+ $this->showPrivateDetails( (int)$args[1] );
} elseif ( count( $args ) === 1 && $args[0] !== '' ) {
if ( $args[0] === 'private' ) {
$out->addWikiMsg( 'abusefilter-invalid-request-noid' );
@@ -133,10 +168,13 @@ class SpecialAbuseLog extends SpecialPage {
$this->mSearchPeriodStart = $request->getText( 'wpSearchPeriodStart' );
$this->mSearchPeriodEnd = $request->getText( 'wpSearchPeriodEnd' );
$this->mSearchTitle = $request->getText( 'wpSearchTitle' );
+ if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ) {
+ $this->mSearchGroup = $request->getText( 'wpSearchGroup' );
+ }
$this->mSearchFilter = null;
$this->mSearchAction = $request->getText( 'wpSearchAction' );
$this->mSearchActionTaken = $request->getText( 'wpSearchActionTaken' );
- if ( self::canSeeDetails() ) {
+ if ( self::canSeeDetails( $this->getUser() ) ) {
$this->mSearchFilter = $request->getText( 'wpSearchFilter' );
}
@@ -176,6 +214,7 @@ class SpecialAbuseLog extends SpecialPage {
* Builds the search form
*/
public function searchForm() {
+ $user = $this->getUser();
$formDescriptor = [
'SearchUser' => [
'label-message' => 'abusefilter-log-search-user',
@@ -211,29 +250,36 @@ class SpecialAbuseLog extends SpecialPage {
];
$filterableActions = $this->getAllFilterableActions();
$actions = array_combine( $filterableActions, $filterableActions );
- $actions[ $this->msg( 'abusefilter-log-search-action-other' )->text() ] = 'other';
- $actions[ $this->msg( 'abusefilter-log-search-action-any' )->text() ] = 'any';
+ ksort( $actions );
+ $actions = array_merge(
+ [ $this->msg( 'abusefilter-log-search-action-any' )->text() => 'any' ],
+ $actions,
+ [ $this->msg( 'abusefilter-log-search-action-other' )->text() => 'other' ]
+ );
$formDescriptor['SearchAction'] = [
'label-message' => 'abusefilter-log-search-action-label',
'type' => 'select',
'options' => $actions,
'default' => 'any',
];
- $options = [
- $this->msg( 'abusefilter-log-noactions' )->text() => 'noactions',
- $this->msg( 'abusefilter-log-search-action-taken-any' )->text() => '',
- ];
+ $options = [];
+ $context = $this->getContext();
foreach ( $this->getAllActions() as $action ) {
- $key = AbuseFilter::getActionDisplay( $action );
+ $key = AbuseFilter::getActionDisplay( $action, $context );
$options[$key] = $action;
}
ksort( $options );
+ $options = array_merge(
+ [ $this->msg( 'abusefilter-log-search-action-taken-any' )->text() => '' ],
+ $options,
+ [ $this->msg( 'abusefilter-log-noactions-filter' )->text() => 'noactions' ]
+ );
$formDescriptor['SearchActionTaken'] = [
'label-message' => 'abusefilter-log-search-action-taken-label',
'type' => 'select',
'options' => $options,
];
- if ( self::canSeeHidden() ) {
+ if ( self::canSeeHidden( $user ) ) {
$formDescriptor['SearchEntries'] = [
'type' => 'select',
'label-message' => 'abusefilter-log-search-entries-label',
@@ -244,15 +290,34 @@ class SpecialAbuseLog extends SpecialPage {
],
];
}
- if ( self::canSeeDetails() ) {
+
+ $groups = $this->getConfig()->get( 'AbuseFilterValidGroups' );
+ if ( count( $groups ) > 1 ) {
+ $options = array_merge(
+ [ $this->msg( 'abusefilter-log-search-group-any' )->text() => 0 ],
+ array_combine( $groups, $groups )
+ );
+ $formDescriptor['SearchGroup'] = [
+ 'label-message' => 'abusefilter-log-search-group',
+ 'type' => 'select',
+ 'options' => $options
+ ];
+ }
+
+ if ( self::canSeeDetails( $user ) ) {
+ $helpmsg = $this->getConfig()->get( 'AbuseFilterIsCentral' )
+ ? $this->msg( 'abusefilter-log-search-filter-help-central' )->escaped()
+ : $this->msg( 'abusefilter-log-search-filter-help' )
+ ->params( AbuseFilter::GLOBAL_FILTER_PREFIX )->escaped();
$formDescriptor['SearchFilter'] = [
'label-message' => 'abusefilter-log-search-filter',
'type' => 'text',
'default' => $this->mSearchFilter,
+ 'help' => $helpmsg
];
}
if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
- // Add free form input for wiki name. Would be nice to generate
+ // @todo Add free form input for wiki name. Would be nice to generate
// a select with unique names in the db at some point.
$formDescriptor['SearchWiki'] = [
'label-message' => 'abusefilter-log-search-wiki',
@@ -265,16 +330,17 @@ class SpecialAbuseLog extends SpecialPage {
->setWrapperLegendMsg( 'abusefilter-log-search' )
->setSubmitTextMsg( 'abusefilter-log-search-submit' )
->setMethod( 'get' )
+ ->setCollapsibleOptions( true )
->prepareForm()
->displayForm( false );
}
/**
- * @param string $id
+ * @param int $id
*/
public function showHideForm( $id ) {
$output = $this->getOutput();
- if ( !$this->getUser()->isAllowed( 'abusefilter-hide-log' ) ) {
+ if ( !$this->permissionManager->userHasRight( $this->getUser(), 'abusefilter-hide-log' ) ) {
$output->addWikiMsg( 'abusefilter-log-hide-forbidden' );
return;
@@ -282,21 +348,20 @@ class SpecialAbuseLog extends SpecialPage {
$dbr = wfGetDB( DB_REPLICA );
- $row = $dbr->selectRow(
- [ 'abuse_filter_log', 'abuse_filter' ],
+ $deleted = $dbr->selectField(
+ 'abuse_filter_log',
'afl_deleted',
[ 'afl_id' => $id ],
- __METHOD__,
- [],
- [ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ]
+ __METHOD__
);
- if ( !$row ) {
+ if ( $deleted === false ) {
+ $output->addWikiMsg( 'abusefilter-log-nonexistent' );
return;
}
$hideReasonsOther = $this->msg( 'revdelete-reasonotherlist' )->text();
- $hideReasons = $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->text();
+ $hideReasons = $this->msg( 'revdelete-reason-dropdown-suppress' )->inContentLanguage()->text();
$hideReasons = Xml::listDropDownOptions( $hideReasons, [ 'other' => $hideReasonsOther ] );
$formInfo = [
@@ -316,7 +381,7 @@ class SpecialAbuseLog extends SpecialPage {
],
'hidden' => [
'type' => 'toggle',
- 'default' => $row->afl_deleted,
+ 'default' => $deleted,
'label-message' => 'abusefilter-log-hide-hidden',
],
];
@@ -331,7 +396,7 @@ class SpecialAbuseLog extends SpecialPage {
// Show suppress log for this entry
$suppressLogPage = new LogPage( 'suppress' );
$output->addHTML( "<h2>" . $suppressLogPage->getName()->escaped() . "</h2>\n" );
- LogEventsList::showLogExtract( $output, 'suppress', $this->getPageTitle( $id ) );
+ LogEventsList::showLogExtract( $output, 'suppress', $this->getPageTitle( (string)$id ) );
}
/**
@@ -375,19 +440,21 @@ class SpecialAbuseLog extends SpecialPage {
*/
public function showList() {
$out = $this->getOutput();
+ $user = $this->getUser();
+ $this->outputHeader( 'abusefilter-log-summary' );
// Generate conditions list.
$conds = [];
if ( $this->mSearchUser ) {
- $user = User::newFromName( $this->mSearchUser );
+ $searchedUser = User::newFromName( $this->mSearchUser );
- if ( !$user ) {
+ if ( !$searchedUser ) {
$conds['afl_user'] = 0;
$conds['afl_user_text'] = $this->mSearchUser;
} else {
- $conds['afl_user'] = $user->getId();
- $conds['afl_user_text'] = $user->getName();
+ $conds['afl_user'] = $searchedUser->getId();
+ $conds['afl_user_text'] = $searchedUser->getName();
}
}
@@ -403,24 +470,58 @@ class SpecialAbuseLog extends SpecialPage {
}
if ( $this->mSearchWiki ) {
- if ( $this->mSearchWiki == wfWikiID() ) {
+ if ( $this->mSearchWiki === WikiMap::getCurrentWikiDbDomain()->getId() ) {
$conds['afl_wiki'] = null;
} else {
$conds['afl_wiki'] = $this->mSearchWiki;
}
}
+ $groupFilters = [];
+ if ( $this->mSearchGroup ) {
+ $groupFilters = $dbr->selectFieldValues(
+ 'abuse_filter',
+ 'af_id',
+ [ 'af_group' => $this->mSearchGroup ],
+ __METHOD__
+ );
+ }
+
+ $searchFilters = [];
if ( $this->mSearchFilter ) {
- $searchFilters = array_map( 'trim', explode( '|', $this->mSearchFilter ) );
+ $rawFilters = array_map( 'trim', explode( '|', $this->mSearchFilter ) );
+ // Map of [ [ id, global ], ... ]
+ $filtersList = [];
+ $foundInvalid = false;
+ foreach ( $rawFilters as $filter ) {
+ try {
+ $filtersList[] = AbuseFilter::splitGlobalName( $filter );
+ } catch ( InvalidArgumentException $e ) {
+ $foundInvalid = true;
+ continue;
+ }
+ }
+
+ // @phan-suppress-next-line PhanImpossibleCondition
+ if ( $foundInvalid ) {
+ $out->addHTML(
+ Html::rawElement(
+ 'p',
+ [],
+ Html::warningBox( $this->msg( 'abusefilter-log-invalid-filter' )->escaped() )
+ )
+ );
+ }
+
// if a filter is hidden, users who can't view private filters should
// not be able to find log entries generated by it.
- if ( !AbuseFilterView::canViewPrivate()
- && !$this->getUser()->isAllowed( 'abusefilter-log-private' )
+ if ( !AbuseFilter::canViewPrivate( $user )
+ && !$this->permissionManager->userHasRight( $user, 'abusefilter-log-private' )
) {
$searchedForPrivate = false;
- foreach ( $searchFilters as $index => $filter ) {
- if ( AbuseFilter::filterHidden( $filter ) ) {
- unset( $searchFilters[$index] );
+ foreach ( $filtersList as $index => $filterData ) {
+ if ( AbuseFilter::filterHidden( ...$filterData ) ) {
+ unset( $filtersList[$index] );
$searchedForPrivate = true;
}
}
@@ -428,12 +529,28 @@ class SpecialAbuseLog extends SpecialPage {
$out->addWikiMsg( 'abusefilter-log-private-not-included' );
}
}
- if ( empty( $searchFilters ) ) {
- $out->addWikiMsg( 'abusefilter-log-noresults' );
+ foreach ( $filtersList as $filterData ) {
+ $searchFilters[] = AbuseFilter::buildGlobalName( ...$filterData );
+ }
+ }
+
+ $searchIDs = null;
+ if ( $this->mSearchGroup && !$this->mSearchFilter ) {
+ $searchIDs = $groupFilters;
+ } elseif ( !$this->mSearchGroup && $this->mSearchFilter ) {
+ $searchIDs = $searchFilters;
+ } elseif ( $this->mSearchGroup && $this->mSearchFilter ) {
+ $searchIDs = array_intersect( $groupFilters, $searchFilters );
+ }
+
+ if ( $searchIDs !== null ) {
+ if ( !count( $searchIDs ) ) {
+ $out->addWikiMsg( 'abusefilter-log-noresults' );
return;
}
- $conds['afl_filter'] = $searchFilters;
+
+ $conds['afl_filter'] = $searchIDs;
}
$searchTitle = Title::newFromText( $this->mSearchTitle );
@@ -442,20 +559,17 @@ class SpecialAbuseLog extends SpecialPage {
$conds['afl_title'] = $searchTitle->getDBkey();
}
- if ( self::canSeeHidden() ) {
- if ( $this->mSearchEntries == '1' ) {
+ if ( self::canSeeHidden( $user ) ) {
+ if ( $this->mSearchEntries === '1' ) {
$conds['afl_deleted'] = 1;
- } elseif ( $this->mSearchEntries == '2' ) {
- $conds[] = self::getNotDeletedCond( $dbr );
+ } elseif ( $this->mSearchEntries === '2' ) {
+ $conds['afl_deleted'] = 0;
}
}
if ( in_array( $this->mSearchImpact, [ '1', '2' ] ) ) {
- $unsuccessfulActionConds = $dbr->makeList( [
- 'afl_rev_id' => null,
- 'afl_log_id' => null,
- ], LIST_AND );
- if ( $this->mSearchImpact == '1' ) {
+ $unsuccessfulActionConds = 'afl_rev_id IS NULL';
+ if ( $this->mSearchImpact === '1' ) {
$conds[] = "NOT ( $unsuccessfulActionConds )";
} else {
$conds[] = $unsuccessfulActionConds;
@@ -490,7 +604,12 @@ class SpecialAbuseLog extends SpecialPage {
}
}
- $pager = new AbuseLogPager( $this, $conds );
+ $pager = new AbuseLogPager(
+ $this,
+ $conds,
+ $this->linkBatchFactory,
+ $this->canSeeUndeleteDiffs()
+ );
$pager->doQuery();
$result = $pager->getResult();
if ( $result && $result->numRows() !== 0 ) {
@@ -503,51 +622,65 @@ class SpecialAbuseLog extends SpecialPage {
}
/**
- * @param string $id
+ * @param string|int $id
+ * @suppress SecurityCheck-SQLInjection
*/
public function showDetails( $id ) {
$out = $this->getOutput();
+ $user = $this->getUser();
- $dbr = wfGetDB( DB_REPLICA );
+ $pager = new AbuseLogPager(
+ $this,
+ [],
+ $this->linkBatchFactory,
+ $this->canSeeUndeleteDiffs()
+ );
+ [
+ 'tables' => $tables,
+ 'fields' => $fields,
+ 'join_conds' => $join_conds,
+ ] = $pager->getQueryInfo();
+
+ $dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
- [ 'abuse_filter_log', 'abuse_filter' ],
- '*',
+ $tables,
+ $fields,
[ 'afl_id' => $id ],
__METHOD__,
[],
- [ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ]
+ $join_conds
);
+ $error = null;
if ( !$row ) {
- $out->addWikiMsg( 'abusefilter-log-nonexistent' );
-
- return;
- }
-
- if ( AbuseFilter::decodeGlobalName( $row->afl_filter ) ) {
- $filter_hidden = null;
+ $error = 'abusefilter-log-nonexistent';
} else {
- $filter_hidden = $row->af_hidden;
- }
-
- if ( !self::canSeeDetails( $row->afl_filter, $filter_hidden ) ) {
- $out->addWikiMsg( 'abusefilter-log-cannot-see-details' );
+ list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter );
+ if ( $global ) {
+ $filter_hidden = null;
+ } else {
+ $filter_hidden = $row->af_hidden;
+ }
- return;
+ if ( !self::canSeeDetails( $user, $filterID, $global, $filter_hidden ) ) {
+ $error = 'abusefilter-log-cannot-see-details';
+ } elseif ( self::isHidden( $row ) === true && !self::canSeeHidden( $user ) ) {
+ $error = 'abusefilter-log-details-hidden';
+ } elseif ( self::isHidden( $row ) === 'implicit' ) {
+ $revRec = MediaWikiServices::getInstance()
+ ->getRevisionLookup()
+ ->getRevisionById( (int)$row->afl_rev_id );
+ if ( !AbuseFilter::userCanViewRev( $revRec, $user ) ) {
+ // The log is visible, but refers to a deleted revision
+ $error = 'abusefilter-log-details-hidden-implicit';
+ }
+ }
}
- if ( self::isHidden( $row ) === true && !self::canSeeHidden() ) {
- $out->addWikiMsg( 'abusefilter-log-details-hidden' );
-
+ if ( $error ) {
+ $out->addWikiMsg( $error );
return;
- } elseif ( self::isHidden( $row ) === 'implicit' ) {
- $rev = Revision::newFromId( $row->afl_rev_id );
- // The log is visible, but refers to a deleted revision
- if ( !$rev->userCan( Revision::SUPPRESSED_ALL, $this->getUser() ) ) {
- $out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' );
- return;
- }
}
$output = Xml::element(
@@ -564,7 +697,8 @@ class SpecialAbuseLog extends SpecialPage {
$out->addJsConfigVars( 'wgAbuseFilterVariables', $vars->dumpAllVars( true ) );
// Diff, if available
- if ( $vars && $vars->getVar( 'action' )->toString() == 'edit' ) {
+ if ( $row->afl_action === 'edit' ) {
+ $vars->setLogger( LoggerFactory::getInstance( 'AbuseFilter' ) );
$old_wikitext = $vars->getVar( 'old_wikitext' )->toString();
$new_wikitext = $vars->getVar( 'new_wikitext' )->toString();
@@ -592,19 +726,19 @@ class SpecialAbuseLog extends SpecialPage {
// Build a table.
$output .= AbuseFilter::buildVarDumpTable( $vars, $this->getContext() );
- if ( self::canSeePrivate() ) {
+ if ( self::canSeePrivateDetails( $user ) ) {
$formDescriptor = [
'Reason' => [
- 'label-message' => 'abusefilter-view-private-reason',
+ 'label-message' => 'abusefilter-view-privatedetails-reason',
'type' => 'text',
'size' => 45,
],
];
$htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
- $htmlForm->setWrapperLegendMsg( 'abusefilter-view-private' )
+ $htmlForm->setWrapperLegendMsg( 'abusefilter-view-privatedetails-legend' )
->setAction( $this->getPageTitle( 'private/' . $id )->getLocalURL() )
- ->setSubmitTextMsg( 'abusefilter-view-private-submit' )
+ ->setSubmitTextMsg( 'abusefilter-view-privatedetails-submit' )
->setMethod( 'post' )
->prepareForm();
@@ -615,38 +749,54 @@ class SpecialAbuseLog extends SpecialPage {
}
/**
- * @param string $id
- * @return void
+ * Can this user see diffs generated by Special:Undelete?
+ * @see \SpecialUndelete
+ *
+ * @return bool
*/
- public function showPrivateDetails( $id ) {
- $lang = $this->getLanguage();
- $out = $this->getOutput();
- $request = $this->getRequest();
-
- $dbr = wfGetDB( DB_REPLICA );
-
- $reason = $request->getText( 'wpReason' );
+ private function canSeeUndeleteDiffs() : bool {
+ if ( !$this->permissionManager->userHasRight( $this->getUser(), 'deletedhistory' ) ) {
+ return false;
+ }
- // Make sure it is a valid request
- $token = $request->getVal( 'wpEditToken' );
- if ( !$request->wasPosted() || !$this->getUser()->matchEditToken( $token ) ) {
- $out->addHTML(
- Xml::tags(
- 'p',
- null,
- Html::errorBox( $this->msg( 'abusefilter-invalid-request' )->params( $id )->parse() )
- )
- );
+ return $this->permissionManager->userHasAnyRight(
+ $this->getUser(), 'deletedtext', 'undelete' );
+ }
- return;
+ /**
+ * Can this user see diffs generated by Special:Undelete for the page?
+ * @see \SpecialUndelete
+ * @param LinkTarget $page
+ *
+ * @return bool
+ */
+ private function canSeeUndeleteDiffForPage( LinkTarget $page ) : bool {
+ if ( !$this->canSeeUndeleteDiffs() ) {
+ return false;
}
- if ( !$this->checkReason( $reason ) ) {
- $out->addWikiMsg( 'abusefilter-noreason' );
- $this->showDetails( $id );
- return;
+ foreach ( [ 'deletedtext', 'undelete' ] as $action ) {
+ if ( $this->permissionManager->userCan(
+ $action, $this->getUser(), $page, PermissionManager::RIGOR_QUICK
+ ) ) {
+ return true;
+ }
}
+ return false;
+ }
+
+ /**
+ * Helper function to select a row with private details and some more context
+ * for an AbuseLog entry.
+ *
+ * @param User $user The user who's trying to view the row
+ * @param int $id The ID of the log entry
+ * @return Status A status object with the requested row stored in the value property,
+ * or an error and no row.
+ */
+ public static function getPrivateDetailsRow( User $user, $id ) {
+ $dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
[ 'abuse_filter_log', 'abuse_filter' ],
[ 'afl_id', 'afl_filter', 'afl_user_text', 'afl_timestamp', 'afl_ip', 'af_id',
@@ -657,41 +807,38 @@ class SpecialAbuseLog extends SpecialPage {
[ 'abuse_filter' => [ 'LEFT JOIN', 'af_id=afl_filter' ] ]
);
+ $status = Status::newGood();
if ( !$row ) {
- $out->addWikiMsg( 'abusefilter-log-nonexistent' );
-
- return;
+ $status->fatal( 'abusefilter-log-nonexistent' );
+ return $status;
}
- if ( AbuseFilter::decodeGlobalName( $row->afl_filter ) ) {
- $filter_hidden = null;
+ list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter );
+ if ( $global ) {
+ $filterHidden = null;
} else {
- $filter_hidden = $row->af_hidden;
- }
-
- if ( !self::canSeeDetails( $row->afl_filter, $filter_hidden ) ) {
- $out->addWikiMsg( 'abusefilter-log-cannot-see-details' );
-
- return;
- }
-
- if ( !self::canSeePrivate() ) {
- $out->addWikiMsg( 'abusefilter-log-cannot-see-private-details' );
-
- return;
+ $filterHidden = $row->af_hidden;
}
- // Log accessing private details
- if ( $this->getConfig()->get( 'AbuseFilterPrivateLog' ) ) {
- $user = $this->getUser();
- self::addLogEntry( $id, $reason, $user );
+ if ( !self::canSeeDetails( $user, $filterID, $global, $filterHidden ) ) {
+ $status->fatal( 'abusefilter-log-cannot-see-details' );
+ return $status;
}
+ $status->setResult( true, $row );
+ return $status;
+ }
- // Show private details (IP).
+ /**
+ * Builds an HTML table with the private details for a given abuseLog entry.
+ *
+ * @param stdClass $row The row, as returned by self::getPrivateDetailsRow()
+ * @return string The HTML output
+ */
+ protected function buildPrivateDetailsTable( $row ) {
$output = Xml::element(
'legend',
null,
- $this->msg( 'abusefilter-log-details-private' )->text()
+ $this->msg( 'abusefilter-log-details-privatedetails' )->text()
);
$header =
@@ -719,7 +866,7 @@ class SpecialAbuseLog extends SpecialPage {
Xml::openElement( 'td' ) .
$linkRenderer->makeKnownLink(
$this->getPageTitle( $row->afl_id ),
- $lang->formatNum( $row->afl_id )
+ $this->getLanguage()->formatNum( $row->afl_id )
) .
Xml::closeElement( 'td' )
);
@@ -733,7 +880,7 @@ class SpecialAbuseLog extends SpecialPage {
) .
Xml::element( 'td',
null,
- $lang->timeanddate( $row->afl_timestamp, true )
+ $this->getLanguage()->timeanddate( $row->afl_timestamp, true )
)
);
@@ -760,7 +907,7 @@ class SpecialAbuseLog extends SpecialPage {
Xml::openElement( 'td' ) .
$linkRenderer->makeKnownLink(
SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ),
- $lang->formatNum( $row->af_id )
+ $this->getLanguage()->formatNum( $row->af_id )
) .
Xml::closeElement( 'td' )
);
@@ -781,14 +928,15 @@ class SpecialAbuseLog extends SpecialPage {
// IP address
if ( $row->afl_ip !== '' ) {
if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) &&
- $this->getUser()->isAllowed( 'checkuser' ) ) {
- $CULink = '&nbsp;&middot;&nbsp;' . $linkRenderer->makeKnownLink(
- SpecialPage::getTitleFor(
- 'CheckUser',
- $row->afl_ip
- ),
- $this->msg( 'abusefilter-log-details-checkuser' )->text()
- );
+ $this->permissionManager->userHasRight( $this->getUser(), 'checkuser' )
+ ) {
+ $CULink = '&nbsp;&middot;&nbsp;' . $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor(
+ 'CheckUser',
+ $row->afl_ip
+ ),
+ $this->msg( 'abusefilter-log-details-checkuser' )->text()
+ );
} else {
$CULink = '';
}
@@ -822,8 +970,60 @@ class SpecialAbuseLog extends SpecialPage {
$output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' );
$output = Xml::tags( 'fieldset', null, $output );
+ return $output;
+ }
- $out->addHTML( $output );
+ /**
+ * @param int $id
+ * @return void
+ */
+ public function showPrivateDetails( $id ) {
+ $out = $this->getOutput();
+ $user = $this->getUser();
+
+ if ( !self::canSeePrivateDetails( $user ) ) {
+ $out->addWikiMsg( 'abusefilter-log-cannot-see-privatedetails' );
+
+ return;
+ }
+ $request = $this->getRequest();
+
+ // Make sure it is a valid request
+ $token = $request->getVal( 'wpEditToken' );
+ if ( !$request->wasPosted() || !$user->matchEditToken( $token ) ) {
+ $out->addHTML(
+ Xml::tags(
+ 'p',
+ null,
+ Html::errorBox( $this->msg( 'abusefilter-invalid-request' )->params( $id )->parse() )
+ )
+ );
+
+ return;
+ }
+
+ $reason = $request->getText( 'wpReason' );
+ if ( !self::checkPrivateDetailsAccessReason( $reason ) ) {
+ $out->addWikiMsg( 'abusefilter-noreason' );
+ $this->showDetails( $id );
+ return;
+ }
+
+ $status = self::getPrivateDetailsRow( $user, $id );
+ if ( !$status->isGood() ) {
+ $out->addWikiMsg( $status->getErrors()[0] );
+ return;
+ }
+ $row = $status->getValue();
+
+ // Log accessing private details
+ if ( $this->getConfig()->get( 'AbuseFilterLogPrivateDetailsAccess' ) ) {
+ self::addPrivateDetailsAccessLogEntry( $id, $reason, $user );
+ }
+
+ // Show private details (IP).
+ $table = $this->buildPrivateDetailsTable( $row );
+ $out->addHTML( $table );
}
/**
@@ -833,8 +1033,9 @@ class SpecialAbuseLog extends SpecialPage {
* @param string $reason
* @return bool
*/
- protected function checkReason( $reason ) {
- return ( !$this->getConfig()->get( 'AbuseFilterForceSummary' ) || strlen( $reason ) > 0 );
+ public static function checkPrivateDetailsAccessReason( $reason ) {
+ global $wgAbuseFilterPrivateDetailsForceReason;
+ return ( !$wgAbuseFilterPrivateDetailsForceReason || strlen( $reason ) > 0 );
}
/**
@@ -843,8 +1044,8 @@ class SpecialAbuseLog extends SpecialPage {
* @param User $user The user who accessed the private details
* @return void
*/
- public static function addLogEntry( $logID, $reason, $user ) {
- $target = self::getTitleFor( 'AbuseLog', $logID );
+ public static function addPrivateDetailsAccessLogEntry( $logID, $reason, User $user ) {
+ $target = self::getTitleFor( 'AbuseLog', (string)$logID );
$logEntry = new ManualLogEntry( 'abusefilterprivatedetails', 'access' );
$logEntry->setPerformer( $user );
@@ -858,49 +1059,50 @@ class SpecialAbuseLog extends SpecialPage {
}
/**
- * @param string|null $filter_id
- * @param bool|int|null $filter_hidden
+ * @param User $user
+ * @param int|null $id The ID of the filter
+ * @param bool|int|null $global Whether the filter is global
+ * @param bool|int|null $hidden Whether the filter is hidden
* @return bool
*/
- public static function canSeeDetails( $filter_id = null, $filter_hidden = null ) {
- global $wgUser;
-
- if ( $filter_id !== null ) {
- if ( $filter_hidden === null ) {
- $filter_hidden = AbuseFilter::filterHidden( $filter_id );
+ public static function canSeeDetails( User $user, $id = null, $global = false, $hidden = null ) {
+ $pm = MediaWikiServices::getInstance()->getPermissionManager();
+ if ( $id !== null ) {
+ if ( $hidden === null ) {
+ $hidden = AbuseFilter::filterHidden( $id, $global );
}
- if ( $filter_hidden ) {
- return $wgUser->isAllowed( 'abusefilter-log-detail' ) && (
- AbuseFilterView::canViewPrivate() || $wgUser->isAllowed( 'abusefilter-log-private' )
- );
+ if ( $hidden ) {
+ return $pm->userHasRight( $user, 'abusefilter-log-detail' )
+ && ( AbuseFilter::canViewPrivate( $user ) ||
+ $pm->userHasRight( $user, 'abusefilter-log-private' ) );
}
}
- return $wgUser->isAllowed( 'abusefilter-log-detail' );
+ return $pm->userHasRight( $user, 'abusefilter-log-detail' );
}
/**
+ * @param UserIdentity $user
* @return bool
*/
- public static function canSeePrivate() {
- global $wgUser;
-
- return $wgUser->isAllowed( 'abusefilter-private' );
+ public static function canSeePrivateDetails( UserIdentity $user ) {
+ return MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $user, 'abusefilter-privatedetails' );
}
/**
+ * @param User $user
* @return bool
*/
- public static function canSeeHidden() {
- global $wgUser;
-
- return $wgUser->isAllowed( 'abusefilter-hidden-log' );
+ public static function canSeeHidden( User $user ) {
+ return MediaWikiServices::getInstance()->getPermissionManager()
+ ->userHasRight( $user, 'abusefilter-hidden-log' );
}
/**
* @param stdClass $row
* @param bool $isListItem
- * @return String
+ * @return string
*/
public function formatRow( $row, $isListItem = true ) {
$user = $this->getUser();
@@ -913,20 +1115,42 @@ class SpecialAbuseLog extends SpecialPage {
$diffLink = false;
$isHidden = self::isHidden( $row );
- if ( !self::canSeeHidden() && $isHidden === true ) {
+ // @todo T224203 Try to show the details if the revision is deleted but the AbuseLog entry
+ // is not. However, watch out to avoid showing too much stuff.
+ if ( !self::canSeeHidden( $user ) && $isHidden ) {
return '';
}
$linkRenderer = $this->getLinkRenderer();
if ( !$row->afl_wiki ) {
- $pageLink = $linkRenderer->makeLink( $title );
- if ( $row->afl_rev_id && $title->exists() ) {
+ $pageLink = $linkRenderer->makeLink(
+ $title,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+ if ( $row->rev_id ) {
$diffLink = $linkRenderer->makeKnownLink(
$title,
new HtmlArmor( $this->msg( 'abusefilter-log-diff' )->parse() ),
[],
- [ 'diff' => 'prev', 'oldid' => $row->afl_rev_id ] );
+ [ 'diff' => 'prev', 'oldid' => $row->rev_id ]
+ );
+ } elseif (
+ isset( $row->ar_timestamp ) && $row->ar_timestamp
+ && $this->canSeeUndeleteDiffForPage( $title )
+ ) {
+ $diffLink = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Undelete' ),
+ new HtmlArmor( $this->msg( 'abusefilter-log-diff' )->parse() ),
+ [],
+ [
+ 'diff' => 'prev',
+ 'target' => $title->getPrefixedText(),
+ 'timestamp' => $row->ar_timestamp,
+ ]
+ );
}
} else {
$pageLink = WikiMap::makeForeignLink( $row->afl_wiki, $row->afl_title );
@@ -937,7 +1161,7 @@ class SpecialAbuseLog extends SpecialPage {
[ 'diff' => 'prev', 'oldid' => $row->afl_rev_id ] );
$diffLink = Linker::makeExternalLink( $diffUrl,
- $this->msg( 'abusefilter-log-diff' )->parse() );
+ $this->msg( 'abusefilter-log-diff' )->text() );
}
}
@@ -949,27 +1173,28 @@ class SpecialAbuseLog extends SpecialPage {
$userLink .= ' (' . WikiMap::getWikiName( $row->afl_wiki ) . ')';
}
- $timestamp = $lang->timeanddate( $row->afl_timestamp, true );
+ $timestamp = htmlspecialchars( $lang->timeanddate( $row->afl_timestamp, true ) );
- $actions_taken = $row->afl_actions;
- if ( !strlen( trim( $actions_taken ) ) ) {
+ $actions_takenRaw = $row->afl_actions;
+ if ( !strlen( trim( $actions_takenRaw ) ) ) {
$actions_taken = $this->msg( 'abusefilter-log-noactions' )->escaped();
} else {
- $actions = explode( ',', $actions_taken );
+ $actions = explode( ',', $actions_takenRaw );
$displayActions = [];
+ $context = $this->getContext();
foreach ( $actions as $action ) {
- $displayActions[] = AbuseFilter::getActionDisplay( $action );
+ $displayActions[] = AbuseFilter::getActionDisplay( $action, $context );
}
$actions_taken = $lang->commaList( $displayActions );
}
- $globalIndex = AbuseFilter::decodeGlobalName( $row->afl_filter );
+ list( $filterID, $global ) = AbuseFilter::splitGlobalName( $row->afl_filter );
- if ( $globalIndex ) {
+ if ( $global ) {
// Pull global filter description
$escaped_comments = Sanitizer::escapeHtmlAllowEntities(
- AbuseFilter::getGlobalFilterDescription( $globalIndex ) );
+ AbuseFilter::getGlobalFilterDescription( $filterID ) );
$filter_hidden = null;
} else {
$escaped_comments = Sanitizer::escapeHtmlAllowEntities(
@@ -977,7 +1202,7 @@ class SpecialAbuseLog extends SpecialPage {
$filter_hidden = $row->af_hidden;
}
- if ( self::canSeeDetails( $row->afl_filter, $filter_hidden ) ) {
+ if ( self::canSeeDetails( $user, $filterID, $global, $filter_hidden ) ) {
if ( $isListItem ) {
$detailsLink = $linkRenderer->makeKnownLink(
$this->getPageTitle( $row->afl_id ),
@@ -997,7 +1222,7 @@ class SpecialAbuseLog extends SpecialPage {
$actionLinks[] = $diffLink;
}
- if ( $user->isAllowed( 'abusefilter-hide-log' ) ) {
+ if ( $this->permissionManager->userHasRight( $user, 'abusefilter-hide-log' ) ) {
$hideLink = $linkRenderer->makeKnownLink(
$this->getPageTitle(),
$this->msg( 'abusefilter-log-hidelink' )->text(),
@@ -1008,25 +1233,25 @@ class SpecialAbuseLog extends SpecialPage {
$actionLinks[] = $hideLink;
}
- if ( $globalIndex ) {
+ if ( $global ) {
$globalURL = WikiMap::getForeignURL(
$this->getConfig()->get( 'AbuseFilterCentralDB' ),
- 'Special:AbuseFilter/' . $globalIndex
+ 'Special:AbuseFilter/' . $filterID
);
$linkText = $this->msg( 'abusefilter-log-detailedentry-global' )
- ->numParams( $globalIndex )->escaped();
+ ->numParams( $filterID )->text();
$filterLink = Linker::makeExternalLink( $globalURL, $linkText );
} else {
- $title = SpecialPage::getTitleFor( 'AbuseFilter', $row->afl_filter );
+ $title = SpecialPage::getTitleFor( 'AbuseFilter', $filterID );
$linkText = $this->msg( 'abusefilter-log-detailedentry-local' )
- ->numParams( $row->afl_filter )->text();
+ ->numParams( $filterID )->text();
$filterLink = $linkRenderer->makeKnownLink( $title, $linkText );
}
$description = $this->msg( 'abusefilter-log-detailedentry-meta' )->rawParams(
$timestamp,
$userLink,
$filterLink,
- $row->afl_action,
+ htmlspecialchars( $row->afl_action ),
$pageLink,
$actions_taken,
$escaped_comments,
@@ -1041,7 +1266,7 @@ class SpecialAbuseLog extends SpecialPage {
$description = $this->msg( $msg )->rawParams(
$timestamp,
$userLink,
- $row->afl_action,
+ htmlspecialchars( $row->afl_action ),
$pageLink,
$actions_taken,
$escaped_comments,
@@ -1050,19 +1275,18 @@ class SpecialAbuseLog extends SpecialPage {
)->params( $row->afl_user_text )->parse();
}
+ $attribs = null;
if ( $isHidden === true ) {
- $description .= ' ' .
- $this->msg( 'abusefilter-log-hidden' )->parse();
- $class = 'afl-hidden';
+ $attribs = [ 'class' => 'mw-abusefilter-log-hidden-entry' ];
} elseif ( $isHidden === 'implicit' ) {
$description .= ' ' .
$this->msg( 'abusefilter-log-hidden-implicit' )->parse();
}
if ( $isListItem ) {
- return Xml::tags( 'li', isset( $class ) ? [ 'class' => $class ] : null, $description );
+ return Xml::tags( 'li', $attribs, $description );
} else {
- return Xml::tags( 'span', isset( $class ) ? [ 'class' => $class ] : null, $description );
+ return Xml::tags( 'span', $attribs, $description );
}
}
@@ -1083,21 +1307,6 @@ class SpecialAbuseLog extends SpecialPage {
}
/**
- * @param \Wikimedia\Rdbms\IDatabase $db
- * @return string
- */
- public static function getNotDeletedCond( $db ) {
- $deletedZeroCond = $db->makeList(
- [ 'afl_deleted' => 0 ], LIST_AND );
- $deletedNullCond = $db->makeList(
- [ 'afl_deleted' => null ], LIST_AND );
- $notDeletedCond = $db->makeList(
- [ $deletedZeroCond, $deletedNullCond ], LIST_OR );
-
- return $notDeletedCond;
- }
-
- /**
* Given a log entry row, decides whether or not it can be viewed by the public.
*
* @param stdClass $row The abuse_filter_log row object.
@@ -1112,19 +1321,14 @@ class SpecialAbuseLog extends SpecialPage {
return true;
}
if ( $row->afl_rev_id ) {
- $revision = Revision::newFromId( $row->afl_rev_id );
- if ( $revision && $revision->getVisibility() != 0 ) {
+ $revision = MediaWikiServices::getInstance()
+ ->getRevisionLookup()
+ ->getRevisionById( $row->afl_rev_id );
+ if ( $revision && $revision->getVisibility() !== 0 ) {
return 'implicit';
}
}
return false;
}
-
- /**
- * @return string
- */
- protected function getGroupName() {
- return 'changes';
- }
}