consequencesRegistry = $consequencesRegistry; $this->lbFactory = $lbFactory; $this->filterProfiler = $filterProfiler; $this->filterLookup = $filterLookup; $this->tagsManager = $tagsManager; $this->filterValidator = $filterValidator; $this->filterCompare = $filterCompare; $this->emergencyCache = $emergencyCache; $this->actorMigration = $actorMigration; } /** * 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 Authority $performer * @param int|null $filterId * @param Filter $newFilter * @param Filter $originalFilter * @return Status */ public function saveFilter( Authority $performer, ?int $filterId, Filter $newFilter, Filter $originalFilter ): Status { $validationStatus = $this->filterValidator->checkAll( $newFilter, $originalFilter, $performer ); if ( !$validationStatus->isGood() ) { return $validationStatus; } // Check for non-changes $differences = $this->filterCompare->compareVersions( $newFilter, $originalFilter ); if ( !$differences ) { return Status::newGood( false ); } // Everything went fine, so let's save the filter $wasGlobal = $originalFilter->isGlobal(); [ $newID, $historyID ] = $this->doSaveFilter( $performer->getUser(), $newFilter, $originalFilter, $differences, $filterId, $wasGlobal ); return Status::newGood( [ $newID, $historyID ] ); } /** * Saves new filter's info to DB * * @param UserIdentity $userIdentity * @param Filter $newFilter * @param Filter $originalFilter * @param array $differences * @param int|null $filterId * @param bool $wasGlobal * @return int[] first element is new ID, second is history ID */ private function doSaveFilter( UserIdentity $userIdentity, Filter $newFilter, Filter $originalFilter, array $differences, ?int $filterId, bool $wasGlobal ): array { $dbw = $this->lbFactory->getPrimaryDatabase(); $newRow = $this->filterToDatabaseRow( $newFilter, $originalFilter ); // Set last modifier. $newRow['af_timestamp'] = $dbw->timestamp(); $newRow += $this->actorMigration->getInsertValues( $dbw, 'af_user', $userIdentity ); $isNew = $filterId === null; // Preserve the old throttled status (if any) only if disabling the filter. // TODO: It might make more sense to check what was actually changed $newRow['af_throttled'] = ( $newRow['af_throttled'] ?? false ) && !$newRow['af_enabled']; // This is null when creating a new filter, but the DB field is NOT NULL $newRow['af_hit_count'] ??= 0; $rowForInsert = array_diff_key( $newRow, [ 'af_id' => true ] ); $dbw->startAtomic( __METHOD__ ); if ( $filterId === null ) { $dbw->newInsertQueryBuilder() ->insertInto( 'abuse_filter' ) ->row( $rowForInsert ) ->caller( __METHOD__ ) ->execute(); $filterId = $dbw->insertId(); } else { $dbw->newUpdateQueryBuilder() ->update( 'abuse_filter' ) ->set( $rowForInsert ) ->where( [ 'af_id' => $filterId ] ) ->caller( __METHOD__ ) ->execute(); } $newRow['af_id'] = $filterId; $actions = $newFilter->getActions(); $actionsRows = []; foreach ( $this->consequencesRegistry->getAllEnabledActionNames() as $action ) { if ( !isset( $actions[$action] ) ) { continue; } $parameters = $actions[$action]; if ( $action === 'throttle' && $parameters[0] === null ) { // 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] = $filterId; } $actionsRows[] = [ 'afa_filter' => $filterId, 'afa_consequence' => $action, 'afa_parameters' => implode( "\n", $parameters ), ]; } // Create a history row $afhRow = []; foreach ( AbuseFilter::HISTORY_MAPPINGS as $afCol => $afhCol ) { // Some fields are expected to be missing during actor migration if ( isset( $newRow[$afCol] ) ) { $afhRow[$afhCol] = $newRow[$afCol]; } } $afhRow['afh_actions'] = serialize( $actions ); $afhRow['afh_changed_fields'] = implode( ',', $differences ); $flags = []; if ( FilterUtils::isHidden( $newRow['af_hidden'] ) ) { $flags[] = 'hidden'; } if ( FilterUtils::isProtected( $newRow['af_hidden'] ) ) { $flags[] = 'protected'; } if ( $newRow['af_enabled'] ) { $flags[] = 'enabled'; } if ( $newRow['af_deleted'] ) { $flags[] = 'deleted'; } if ( $newRow['af_global'] ) { $flags[] = 'global'; } $afhRow['afh_flags'] = implode( ',', $flags ); $afhRow['afh_filter'] = $filterId; // Do the update $dbw->newInsertQueryBuilder() ->insertInto( 'abuse_filter_history' ) ->row( $afhRow ) ->caller( __METHOD__ ) ->execute(); $historyID = $dbw->insertId(); if ( !$isNew ) { $dbw->newDeleteQueryBuilder() ->deleteFrom( 'abuse_filter_action' ) ->where( [ 'afa_filter' => $filterId ] ) ->caller( __METHOD__ ) ->execute(); } if ( $actionsRows ) { $dbw->newInsertQueryBuilder() ->insertInto( 'abuse_filter_action' ) ->rows( $actionsRows ) ->caller( __METHOD__ ) ->execute(); } $dbw->endAtomic( __METHOD__ ); // Invalidate cache if this was a global rule if ( $wasGlobal || $newRow['af_global'] ) { $this->filterLookup->purgeGroupWANCache( $newRow['af_group'] ); } // Logging $logEntry = new ManualLogEntry( 'abusefilter', $isNew ? 'create' : 'modify' ); $logEntry->setPerformer( $userIdentity ); $logEntry->setTarget( SpecialAbuseFilter::getTitleForSubpage( (string)$filterId ) ); $logEntry->setParameters( [ 'historyId' => $historyID, 'newId' => $filterId ] ); $logid = $logEntry->insert( $dbw ); $logEntry->publish( $logid ); // Purge the tag list cache so the fetchAllTags hook applies tag changes if ( isset( $actions['tag'] ) ) { $this->tagsManager->purgeTagCache(); } $this->filterProfiler->resetFilterProfile( $filterId ); if ( $newRow['af_enabled'] ) { $this->emergencyCache->setNewForFilter( $filterId, $newRow['af_group'] ); } return [ $filterId, $historyID ]; } /** * @todo Perhaps add validation to ensure no null values remained. * @note For simplicity, data about the last editor are omitted. * @param Filter $filter * @return array */ private function filterToDatabaseRow( Filter $filter, Filter $originalFilter ): array { // T67807: integer 1's & 0's might be better understood than booleans // If the filter is already protected, it must remain protected even if // the current filter doesn't use a protected variable anymore $privacyLevel = $filter->getPrivacyLevel(); if ( $originalFilter->isProtected() ) { $privacyLevel |= Flags::FILTER_USES_PROTECTED_VARS; } return [ 'af_id' => $filter->getID(), 'af_pattern' => $filter->getRules(), 'af_public_comments' => $filter->getName(), 'af_comments' => $filter->getComments(), 'af_group' => $filter->getGroup(), 'af_actions' => implode( ',', $filter->getActionsNames() ), 'af_enabled' => (int)$filter->isEnabled(), 'af_deleted' => (int)$filter->isDeleted(), 'af_hidden' => $privacyLevel, 'af_global' => (int)$filter->isGlobal(), 'af_timestamp' => $filter->getTimestamp(), 'af_hit_count' => $filter->getHitCount(), 'af_throttled' => (int)$filter->isThrottled(), ]; } }