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; $this->deferredUpdatesManager = $deferredUpdatesManager; 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'] ); } } $runnerData ??= $this->checkAllFiltersInternal(); $this->deferredUpdatesManager->addCallableUpdate( function () use ( $runnerData ) { $this->profileExecution( $runnerData ); $this->updateEmergencyCache( $runnerData->getMatchesMap() ); } ); // 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 */ private 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} */ private 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 */ private 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 */ private 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"] ); } } } }