mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/AbuseFilter.git
synced 2024-11-28 16:00:28 +00:00
2024 lines
60 KiB
PHP
2024 lines
60 KiB
PHP
<?php
|
|
|
|
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGenerator;
|
|
use MediaWiki\Logger\LoggerFactory;
|
|
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
|
|
* 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;
|
|
|
|
/**
|
|
* @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 = [];
|
|
|
|
/** @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
|
|
*/
|
|
public static $tagsToSet = [];
|
|
|
|
/**
|
|
* @var array IDs of logged filters like [ page title => [ 'local' => [ids], 'global' => [ids] ] ].
|
|
* @fixme avoid global state
|
|
*/
|
|
public static $logIds = [];
|
|
|
|
/**
|
|
* Duration for the blockautopromote action.
|
|
* A future improvement could be to make this customizable on a per-filter basis.
|
|
* @var int
|
|
*/
|
|
private const BLOCKAUTOPROMOTE_DURATION = 5 * 86400;
|
|
|
|
/**
|
|
* @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',
|
|
'af_timestamp' => 'afh_timestamp',
|
|
'af_comments' => 'afh_comments',
|
|
'af_public_comments' => 'afh_public_comments',
|
|
'af_deleted' => 'afh_deleted',
|
|
'af_id' => 'afh_filter',
|
|
'af_group' => 'afh_group',
|
|
];
|
|
public 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 */
|
|
public const DISABLED_VARS = [
|
|
'old_text' => 'old-text',
|
|
'old_html' => 'old-html',
|
|
'minor_edit' => 'minor-edit'
|
|
];
|
|
|
|
public 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',
|
|
];
|
|
|
|
/**
|
|
* @deprecated Use VariableGenerator::addUserVars()
|
|
* @param User $user
|
|
* @param RecentChange|null $entry
|
|
* @return AbuseFilterVariableHolder
|
|
*/
|
|
public static function generateUserVars( User $user, RecentChange $entry = null ) {
|
|
$vars = new AbuseFilterVariableHolder();
|
|
$generator = new VariableGenerator( $vars );
|
|
return $generator->addUserVars( $user, $entry )->getVariableHolder();
|
|
}
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
public static function getBuilderValues() {
|
|
static $realValues = null;
|
|
|
|
if ( $realValues ) {
|
|
return $realValues;
|
|
}
|
|
|
|
$realValues = self::BUILDER_VALUES;
|
|
|
|
Hooks::run( 'AbuseFilter-builder', [ &$realValues ] );
|
|
|
|
return $realValues;
|
|
}
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
public static function getDeprecatedVariables() {
|
|
static $deprecatedVars = null;
|
|
|
|
if ( $deprecatedVars ) {
|
|
return $deprecatedVars;
|
|
}
|
|
|
|
$deprecatedVars = self::DEPRECATED_VARS;
|
|
|
|
Hooks::run( 'AbuseFilter-deprecatedVariables', [ &$deprecatedVars ] );
|
|
|
|
return $deprecatedVars;
|
|
}
|
|
|
|
/**
|
|
* @param int $filterID The ID of the filter
|
|
* @param bool|int $global Whether the filter is global
|
|
* @return bool
|
|
*/
|
|
public static function filterHidden( $filterID, $global = false ) {
|
|
global $wgAbuseFilterCentralDB;
|
|
|
|
if ( $global ) {
|
|
if ( !$wgAbuseFilterCentralDB ) {
|
|
return false;
|
|
}
|
|
$dbr = self::getCentralDB( DB_REPLICA );
|
|
} else {
|
|
$dbr = wfGetDB( DB_REPLICA );
|
|
}
|
|
|
|
$hidden = $dbr->selectField(
|
|
'abuse_filter',
|
|
'af_hidden',
|
|
[ 'af_id' => $filterID ],
|
|
__METHOD__
|
|
);
|
|
|
|
return (bool)$hidden;
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use VariableGenerator::addTitleVars
|
|
* @param Title|null $title
|
|
* @param string $prefix
|
|
* @param RecentChange|null $entry
|
|
* @return AbuseFilterVariableHolder
|
|
*/
|
|
public static function generateTitleVars( $title, $prefix, RecentChange $entry = null ) {
|
|
$vars = new AbuseFilterVariableHolder();
|
|
if ( !( $title instanceof Title ) ) {
|
|
return $vars;
|
|
}
|
|
$generator = new VariableGenerator( $vars );
|
|
return $generator->addTitleVars( $title, $prefix, $entry )->getVariableHolder();
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use VariableGenerator::addGenericVars
|
|
* @return AbuseFilterVariableHolder
|
|
*/
|
|
public static function generateGenericVars() {
|
|
$vars = new AbuseFilterVariableHolder();
|
|
$generator = new VariableGenerator( $vars );
|
|
return $generator->addGenericVars()->getVariableHolder();
|
|
}
|
|
|
|
/**
|
|
* @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 ) {
|
|
try {
|
|
$res = self::getDefaultParser()->checkSyntax( $filter );
|
|
} catch ( AFPUserVisibleException $excep ) {
|
|
$res = [ $excep->getMessageObj()->text(), $excep->mPosition ];
|
|
}
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* @param string $expr
|
|
* @return Status
|
|
*/
|
|
public static function evaluateExpression( $expr ) {
|
|
if ( self::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 = self::getDefaultParser( $vars );
|
|
|
|
return Status::newGood( $parser->evaluateExpression( $expr ) );
|
|
}
|
|
|
|
/**
|
|
* @param string $conds
|
|
* @param AbuseFilterParser $parser The parser instance to use.
|
|
* @param bool $ignoreError
|
|
* @param string|null $filter The ID of the filter being parsed
|
|
* @return bool
|
|
* @throws Exception
|
|
*/
|
|
public static function checkConditions(
|
|
$conds, AbuseFilterParser $parser, $ignoreError = true, $filter = null
|
|
) {
|
|
try {
|
|
$result = $parser->parse( $conds );
|
|
} catch ( Exception $excep ) {
|
|
$result = false;
|
|
|
|
if ( $excep instanceof AFPUserVisibleException ) {
|
|
$msg = $excep->getMessageForLogs();
|
|
$excep->setLocalizedMessage();
|
|
} else {
|
|
$msg = $excep->getMessage();
|
|
}
|
|
|
|
$logger = LoggerFactory::getInstance( 'AbuseFilter' );
|
|
$extraInfo = $filter !== null ? " for filter $filter" : '';
|
|
$logger->warning( "AbuseFilter parser error$extraInfo: $msg" );
|
|
|
|
if ( !$ignoreError ) {
|
|
throw $excep;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 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 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(
|
|
AbuseFilterVariableHolder $vars,
|
|
Title $title,
|
|
$group = 'default',
|
|
$mode = 'execute'
|
|
) {
|
|
$parser = self::getDefaultParser( $vars );
|
|
$user = RequestContext::getMain()->getUser();
|
|
|
|
$runner = new AbuseFilterRunner( $user, $title, $vars, $group );
|
|
$runner->parser = $parser;
|
|
return $runner->checkAllFilters();
|
|
}
|
|
|
|
/**
|
|
* @param int|string $filter
|
|
* @internal
|
|
*/
|
|
public static function resetFilterProfile( $filter ) {
|
|
$stash = MediaWikiServices::getInstance()->getMainObjectStash();
|
|
$profileKey = self::filterProfileKey( $filter );
|
|
|
|
$stash->delete( $profileKey );
|
|
}
|
|
|
|
/**
|
|
* Retrieve per-filter statistics.
|
|
*
|
|
* @param string $filter
|
|
* @return array
|
|
*/
|
|
public static function getFilterProfile( $filter ) {
|
|
$stash = MediaWikiServices::getInstance()->getMainObjectStash();
|
|
$profile = $stash->get( self::filterProfileKey( $filter ) );
|
|
|
|
if ( $profile !== false ) {
|
|
$curCount = $profile['count'];
|
|
$curTotalTime = $profile['total-time'];
|
|
$curTotalConds = $profile['total-cond'];
|
|
} else {
|
|
return [ 0, 0, 0, 0 ];
|
|
}
|
|
|
|
// Return in milliseconds, rounded to 2dp
|
|
$avgTime = round( $curTotalTime / $curCount, 2 );
|
|
$avgCond = round( $curTotalConds / $curCount, 1 );
|
|
|
|
return [ $curCount, $profile['matches'], $avgTime, $avgCond ];
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* @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" );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 (string|array)[][][]
|
|
* @phan-return array<string,array<string,array{action:string,parameters:string[]}>>
|
|
*/
|
|
public static function getConsequencesForFilters( $filters ) {
|
|
$globalFilters = [];
|
|
$localFilters = [];
|
|
|
|
foreach ( $filters as $filter ) {
|
|
list( $filterID, $global ) = self::splitGlobalName( $filter );
|
|
|
|
if ( $global ) {
|
|
$globalFilters[] = $filterID;
|
|
} else {
|
|
$localFilters[] = $filter;
|
|
}
|
|
}
|
|
|
|
// Load local filter info
|
|
$dbr = wfGetDB( DB_REPLICA );
|
|
// Retrieve the consequences.
|
|
$consequences = [];
|
|
|
|
if ( count( $localFilters ) ) {
|
|
$consequences = self::loadConsequencesFromDB( $dbr, $localFilters );
|
|
}
|
|
|
|
if ( count( $globalFilters ) ) {
|
|
$consequences += self::loadConsequencesFromDB(
|
|
self::getCentralDB( DB_REPLICA ),
|
|
$globalFilters,
|
|
self::GLOBAL_FILTER_PREFIX
|
|
);
|
|
}
|
|
|
|
return $consequences;
|
|
}
|
|
|
|
/**
|
|
* @param IDatabase $dbr
|
|
* @param string[] $filters
|
|
* @param string $prefix
|
|
* @return (string|array)[][][]
|
|
* @phan-return array<string,array<string,array{action:string,parameters:string[]}>>
|
|
*/
|
|
public static function loadConsequencesFromDB( IDatabase $dbr, $filters, $prefix = '' ) {
|
|
$actionsByFilter = [];
|
|
foreach ( $filters as $filter ) {
|
|
$actionsByFilter[$prefix . $filter] = [];
|
|
}
|
|
|
|
$res = $dbr->select(
|
|
[ 'abuse_filter_action', 'abuse_filter' ],
|
|
'*',
|
|
[ 'af_id' => $filters ],
|
|
__METHOD__,
|
|
[],
|
|
[ 'abuse_filter_action' => [ 'LEFT JOIN', 'afa_filter=af_id' ] ]
|
|
);
|
|
|
|
// Categorise consequences by filter.
|
|
global $wgAbuseFilterRestrictions;
|
|
foreach ( $res as $row ) {
|
|
if ( $row->af_throttled
|
|
&& !empty( $wgAbuseFilterRestrictions[$row->afa_consequence] )
|
|
) {
|
|
// Don't do the action, 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] = [
|
|
'action' => $row->afa_consequence,
|
|
'parameters' => array_filter( explode( "\n", $row->afa_parameters ) )
|
|
];
|
|
}
|
|
}
|
|
|
|
return $actionsByFilter;
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
* @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
|
|
) {
|
|
$runner = new AbuseFilterRunner( $user, $title, $vars, $group );
|
|
return $runner->run();
|
|
}
|
|
|
|
/**
|
|
* @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( $filter ) {
|
|
global $wgAbuseFilterCentralDB;
|
|
|
|
if ( !isset( self::$filterCache[$filter] ) ) {
|
|
list( $filterID, $global ) = self::splitGlobalName( $filter );
|
|
if ( $global ) {
|
|
// Global wiki filter
|
|
if ( !$wgAbuseFilterCentralDB ) {
|
|
return null;
|
|
}
|
|
$dbr = self::getCentralDB( DB_REPLICA );
|
|
} else {
|
|
// Local wiki filter
|
|
$dbr = wfGetDB( DB_REPLICA );
|
|
}
|
|
|
|
$row = $dbr->selectRow(
|
|
'abuse_filter',
|
|
self::ALL_ABUSE_FILTER_FIELDS,
|
|
[ 'af_id' => $filterID ],
|
|
__METHOD__
|
|
);
|
|
self::$filterCache[$filter] = $row ?: null;
|
|
}
|
|
|
|
return self::$filterCache[$filter];
|
|
}
|
|
|
|
/**
|
|
* Checks whether the given object represents a full abuse_filter DB row
|
|
* @param stdClass $row
|
|
* @return bool
|
|
*/
|
|
public static function isFullAbuseFilterRow( stdClass $row ) {
|
|
$actual = array_keys( get_object_vars( $row ) );
|
|
|
|
if (
|
|
count( $actual ) !== count( self::ALL_ABUSE_FILTER_FIELDS )
|
|
|| array_diff( self::ALL_ABUSE_FILTER_FIELDS, $actual )
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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 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.' );
|
|
}
|
|
self::$filterCache[$id] = $row;
|
|
}
|
|
|
|
/**
|
|
* Store a var dump to External Storage or the text table
|
|
* Some of this code is stolen from Revision::insertOn and friends
|
|
*
|
|
* @param AbuseFilterVariableHolder $vars
|
|
* @param bool $global
|
|
*
|
|
* @return int The insert ID.
|
|
*/
|
|
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
|
|
// as those are needed for the diff view on top of the abuse log pages
|
|
$vars = $vars->dumpAllVars( [ 'old_wikitext', 'new_wikitext' ] );
|
|
|
|
// Vars is an array with native PHP data types (non-objects) now
|
|
$text = FormatJson::encode( $vars );
|
|
$flags = [ 'utf-8' ];
|
|
|
|
if ( $wgCompressRevisions && function_exists( 'gzdeflate' ) ) {
|
|
$text = gzdeflate( $text );
|
|
$flags[] = 'gzip';
|
|
}
|
|
|
|
// Store to ExternalStore if applicable
|
|
global $wgDefaultExternalStore, $wgAbuseFilterCentralDB;
|
|
if ( $wgDefaultExternalStore ) {
|
|
if ( $global ) {
|
|
$text = ExternalStore::insertToForeignDefault( $text, $wgAbuseFilterCentralDB );
|
|
} else {
|
|
$text = ExternalStore::insertToDefault( $text );
|
|
}
|
|
|
|
$flags[] = 'external';
|
|
}
|
|
|
|
// Store to text table
|
|
if ( $global ) {
|
|
$dbw = self::getCentralDB( DB_MASTER );
|
|
} else {
|
|
$dbw = wfGetDB( DB_MASTER );
|
|
}
|
|
$dbw->insert( 'text',
|
|
[
|
|
'old_text' => $text,
|
|
'old_flags' => implode( ',', $flags ),
|
|
], __METHOD__
|
|
);
|
|
|
|
return $dbw->insertId();
|
|
}
|
|
|
|
/**
|
|
* Retrieve a var dump from External Storage or the text table
|
|
* Some of this code is stolen from Revision::loadText et al
|
|
*
|
|
* @param string $stored_dump
|
|
*
|
|
* @return AbuseFilterVariableHolder
|
|
*/
|
|
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 );
|
|
return is_array( $data ) ? AbuseFilterVariableHolder::newFromArray( $data ) : $data;
|
|
}
|
|
|
|
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 );
|
|
|
|
$text_row = $dbr->selectRow(
|
|
'text',
|
|
[ 'old_text', 'old_flags' ],
|
|
[ 'old_id' => $text_id ],
|
|
__METHOD__
|
|
);
|
|
|
|
if ( !$text_row ) {
|
|
$logger = LoggerFactory::getInstance( 'AbuseFilter' );
|
|
$logger->warning( __METHOD__ . ": no text row found for input $stored_dump." );
|
|
return new AbuseFilterVariableHolder;
|
|
}
|
|
|
|
$flags = $text_row->old_flags === '' ? [] : explode( ',', $text_row->old_flags );
|
|
$text = $text_row->old_text;
|
|
|
|
if ( in_array( 'external', $flags ) ) {
|
|
$text = ExternalStore::fetchFromURL( $text );
|
|
}
|
|
|
|
if ( in_array( 'gzip', $flags ) ) {
|
|
$text = gzinflate( $text );
|
|
}
|
|
|
|
$obj = FormatJson::decode( $text, true );
|
|
if ( $obj === null ) {
|
|
// Temporary code until all rows will be JSON-encoded
|
|
$obj = unserialize( $text );
|
|
}
|
|
|
|
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 = AbuseFilterVariableHolder::newFromArray( $vars );
|
|
// If old variable names are used, make sure to keep them
|
|
if ( count( array_intersect_key( self::getDeprecatedVariables(), $obj->getVars() ) ) !== 0 ) {
|
|
$obj->mVarsVersion = 1;
|
|
}
|
|
}
|
|
|
|
return $obj;
|
|
}
|
|
|
|
/**
|
|
* Get an identifier for the given action to be used in self::$tagsToSet
|
|
*
|
|
* @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 getTaggingActionId( $action, Title $title, $username ) {
|
|
return implode(
|
|
'-',
|
|
[
|
|
$title->getPrefixedText(),
|
|
$username,
|
|
$action
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array[] $tagsByAction Map of (integer => string[])
|
|
*/
|
|
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 )
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
|
|
* @return string
|
|
*/
|
|
public static function getGlobalRulesKey( $group ) {
|
|
global $wgAbuseFilterIsCentral, $wgAbuseFilterCentralDB;
|
|
|
|
$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
|
|
if ( !$wgAbuseFilterIsCentral ) {
|
|
return $cache->makeGlobalKey( 'abusefilter', 'rules', $wgAbuseFilterCentralDB, $group );
|
|
}
|
|
|
|
return $cache->makeKey( 'abusefilter', 'rules', $group );
|
|
}
|
|
|
|
/**
|
|
* Gets the autopromotion block status for the given user
|
|
*
|
|
* @param User $target
|
|
* @return int
|
|
*/
|
|
public static function getAutoPromoteBlockStatus( User $target ) {
|
|
$store = ObjectCache::getInstance( 'db-replicated' );
|
|
|
|
return (int)$store->get( self::autoPromoteBlockKey( $store, $target ) );
|
|
}
|
|
|
|
/**
|
|
* Blocks autopromotion for the given user
|
|
*
|
|
* @param User $target
|
|
* @param string $msg The message to show in the log
|
|
* @return bool True on success, false on failure
|
|
*/
|
|
public static function blockAutoPromote( User $target, $msg ) {
|
|
$store = ObjectCache::getInstance( 'db-replicated' );
|
|
if ( !$store->set(
|
|
self::autoPromoteBlockKey( $store, $target ),
|
|
1,
|
|
self::BLOCKAUTOPROMOTE_DURATION
|
|
) ) {
|
|
// Failed to set key
|
|
$logger = LoggerFactory::getInstance( 'AbuseFilter' );
|
|
$logger->warning(
|
|
"Failed to block autopromotion for $target. Error: " . $store->getLastError()
|
|
);
|
|
return false;
|
|
}
|
|
|
|
$logEntry = new ManualLogEntry( 'rights', 'blockautopromote' );
|
|
$performer = self::getFilterUser();
|
|
$logEntry->setPerformer( $performer );
|
|
$logEntry->setTarget( $target->getUserPage() );
|
|
|
|
$logEntry->setParameters( [
|
|
'7::duration' => self::BLOCKAUTOPROMOTE_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;
|
|
}
|
|
|
|
/**
|
|
* Unblocks autopromotion for the given user
|
|
*
|
|
* @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;
|
|
}
|
|
|
|
$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 BagOStuff $store
|
|
* @param User $target
|
|
* @return string
|
|
* @internal
|
|
*/
|
|
public static function autoPromoteBlockKey( BagOStuff $store, User $target ) {
|
|
return $store->makeKey( 'abusefilter', 'block-autopromote', $target->getId() );
|
|
}
|
|
|
|
/**
|
|
* @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( $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 $value[$group] ?? $value['default'];
|
|
}
|
|
|
|
/**
|
|
* Get the memcache access key used to store per-filter profiling data.
|
|
*
|
|
* @param string|int $filter
|
|
* @return string
|
|
*/
|
|
public static function filterProfileKey( $filter ) {
|
|
$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
|
|
return $cache->makeKey( 'abusefilter-profile', 'v3', $filter );
|
|
}
|
|
|
|
/**
|
|
* Memcache access key used to store overall profiling data for rule groups
|
|
*
|
|
* @param string $group
|
|
* @return string
|
|
*/
|
|
public static function filterProfileGroupKey( $group ) {
|
|
$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
|
|
return $cache->makeKey( 'abusefilter-profile', 'group', $group );
|
|
}
|
|
|
|
/**
|
|
* @return User
|
|
*/
|
|
public static function getFilterUser() : User {
|
|
$username = wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text();
|
|
$user = User::newSystemUser( $username, [ 'steal' => true ] );
|
|
|
|
if ( !$user ) {
|
|
// User name is invalid. Don't throw because this is a system message, easy
|
|
// to change and make wrong either by mistake or intentionally to break the site.
|
|
wfWarn(
|
|
'The AbuseFilter user\'s name is invalid. Please change it in ' .
|
|
'MediaWiki:abusefilter-blocker'
|
|
);
|
|
// Use the default name to avoid breaking other stuff. This should have no harm,
|
|
// aside from blocks temporarily attributed to another user.
|
|
$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
|
|
if ( !in_array( 'sysop', $user->getGroups() ) ) {
|
|
$user->addGroup( 'sysop' );
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* Extract values for syntax highlight
|
|
*
|
|
* @param bool $canEdit
|
|
* @return array
|
|
*/
|
|
public static function getAceConfig( $canEdit ) {
|
|
$values = self::getBuilderValues();
|
|
$deprecatedVars = self::getDeprecatedVariables();
|
|
|
|
$builderVariables = implode( '|', array_keys( $values['vars'] ) );
|
|
$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 );
|
|
// Extract operators from tokenizer like we do in AbuseFilterParserTest
|
|
$operators = implode( '|', array_map( function ( $op ) {
|
|
return preg_quote( $op, '/' );
|
|
}, AbuseFilterTokenizer::OPERATORS ) );
|
|
$deprecatedVariables = implode( '|', array_keys( $deprecatedVars ) );
|
|
$disabledVariables = implode( '|', array_keys( self::DISABLED_VARS ) );
|
|
|
|
return [
|
|
'variables' => $builderVariables,
|
|
'functions' => $builderFunctions,
|
|
'keywords' => $builderKeywords,
|
|
'operators' => $operators,
|
|
'deprecated' => $deprecatedVariables,
|
|
'disabled' => $disabledVariables,
|
|
'aceReadOnly' => !$canEdit
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check whether a filter is allowed to use a tag
|
|
*
|
|
* @param string $tag Tag name
|
|
* @return Status
|
|
*/
|
|
public static function isAllowedTag( $tag ) {
|
|
$tagNameStatus = ChangeTags::isTagNameValid( $tag );
|
|
|
|
if ( !$tagNameStatus->isGood() ) {
|
|
return $tagNameStatus;
|
|
}
|
|
|
|
$finalStatus = Status::newGood();
|
|
|
|
$canAddStatus =
|
|
ChangeTags::canAddTagsAccompanyingChange(
|
|
[ $tag ]
|
|
);
|
|
|
|
if ( $canAddStatus->isGood() ) {
|
|
return $finalStatus;
|
|
}
|
|
|
|
if ( $tag === 'abusefilter-condition-limit' ) {
|
|
$finalStatus->fatal( 'abusefilter-tag-reserved' );
|
|
return $finalStatus;
|
|
}
|
|
|
|
$alreadyDefinedTags = [];
|
|
AbuseFilterHooks::onListDefinedTags( $alreadyDefinedTags );
|
|
|
|
if ( in_array( $tag, $alreadyDefinedTags, true ) ) {
|
|
return $finalStatus;
|
|
}
|
|
|
|
$canCreateTagStatus = ChangeTags::canCreateTag( $tag );
|
|
if ( $canCreateTagStatus->isGood() ) {
|
|
return $finalStatus;
|
|
}
|
|
|
|
$finalStatus->fatal( 'abusefilter-edit-bad-tags' );
|
|
return $finalStatus;
|
|
}
|
|
|
|
/**
|
|
* Validate throttle parameters
|
|
*
|
|
* @param array $params Throttle parameters
|
|
* @return null|string Null on success, a string with the error message on failure
|
|
*/
|
|
public static function checkThrottleParameters( $params ) {
|
|
list( $throttleCount, $throttlePeriod ) = explode( ',', $params[1], 2 );
|
|
$throttleGroups = array_slice( $params, 2 );
|
|
$validGroups = [
|
|
'ip',
|
|
'user',
|
|
'range',
|
|
'creationdate',
|
|
'editcount',
|
|
'site',
|
|
'page'
|
|
];
|
|
|
|
$error = null;
|
|
if ( preg_match( '/^[1-9][0-9]*$/', $throttleCount ) === 0 ) {
|
|
$error = 'abusefilter-edit-invalid-throttlecount';
|
|
} elseif ( preg_match( '/^[1-9][0-9]*$/', $throttlePeriod ) === 0 ) {
|
|
$error = 'abusefilter-edit-invalid-throttleperiod';
|
|
} elseif ( !$throttleGroups ) {
|
|
$error = 'abusefilter-edit-empty-throttlegroups';
|
|
} else {
|
|
$valid = true;
|
|
// Groups should be unique in three ways: no direct duplicates like 'user' and 'user',
|
|
// no duplicated subgroups, not even shuffled ('ip,user' and 'user,ip') and no duplicates
|
|
// within subgroups ('user,ip,user')
|
|
$uniqueGroups = [];
|
|
$uniqueSubGroups = true;
|
|
// Every group should be valid, and subgroups should have valid groups inside
|
|
foreach ( $throttleGroups as $group ) {
|
|
if ( strpos( $group, ',' ) !== false ) {
|
|
$subGroups = explode( ',', $group );
|
|
if ( $subGroups !== array_unique( $subGroups ) ) {
|
|
$uniqueSubGroups = false;
|
|
break;
|
|
}
|
|
foreach ( $subGroups as $subGroup ) {
|
|
if ( !in_array( $subGroup, $validGroups ) ) {
|
|
$valid = false;
|
|
break 2;
|
|
}
|
|
}
|
|
sort( $subGroups );
|
|
$uniqueGroups[] = implode( ',', $subGroups );
|
|
} else {
|
|
if ( !in_array( $group, $validGroups ) ) {
|
|
$valid = false;
|
|
break;
|
|
}
|
|
$uniqueGroups[] = $group;
|
|
}
|
|
}
|
|
|
|
if ( !$valid ) {
|
|
$error = 'abusefilter-edit-invalid-throttlegroups';
|
|
} elseif ( !$uniqueSubGroups || $uniqueGroups !== array_unique( $uniqueGroups ) ) {
|
|
$error = 'abusefilter-edit-duplicated-throttlegroups';
|
|
}
|
|
}
|
|
|
|
return $error;
|
|
}
|
|
|
|
/**
|
|
* 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 ContextSource $context
|
|
* @param int|string $filter
|
|
* @param stdClass $newRow
|
|
* @param array $actions
|
|
* @param IDatabase $dbw DB_MASTER Where the filter should be saved
|
|
* @return Status
|
|
*/
|
|
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' ) );
|
|
if ( $syntaxerr !== true ) {
|
|
$validationStatus->error( 'abusefilter-edit-badsyntax', $syntaxerr[0] );
|
|
return $validationStatus;
|
|
}
|
|
// Check for missing required fields (title and pattern)
|
|
$missing = [];
|
|
if ( !$request->getVal( 'wpFilterRules' ) ||
|
|
trim( $request->getVal( 'wpFilterRules' ) ) === '' ) {
|
|
$missing[] = $context->msg( 'abusefilter-edit-field-conditions' )->escaped();
|
|
}
|
|
if ( !$request->getVal( 'wpFilterDescription' ) ) {
|
|
$missing[] = $context->msg( 'abusefilter-edit-field-description' )->escaped();
|
|
}
|
|
if ( count( $missing ) !== 0 ) {
|
|
$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' ) && $request->getCheck( 'wpFilterDeleted' ) ) {
|
|
$validationStatus->error( 'abusefilter-edit-deleting-enabled' );
|
|
return $validationStatus;
|
|
}
|
|
|
|
// If we've activated the 'tag' option, check the arguments for validity.
|
|
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() ) {
|
|
$err = $status->getErrors();
|
|
$msg = $err[0]['message'];
|
|
$validationStatus->error( $msg );
|
|
return $validationStatus;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 ( 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 ],
|
|
$availableActions
|
|
);
|
|
|
|
// Don't allow adding a new global rule, or updating a
|
|
// rule that is currently global, without permissions.
|
|
if (
|
|
!self::canEditFilter( $user, $newRow ) ||
|
|
!self::canEditFilter( $user, $newRow->mOriginalRow )
|
|
) {
|
|
$validationStatus->fatal( 'abusefilter-edit-notallowed-global' );
|
|
return $validationStatus;
|
|
}
|
|
|
|
// Don't allow custom messages on global rules
|
|
if ( $newRow->af_global == 1 && (
|
|
$request->getVal( 'wpFilterWarnMessage' ) !== 'abusefilter-warning' ||
|
|
$request->getVal( 'wpFilterDisallowMessage' ) !== 'abusefilter-disallowed'
|
|
) ) {
|
|
$validationStatus->fatal( 'abusefilter-edit-notallowed-global-custom-msg' );
|
|
return $validationStatus;
|
|
}
|
|
|
|
$origActions = $newRow->mOriginalActions;
|
|
$wasGlobal = (bool)$newRow->mOriginalRow->af_global;
|
|
|
|
unset( $newRow->mOriginalRow );
|
|
unset( $newRow->mOriginalActions );
|
|
|
|
// Check for non-changes
|
|
if ( !count( $differences ) ) {
|
|
$validationStatus->setResult( true, false );
|
|
return $validationStatus;
|
|
}
|
|
|
|
// Check for restricted actions
|
|
$restrictions = $context->getConfig()->get( 'AbuseFilterRestrictions' );
|
|
if ( count( array_intersect_key(
|
|
array_filter( $restrictions ),
|
|
array_merge( $actions, $origActions )
|
|
) )
|
|
&& !MediaWikiServices::getInstance()->getPermissionManager()
|
|
->userHasRight( $user, 'abusefilter-modify-restricted' )
|
|
) {
|
|
$validationStatus->error( 'abusefilter-edit-restricted' );
|
|
return $validationStatus;
|
|
}
|
|
|
|
// Everything went fine, so let's save the filter
|
|
list( $new_id, $history_id ) =
|
|
self::doSaveFilter( $newRow, $differences, $filter, $actions, $wasGlobal, $context, $dbw );
|
|
$validationStatus->setResult( true, [ $new_id, $history_id ] );
|
|
return $validationStatus;
|
|
}
|
|
|
|
/**
|
|
* Saves new filter's info to DB
|
|
*
|
|
* @param stdClass $newRow
|
|
* @param array $differences
|
|
* @param int|string $filter
|
|
* @param array $actions
|
|
* @param bool $wasGlobal
|
|
* @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(
|
|
$newRow,
|
|
$differences,
|
|
$filter,
|
|
$actions,
|
|
$wasGlobal,
|
|
ContextSource $context,
|
|
IDatabase $dbw
|
|
) {
|
|
$user = $context->getUser();
|
|
|
|
// Convert from object to array
|
|
$newRow = get_object_vars( $newRow );
|
|
|
|
// Set last modifier.
|
|
$newRow['af_timestamp'] = $dbw->timestamp();
|
|
$newRow['af_user'] = $user->getId();
|
|
$newRow['af_user_text'] = $user->getName();
|
|
|
|
$dbw->startAtomic( __METHOD__ );
|
|
|
|
// Insert MAIN row.
|
|
if ( $filter === 'new' ) {
|
|
$new_id = null;
|
|
$is_new = true;
|
|
} else {
|
|
$new_id = $filter;
|
|
$is_new = false;
|
|
}
|
|
|
|
// Reset throttled marker, if we're re-enabling it.
|
|
$newRow['af_throttled'] = $newRow['af_throttled'] && !$newRow['af_enabled'];
|
|
$newRow['af_id'] = $new_id;
|
|
|
|
// T67807: integer 1's & 0's might be better understood than booleans
|
|
$newRow['af_enabled'] = (int)$newRow['af_enabled'];
|
|
$newRow['af_hidden'] = (int)$newRow['af_hidden'];
|
|
$newRow['af_throttled'] = (int)$newRow['af_throttled'];
|
|
$newRow['af_deleted'] = (int)$newRow['af_deleted'];
|
|
$newRow['af_global'] = (int)$newRow['af_global'];
|
|
|
|
$dbw->replace( 'abuse_filter', 'af_id', $newRow, __METHOD__ );
|
|
|
|
if ( $is_new ) {
|
|
$new_id = $dbw->insertId();
|
|
}
|
|
'@phan-var int $new_id';
|
|
|
|
$availableActions = $context->getConfig()->get( 'AbuseFilterActions' );
|
|
$actionsRows = [];
|
|
foreach ( array_filter( $availableActions ) as $action => $_ ) {
|
|
// Check if it's set
|
|
$enabled = isset( $actions[$action] );
|
|
|
|
if ( $enabled ) {
|
|
$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
|
|
// it, a maintenance script will be necessary to clean up the table.
|
|
$parameters[0] = $new_id;
|
|
}
|
|
|
|
$thisRow = [
|
|
'afa_filter' => $new_id,
|
|
'afa_consequence' => $action,
|
|
'afa_parameters' => implode( "\n", $parameters )
|
|
];
|
|
$actionsRows[] = $thisRow;
|
|
}
|
|
}
|
|
|
|
// Create a history row
|
|
$afh_row = [];
|
|
|
|
foreach ( self::HISTORY_MAPPINGS as $af_col => $afh_col ) {
|
|
$afh_row[$afh_col] = $newRow[$af_col];
|
|
}
|
|
|
|
$afh_row['afh_actions'] = serialize( $actions );
|
|
|
|
$afh_row['afh_changed_fields'] = implode( ',', $differences );
|
|
|
|
$flags = [];
|
|
if ( $newRow['af_hidden'] ) {
|
|
$flags[] = 'hidden';
|
|
}
|
|
if ( $newRow['af_enabled'] ) {
|
|
$flags[] = 'enabled';
|
|
}
|
|
if ( $newRow['af_deleted'] ) {
|
|
$flags[] = 'deleted';
|
|
}
|
|
if ( $newRow['af_global'] ) {
|
|
$flags[] = 'global';
|
|
}
|
|
|
|
$afh_row['afh_flags'] = implode( ',', $flags );
|
|
|
|
$afh_row['afh_filter'] = $new_id;
|
|
|
|
// Do the update
|
|
$dbw->insert( 'abuse_filter_history', $afh_row, __METHOD__ );
|
|
$history_id = $dbw->insertId();
|
|
if ( $filter !== 'new' ) {
|
|
$dbw->delete(
|
|
'abuse_filter_action',
|
|
[ 'afa_filter' => $filter ],
|
|
__METHOD__
|
|
);
|
|
}
|
|
$dbw->insert( 'abuse_filter_action', $actionsRows, __METHOD__ );
|
|
|
|
$dbw->endAtomic( __METHOD__ );
|
|
|
|
// Invalidate cache if this was a global rule
|
|
if ( $wasGlobal || $newRow['af_global'] ) {
|
|
$group = 'default';
|
|
if ( isset( $newRow['af_group'] ) && $newRow['af_group'] !== '' ) {
|
|
$group = $newRow['af_group'];
|
|
}
|
|
|
|
$globalRulesKey = self::getGlobalRulesKey( $group );
|
|
MediaWikiServices::getInstance()->getMainWANObjectCache()->touchCheckKey( $globalRulesKey );
|
|
}
|
|
|
|
// Logging
|
|
$subtype = $filter === 'new' ? 'create' : 'modify';
|
|
$logEntry = new ManualLogEntry( 'abusefilter', $subtype );
|
|
$logEntry->setPerformer( $user );
|
|
$logEntry->setTarget(
|
|
SpecialPage::getTitleFor( SpecialAbuseFilter::PAGE_NAME, (string)$new_id )
|
|
);
|
|
$logEntry->setParameters( [
|
|
'historyId' => $history_id,
|
|
'newId' => $new_id
|
|
] );
|
|
$logid = $logEntry->insert( $dbw );
|
|
$logEntry->publish( $logid );
|
|
|
|
// Purge the tag list cache so the fetchAllTags hook applies tag changes
|
|
if ( isset( $actions['tag'] ) ) {
|
|
AbuseFilterHooks::purgeTagCache();
|
|
}
|
|
|
|
self::resetFilterProfile( $new_id );
|
|
return [ $new_id, $history_id ];
|
|
}
|
|
|
|
/**
|
|
* Each version is expected to be an array( $row, $actions )
|
|
* Returns an array of fields that are different.
|
|
*
|
|
* @param array $version_1
|
|
* @param array $version_2
|
|
* @param string[] $availableActions All actions enabled in the AF config
|
|
*
|
|
* @return array
|
|
*/
|
|
public static function compareVersions(
|
|
array $version_1,
|
|
array $version_2,
|
|
array $availableActions
|
|
) {
|
|
$compareFields = [
|
|
'af_public_comments',
|
|
'af_pattern',
|
|
'af_comments',
|
|
'af_deleted',
|
|
'af_enabled',
|
|
'af_hidden',
|
|
'af_global',
|
|
'af_group',
|
|
];
|
|
$differences = [];
|
|
|
|
list( $row1, $actions1 ) = $version_1;
|
|
list( $row2, $actions2 ) = $version_2;
|
|
|
|
foreach ( $compareFields as $field ) {
|
|
if ( !isset( $row2->$field ) || $row1->$field != $row2->$field ) {
|
|
$differences[] = $field;
|
|
}
|
|
}
|
|
|
|
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], $actions2[$action] ) ||
|
|
array_diff( $actions2[$action], $actions1[$action] ) ) {
|
|
// Different parameters
|
|
$differences[] = 'actions';
|
|
}
|
|
} else {
|
|
// One's unset, one's set.
|
|
$differences[] = 'actions';
|
|
}
|
|
}
|
|
|
|
return array_unique( $differences );
|
|
}
|
|
|
|
/**
|
|
* @param stdClass $row
|
|
* @return array
|
|
*/
|
|
public static function translateFromHistory( $row ) {
|
|
// Manually translate into an abuse_filter row.
|
|
$af_row = new stdClass;
|
|
|
|
foreach ( self::HISTORY_MAPPINGS as $af_col => $afh_col ) {
|
|
$af_row->$af_col = $row->$afh_col;
|
|
}
|
|
|
|
// Process flags
|
|
$af_row->af_deleted = 0;
|
|
$af_row->af_hidden = 0;
|
|
$af_row->af_enabled = 0;
|
|
|
|
if ( $row->afh_flags !== '' ) {
|
|
$flags = explode( ',', $row->afh_flags );
|
|
foreach ( $flags as $flag ) {
|
|
$col_name = "af_$flag";
|
|
$af_row->$col_name = 1;
|
|
}
|
|
}
|
|
|
|
// Process actions
|
|
$actionsRaw = unserialize( $row->afh_actions );
|
|
$actionsOutput = is_array( $actionsRaw ) ? $actionsRaw : [];
|
|
|
|
return [ $af_row, $actionsOutput ];
|
|
}
|
|
|
|
/**
|
|
* @param string $action
|
|
* @return string HTML
|
|
*/
|
|
public static function getActionDisplay( $action ) {
|
|
// Give grep a chance to find the usages:
|
|
// abusefilter-action-tag, abusefilter-action-throttle, abusefilter-action-warn,
|
|
// abusefilter-action-blockautopromote, abusefilter-action-block, abusefilter-action-degroup,
|
|
// abusefilter-action-rangeblock, abusefilter-action-disallow
|
|
$display = wfMessage( "abusefilter-action-$action" )->escaped();
|
|
$display = wfMessage( "abusefilter-action-$action" )->rawParams( $display )->isDisabled()
|
|
? htmlspecialchars( $action )
|
|
: $display;
|
|
|
|
return $display;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $var
|
|
* @param string $indent
|
|
* @return string
|
|
*/
|
|
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 var_export( $var, true );
|
|
}
|
|
|
|
/**
|
|
* @param AbuseFilterVariableHolder|array $vars
|
|
* @param IContextSource $context
|
|
* @return string
|
|
*/
|
|
public static function buildVarDumpTable( $vars, IContextSource $context ) {
|
|
// Export all values
|
|
if ( $vars instanceof AbuseFilterVariableHolder ) {
|
|
$vars = $vars->exportAllVars();
|
|
}
|
|
|
|
$output = '';
|
|
|
|
// I don't want to change the names of the pre-existing messages
|
|
// describing the variables, nor do I want to rewrite them, so I'm just
|
|
// mapping the variable names to builder messages with a pre-existing array.
|
|
$variableMessageMappings = self::getBuilderValues();
|
|
$variableMessageMappings = $variableMessageMappings['vars'];
|
|
|
|
$output .=
|
|
Xml::openElement( 'table', [ 'class' => 'mw-abuselog-details' ] ) .
|
|
Xml::openElement( 'tbody' ) .
|
|
"\n";
|
|
|
|
$header =
|
|
Xml::element( 'th', null, $context->msg( 'abusefilter-log-details-var' )->text() ) .
|
|
Xml::element( 'th', null, $context->msg( 'abusefilter-log-details-val' )->text() );
|
|
$output .= Xml::tags( 'tr', null, $header ) . "\n";
|
|
|
|
if ( !count( $vars ) ) {
|
|
$output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' );
|
|
|
|
return $output;
|
|
}
|
|
|
|
// Now, build the body of the table.
|
|
$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() .
|
|
' ' . Html::element( 'code', [], $context->msg( 'parentheses' )->rawParams( $key )->text() );
|
|
} elseif ( !empty( self::DISABLED_VARS[$key] ) ) {
|
|
$mapping = self::DISABLED_VARS[$key];
|
|
$keyDisplay = $context->msg( "abusefilter-edit-builder-vars-$mapping" )->parse() .
|
|
' ' . Html::element( 'code', [], $context->msg( 'parentheses' )->rawParams( $key )->text() );
|
|
} else {
|
|
$keyDisplay = Html::element( 'code', [], $key );
|
|
}
|
|
|
|
if ( $value === null ) {
|
|
$value = '';
|
|
}
|
|
$value = Html::element(
|
|
'div',
|
|
[ 'class' => 'mw-abuselog-var-value' ],
|
|
self::formatVar( $value )
|
|
);
|
|
|
|
$trow =
|
|
Xml::tags( 'td', [ 'class' => 'mw-abuselog-var' ], $keyDisplay ) .
|
|
Xml::tags( 'td', [ 'class' => 'mw-abuselog-var-value' ], $value );
|
|
$output .=
|
|
Xml::tags( 'tr',
|
|
[ 'class' => "mw-abuselog-details-$key mw-abuselog-value" ], $trow
|
|
) . "\n";
|
|
}
|
|
|
|
$output .= Xml::closeElement( 'tbody' ) . Xml::closeElement( 'table' );
|
|
|
|
return $output;
|
|
}
|
|
|
|
/**
|
|
* @param string $action
|
|
* @param string[] $parameters
|
|
* @param Language $lang
|
|
* @return string
|
|
*/
|
|
public static function formatAction( $action, $parameters, $lang ) {
|
|
if ( count( $parameters ) === 0 ||
|
|
( $action === 'block' && count( $parameters ) !== 3 ) ) {
|
|
$displayAction = self::getActionDisplay( $action );
|
|
} else {
|
|
if ( $action === 'block' ) {
|
|
// Needs to be treated separately since the message is more complex
|
|
$messages = [
|
|
wfMessage( 'abusefilter-block-anon' )->escaped() .
|
|
wfMessage( 'colon-separator' )->escaped() .
|
|
$lang->translateBlockExpiry( $parameters[1] ),
|
|
wfMessage( 'abusefilter-block-user' )->escaped() .
|
|
wfMessage( 'colon-separator' )->escaped() .
|
|
$lang->translateBlockExpiry( $parameters[2] )
|
|
];
|
|
if ( $parameters[0] === 'blocktalk' ) {
|
|
$messages[] = wfMessage( 'abusefilter-block-talk' )->escaped();
|
|
}
|
|
$displayAction = $lang->commaList( $messages );
|
|
} elseif ( $action === 'throttle' ) {
|
|
array_shift( $parameters );
|
|
list( $actions, $time ) = explode( ',', array_shift( $parameters ) );
|
|
|
|
// 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 = $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() .
|
|
$lang->semicolonList( array_map( 'htmlspecialchars', $parameters ) );
|
|
}
|
|
}
|
|
|
|
return $displayAction;
|
|
}
|
|
|
|
/**
|
|
* @param string $value
|
|
* @param Language $lang
|
|
* @return string
|
|
*/
|
|
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 $lang->commaList( $flags_display );
|
|
}
|
|
|
|
/**
|
|
* @param int $filterID
|
|
* @return string
|
|
*/
|
|
public static function getGlobalFilterDescription( $filterID ) {
|
|
global $wgAbuseFilterCentralDB;
|
|
|
|
if ( !$wgAbuseFilterCentralDB ) {
|
|
return '';
|
|
}
|
|
|
|
static $cache = [];
|
|
if ( isset( $cache[$filterID] ) ) {
|
|
return $cache[$filterID];
|
|
}
|
|
|
|
$fdb = self::getCentralDB( DB_REPLICA );
|
|
|
|
$cache[$filterID] = $fdb->selectField(
|
|
'abuse_filter',
|
|
'af_public_comments',
|
|
[ 'af_id' => $filterID ],
|
|
__METHOD__
|
|
);
|
|
|
|
return $cache[$filterID];
|
|
}
|
|
|
|
/**
|
|
* Gives either the user-specified name for a group,
|
|
* or spits the input back out
|
|
* @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
|
|
* @return string A name for that filter group, or the input.
|
|
*/
|
|
public static function nameGroup( $group ) {
|
|
// Give grep a chance to find the usages: abusefilter-group-default
|
|
$msg = "abusefilter-group-$group";
|
|
|
|
return wfMessage( $msg )->exists() ? wfMessage( $msg )->escaped() : $group;
|
|
}
|
|
|
|
/**
|
|
* Look up some text of a revision from its revision id
|
|
*
|
|
* Note that this is really *some* text, we do not make *any* guarantee
|
|
* that this text will be even close to what the user actually sees, or
|
|
* that the form is fit for any intended purpose.
|
|
*
|
|
* Note also that if the revision for any reason is not an Revision
|
|
* the function returns with an empty string.
|
|
*
|
|
* 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( ?RevisionRecord $revision, User $user ) {
|
|
if ( !$revision ) {
|
|
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 = implode( "\n\n", $strings );
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Converts the given Content object to a string.
|
|
*
|
|
* This uses Content::getNativeData() if $content is an instance of TextContent,
|
|
* or Content::getTextForSearchIndex() otherwise.
|
|
*
|
|
* The hook 'AbuseFilter::contentToString' can be used to override this
|
|
* behavior.
|
|
*
|
|
* @internal
|
|
* @todo Move elsewhere. VariableGenerator is a good candidate
|
|
*
|
|
* @param Content $content
|
|
*
|
|
* @return string a suitable string representation of the content.
|
|
*/
|
|
public static function contentToString( Content $content ) {
|
|
$text = null;
|
|
|
|
if ( Hooks::run( 'AbuseFilter-contentToString', [ $content, &$text ] ) ) {
|
|
$text = $content instanceof TextContent
|
|
? $content->getText()
|
|
: $content->getTextForSearchIndex();
|
|
}
|
|
|
|
// T22310
|
|
$text = TextContent::normalizeLineEndings( (string)$text );
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* Get the history ID of the first change to a given filter
|
|
*
|
|
* @param int $filterID Filter id
|
|
* @return string
|
|
*/
|
|
public static function getFirstFilterChange( $filterID ) {
|
|
static $firstChanges = [];
|
|
|
|
if ( !isset( $firstChanges[$filterID] ) ) {
|
|
$dbr = wfGetDB( DB_REPLICA );
|
|
$historyID = $dbr->selectField(
|
|
'abuse_filter_history',
|
|
'afh_id',
|
|
[
|
|
'afh_filter' => $filterID,
|
|
],
|
|
__METHOD__,
|
|
[ 'ORDER BY' => 'afh_timestamp ASC' ]
|
|
);
|
|
$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' );
|
|
return new $wgAbuseFilterParserClass( $contLang, $cache, $logger, $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
|
|
);
|
|
}
|
|
}
|