mediawiki-extensions-AbuseF.../includes/FilterRunner.php
2023-01-05 21:23:24 +00:00

414 lines
13 KiB
PHP

<?php
namespace MediaWiki\Extension\AbuseFilter;
use BadMethodCallException;
use DeferredUpdates;
use InvalidArgumentException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory;
use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Extension\AbuseFilter\Watcher\Watcher;
use Psr\Log\LoggerInterface;
use Status;
use Title;
use User;
/**
* 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.
* @internal Not stable yet
*/
class FilterRunner {
public const CONSTRUCTOR_OPTIONS = [
'AbuseFilterValidGroups',
'AbuseFilterCentralDB',
'AbuseFilterIsCentral',
'AbuseFilterConditionLimit',
];
/** @var AbuseFilterHookRunner */
private $hookRunner;
/** @var FilterProfiler */
private $filterProfiler;
/** @var ChangeTagger */
private $changeTagger;
/** @var FilterLookup */
private $filterLookup;
/** @var RuleCheckerFactory */
private $ruleCheckerFactory;
/** @var ConsequencesExecutorFactory */
private $consExecutorFactory;
/** @var AbuseLoggerFactory */
private $abuseLoggerFactory;
/** @var EmergencyCache */
private $emergencyCache;
/** @var Watcher[] */
private $watchers;
/** @var EditStashCache */
private $stashCache;
/** @var LoggerInterface */
private $logger;
/** @var VariablesManager */
private $varManager;
/** @var VariableGeneratorFactory */
private $varGeneratorFactory;
/** @var ServiceOptions */
private $options;
/**
* @var FilterEvaluator
*/
private $ruleChecker;
/**
* @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 VariableHolder 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;
/**
* @param AbuseFilterHookRunner $hookRunner
* @param FilterProfiler $filterProfiler
* @param ChangeTagger $changeTagger
* @param FilterLookup $filterLookup
* @param RuleCheckerFactory $ruleCheckerFactory
* @param ConsequencesExecutorFactory $consExecutorFactory
* @param AbuseLoggerFactory $abuseLoggerFactory
* @param VariablesManager $varManager
* @param VariableGeneratorFactory $varGeneratorFactory
* @param EmergencyCache $emergencyCache
* @param Watcher[] $watchers
* @param EditStashCache $stashCache
* @param LoggerInterface $logger
* @param ServiceOptions $options
* @param User $user
* @param Title $title
* @param VariableHolder $vars
* @param string $group
* @throws InvalidArgumentException If $group is invalid or the 'action' variable is unset
*/
public function __construct(
AbuseFilterHookRunner $hookRunner,
FilterProfiler $filterProfiler,
ChangeTagger $changeTagger,
FilterLookup $filterLookup,
RuleCheckerFactory $ruleCheckerFactory,
ConsequencesExecutorFactory $consExecutorFactory,
AbuseLoggerFactory $abuseLoggerFactory,
VariablesManager $varManager,
VariableGeneratorFactory $varGeneratorFactory,
EmergencyCache $emergencyCache,
array $watchers,
EditStashCache $stashCache,
LoggerInterface $logger,
ServiceOptions $options,
User $user,
Title $title,
VariableHolder $vars,
string $group
) {
$this->hookRunner = $hookRunner;
$this->filterProfiler = $filterProfiler;
$this->changeTagger = $changeTagger;
$this->filterLookup = $filterLookup;
$this->ruleCheckerFactory = $ruleCheckerFactory;
$this->consExecutorFactory = $consExecutorFactory;
$this->abuseLoggerFactory = $abuseLoggerFactory;
$this->varManager = $varManager;
$this->varGeneratorFactory = $varGeneratorFactory;
$this->emergencyCache = $emergencyCache;
$this->watchers = $watchers;
$this->stashCache = $stashCache;
$this->logger = $logger;
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
if ( !in_array( $group, $options->get( 'AbuseFilterValidGroups' ), true ) ) {
throw new InvalidArgumentException( "Group $group is not a valid group" );
}
$this->options = $options;
if ( !$vars->varIsSet( 'action' ) ) {
throw new InvalidArgumentException( "The 'action' variable is not set." );
}
$this->user = $user;
$this->title = $title;
$this->vars = $vars;
$this->group = $group;
$this->action = $vars->getComputedVariable( 'action' )->toString();
}
/**
* Inits variables and parser right before running
*/
private function init() {
// Add vars from extensions
$this->hookRunner->onAbuseFilter_filterAction(
$this->vars,
$this->title
);
$this->hookRunner->onAbuseFilterAlterVariables(
$this->vars,
$this->title,
$this->user
);
$generator = $this->varGeneratorFactory->newGenerator( $this->vars );
$this->vars = $generator->addGenericVars()->getVariableHolder();
$this->ruleChecker = $this->ruleCheckerFactory->newRuleChecker( $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 {
$this->init();
$skipReasons = [];
$shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
$this->vars, $this->title, $this->user, $skipReasons
);
if ( !$shouldFilter ) {
$this->logger->info(
'Skipping action {action}. Reasons provided: {reasons}',
[ 'action' => $this->action, 'reasons' => implode( ', ', $skipReasons ) ]
);
return Status::newGood();
}
$useStash = $allowStash && $this->action === 'edit';
$runnerData = null;
if ( $useStash ) {
$cacheData = $this->stashCache->seek( $this->vars );
if ( $cacheData !== false ) {
// Use cached vars (T176291) and profiling data (T191430)
$this->vars = VariableHolder::newFromArray( $cacheData['vars'] );
$runnerData = RunnerData::fromArray( $cacheData['data'] );
}
}
if ( $runnerData === null ) {
$runnerData = $this->checkAllFiltersInternal();
}
// hack until DI for DeferredUpdates is possible (T265749)
if ( defined( 'MW_PHPUNIT_TEST' ) ) {
$this->profileExecution( $runnerData );
$this->updateEmergencyCache( $runnerData->getMatchesMap() );
} else {
// @codeCoverageIgnoreStart
DeferredUpdates::addCallableUpdate( function () use ( $runnerData ) {
$this->profileExecution( $runnerData );
$this->updateEmergencyCache( $runnerData->getMatchesMap() );
} );
// @codeCoverageIgnoreEnd
}
// TODO: inject the action specifier to avoid this
$accountname = $this->varManager->getVar(
$this->vars,
'accountname',
VariablesManager::GET_BC
)->toNative();
$spec = new ActionSpecifier(
$this->action,
$this->title,
$this->user,
$this->user->getRequest()->getIP(),
$accountname
);
// Tag the action if the condition limit was hit
if ( $runnerData->getTotalConditions() > $this->options->get( 'AbuseFilterConditionLimit' ) ) {
$this->changeTagger->addConditionsLimitTag( $spec );
}
$matchedFilters = $runnerData->getMatchedFilters();
if ( count( $matchedFilters ) === 0 ) {
return Status::newGood();
}
$executor = $this->consExecutorFactory->newExecutor( $spec, $this->vars );
$status = $executor->executeFilterActions( $matchedFilters );
$actionsTaken = $status->getValue();
// Note, it's important that we create an AbuseLogger now, after all lazy-loaded variables
// requested by active filters have been computed
$abuseLogger = $this->abuseLoggerFactory->newLogger( $this->title, $this->user, $this->vars );
[
'local' => $loggedLocalFilters,
'global' => $loggedGlobalFilters
] = $abuseLogger->addLogEntries( $actionsTaken );
foreach ( $this->watchers as $watcher ) {
$watcher->run( $loggedLocalFilters, $loggedGlobalFilters, $this->group );
}
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();
}
// XXX: We need a copy here because the cache key is computed
// from the variables, but some variables can be loaded lazily
// which would store the data with a key distinct from that
// computed by seek() in ::run().
// TODO: Find better way to generate the cache key.
$origVars = clone $this->vars;
$runnerData = $this->checkAllFiltersInternal();
// Save the filter stash result and do nothing further
$cacheData = [
'vars' => $this->varManager->dumpAllVars( $this->vars ),
'data' => $runnerData->toArray(),
];
$this->stashCache->store( $origVars, $cacheData );
return Status::newGood();
}
/**
* Run all filters and return information about matches and profiling
*
* @return RunnerData
*/
protected function checkAllFiltersInternal(): RunnerData {
// Ensure there's no extra time leftover
LazyVariableComputer::$profilingExtraTime = 0;
$data = new RunnerData();
foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, false ) as $filter ) {
[ $status, $timeTaken ] = $this->checkFilter( $filter );
$data->record( $filter->getID(), false, $status, $timeTaken );
}
if ( $this->options->get( 'AbuseFilterCentralDB' ) && !$this->options->get( 'AbuseFilterIsCentral' ) ) {
foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, true ) as $filter ) {
[ $status, $timeTaken ] = $this->checkFilter( $filter, true );
$data->record( $filter->getID(), true, $status, $timeTaken );
}
}
return $data;
}
/**
* Returns an associative array of filters which were tripped
*
* @internal BC method
* @return bool[] Map of (filter ID => bool)
*/
public function checkAllFilters(): array {
$this->init();
return $this->checkAllFiltersInternal()->getMatchesMap();
}
/**
* Check the conditions of a single filter, and profile it
*
* @param ExistingFilter $filter
* @param bool $global
* @return array [ status, time taken ]
* @phan-return array{0:\MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerStatus,1:float}
*/
protected function checkFilter( ExistingFilter $filter, bool $global = false ): array {
$filterName = GlobalNameUtils::buildGlobalName( $filter->getID(), $global );
$startTime = microtime( true );
$origExtraTime = LazyVariableComputer::$profilingExtraTime;
$status = $this->ruleChecker->checkConditions( $filter->getRules(), $filterName );
$actualExtra = LazyVariableComputer::$profilingExtraTime - $origExtraTime;
$timeTaken = 1000 * ( microtime( true ) - $startTime - $actualExtra );
return [ $status, $timeTaken ];
}
/**
* @param RunnerData $data
*/
protected function profileExecution( RunnerData $data ) {
$allFilters = $data->getAllFilters();
$matchedFilters = $data->getMatchedFilters();
$this->filterProfiler->recordRuntimeProfilingResult(
count( $allFilters ),
$data->getTotalConditions(),
$data->getTotalRuntime()
);
$this->filterProfiler->recordPerFilterProfiling( $this->title, $data->getProfilingData() );
$this->filterProfiler->recordStats(
$this->group,
$data->getTotalConditions(),
$data->getTotalRuntime(),
(bool)$matchedFilters
);
}
/**
* @param bool[] $matches
*/
protected function updateEmergencyCache( array $matches ): void {
$filters = $this->emergencyCache->getFiltersToCheckInGroup( $this->group );
foreach ( $filters as $filter ) {
if ( array_key_exists( "$filter", $matches ) ) {
$this->emergencyCache->incrementForFilter( $filter, $matches["$filter"] );
}
}
}
}