Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.71% covered (warning)
85.71%
102 / 119
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterStore
85.71% covered (warning)
85.71%
102 / 119
75.00% covered (warning)
75.00%
3 / 4
29.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveFilter
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 doSaveFilter
81.32% covered (warning)
81.32%
74 / 91
0.00% covered (danger)
0.00%
0 / 1
23.88
 filterToDatabaseRow
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagsManager;
6use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
7use MediaWiki\Extension\AbuseFilter\Filter\Filter;
8use MediaWiki\Extension\AbuseFilter\Filter\Flags;
9use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter;
10use MediaWiki\Logging\ManualLogEntry;
11use MediaWiki\Permissions\Authority;
12use MediaWiki\Status\Status;
13use MediaWiki\User\ActorNormalization;
14use MediaWiki\User\UserIdentity;
15use Wikimedia\Rdbms\LBFactory;
16
17/**
18 * @internal
19 */
20class FilterStore {
21    public const SERVICE_NAME = 'AbuseFilterFilterStore';
22
23    public function __construct(
24        private readonly ConsequencesRegistry $consequencesRegistry,
25        private readonly LBFactory $lbFactory,
26        private readonly ActorNormalization $actorNormalization,
27        private readonly FilterProfiler $filterProfiler,
28        private readonly FilterLookup $filterLookup,
29        private readonly ChangeTagsManager $tagsManager,
30        private readonly FilterValidator $filterValidator,
31        private readonly FilterCompare $filterCompare,
32        private readonly EmergencyCache $emergencyCache
33    ) {
34    }
35
36    /**
37     * Checks whether user input for the filter editing form is valid and if so saves the filter.
38     * Returns a Status object which can be:
39     *  - Good with [ new_filter_id, history_id ] as value if the filter was successfully saved
40     *  - Good with value = false if everything went fine but the filter is unchanged
41     *  - OK with errors if a validation error occurred
42     *  - Fatal in case of a permission-related error
43     *
44     * @param Authority $performer
45     * @param int|null $filterId
46     * @param Filter $newFilter
47     * @param Filter $originalFilter
48     * @return Status
49     */
50    public function saveFilter(
51        Authority $performer,
52        ?int $filterId,
53        Filter $newFilter,
54        Filter $originalFilter
55    ): Status {
56        $validationStatus = $this->filterValidator->checkAll( $newFilter, $originalFilter, $performer );
57        if ( !$validationStatus->isGood() ) {
58            return $validationStatus;
59        }
60
61        // Check for non-changes
62        $differences = $this->filterCompare->compareVersions( $newFilter, $originalFilter );
63        if ( !$differences ) {
64            return Status::newGood( false );
65        }
66
67        // Everything went fine, so let's save the filter
68        [ $newID, $historyID ] = $this->doSaveFilter(
69            $performer->getUser(), $newFilter, $originalFilter, $differences, $filterId );
70        return Status::newGood( [ $newID, $historyID ] );
71    }
72
73    /**
74     * Saves new filter's info to DB
75     *
76     * @param UserIdentity $userIdentity
77     * @param Filter $newFilter
78     * @param Filter $originalFilter
79     * @param array $differences
80     * @param int|null $filterId
81     * @return int[] first element is new ID, second is history ID
82     */
83    private function doSaveFilter(
84        UserIdentity $userIdentity,
85        Filter $newFilter,
86        Filter $originalFilter,
87        array $differences,
88        ?int $filterId
89    ): array {
90        $dbw = $this->lbFactory->getPrimaryDatabase();
91        $newRow = $this->filterToDatabaseRow( $newFilter, $originalFilter );
92
93        // Set last modifier.
94        $newRow['af_timestamp'] = $dbw->timestamp();
95        $newRow['af_actor'] = $this->actorNormalization->acquireActorId( $userIdentity, $dbw );
96
97        $isNew = $filterId === null;
98
99        // Preserve the old throttled status (if any) only if disabling the filter.
100        // TODO: It might make more sense to check what was actually changed
101        $newRow['af_throttled'] = ( $newRow['af_throttled'] ?? false ) && !$newRow['af_enabled'];
102        // This is null when creating a new filter, but the DB field is NOT NULL
103        $newRow['af_hit_count'] ??= 0;
104        $rowForInsert = array_diff_key( $newRow, [ 'af_id' => true ] );
105
106        $dbw->startAtomic( __METHOD__ );
107        if ( $filterId === null ) {
108            $dbw->newInsertQueryBuilder()
109                ->insertInto( 'abuse_filter' )
110                ->row( $rowForInsert )
111                ->caller( __METHOD__ )
112                ->execute();
113            $filterId = $dbw->insertId();
114        } else {
115            $dbw->newUpdateQueryBuilder()
116                ->update( 'abuse_filter' )
117                ->set( $rowForInsert )
118                ->where( [ 'af_id' => $filterId ] )
119                ->caller( __METHOD__ )
120                ->execute();
121        }
122        $newRow['af_id'] = $filterId;
123
124        $actions = $newFilter->getActions();
125        $actionsRows = [];
126        foreach ( $this->consequencesRegistry->getAllEnabledActionNames() as $action ) {
127            if ( !isset( $actions[$action] ) ) {
128                continue;
129            }
130
131            $parameters = $actions[$action];
132            if ( $action === 'throttle' && $parameters[0] === null ) {
133                // FIXME: Do we really need to keep the filter ID inside throttle parameters?
134                // We'd save space, keep things simpler and avoid this hack. Note: if removing
135                // it, a maintenance script will be necessary to clean up the table.
136                $parameters[0] = $filterId;
137            }
138
139            $actionsRows[] = [
140                'afa_filter' => $filterId,
141                'afa_consequence' => $action,
142                'afa_parameters' => implode( "\n", $parameters ),
143            ];
144        }
145
146        // Create a history row
147        $afhRow = [];
148
149        foreach ( AbuseFilter::HISTORY_MAPPINGS as $afCol => $afhCol ) {
150            // Some fields are expected to be missing during actor migration
151            if ( isset( $newRow[$afCol] ) ) {
152                $afhRow[$afhCol] = $newRow[$afCol];
153            }
154        }
155
156        $afhRow['afh_actions'] = serialize( $actions );
157
158        $afhRow['afh_changed_fields'] = implode( ',', $differences );
159
160        $flags = [];
161        if ( FilterUtils::isHidden( $newRow['af_hidden'] ) ) {
162            $flags[] = 'hidden';
163        }
164        if ( FilterUtils::isProtected( $newRow['af_hidden'] ) ) {
165            $flags[] = 'protected';
166        }
167        if ( $newRow['af_enabled'] ) {
168            $flags[] = 'enabled';
169        }
170        if ( $newRow['af_deleted'] ) {
171            $flags[] = 'deleted';
172        }
173        if ( $newRow['af_global'] ) {
174            $flags[] = 'global';
175        }
176
177        $afhRow['afh_flags'] = implode( ',', $flags );
178
179        $afhRow['afh_filter'] = $filterId;
180
181        // Do the update
182        $dbw->newInsertQueryBuilder()
183            ->insertInto( 'abuse_filter_history' )
184            ->row( $afhRow )
185            ->caller( __METHOD__ )
186            ->execute();
187        $historyID = $dbw->insertId();
188        if ( !$isNew ) {
189            $dbw->newDeleteQueryBuilder()
190                ->deleteFrom( 'abuse_filter_action' )
191                ->where( [ 'afa_filter' => $filterId ] )
192                ->caller( __METHOD__ )
193                ->execute();
194        }
195        if ( $actionsRows ) {
196            $dbw->newInsertQueryBuilder()
197                ->insertInto( 'abuse_filter_action' )
198                ->rows( $actionsRows )
199                ->caller( __METHOD__ )
200                ->execute();
201        }
202
203        $dbw->endAtomic( __METHOD__ );
204
205        // Invalidate cache if this was a global rule
206        if ( $originalFilter->isGlobal() || $newRow['af_global'] ) {
207            $this->filterLookup->purgeGroupWANCache( $newRow['af_group'] );
208        }
209
210        // Logging
211        $logEntry = new ManualLogEntry( 'abusefilter', $isNew ? 'create' : 'modify' );
212        $logEntry->setPerformer( $userIdentity );
213        $logEntry->setTarget( SpecialAbuseFilter::getTitleForSubpage( (string)$filterId ) );
214        $logEntry->setParameters( [
215            'historyId' => $historyID,
216            'newId' => $filterId
217        ] );
218        $logid = $logEntry->insert( $dbw );
219        $logEntry->publish( $logid );
220
221        // Purge the tag list cache so the fetchAllTags hook applies tag changes
222        if ( isset( $actions['tag'] ) ) {
223            $this->tagsManager->purgeTagCache();
224        }
225
226        $this->filterProfiler->resetFilterProfile( $filterId );
227        if ( $newRow['af_enabled'] ) {
228            $this->emergencyCache->setNewForFilter( $filterId, $newRow['af_group'] );
229        }
230        return [ $filterId, $historyID ];
231    }
232
233    /**
234     * @todo Perhaps add validation to ensure no null values remained.
235     * @note For simplicity, data about the last editor are omitted.
236     * @param Filter $filter
237     * @return array
238     */
239    private function filterToDatabaseRow( Filter $filter, Filter $originalFilter ): array {
240        // T67807: integer 1's & 0's might be better understood than booleans
241
242        // If the filter is already protected, it must remain protected even if
243        // the current filter doesn't use a protected variable anymore
244        // FIXME: Resposibility for this is currently unclear. It should be
245        // enforced prior to the FilterCompare::compareVersions call to avoid
246        // dummy filter versions.
247        $privacyLevel = $filter->getPrivacyLevel();
248        if ( $originalFilter->isProtected() ) {
249            $privacyLevel |= Flags::FILTER_USES_PROTECTED_VARS;
250        }
251
252        return [
253            'af_id' => $filter->getID(),
254            'af_pattern' => $filter->getRules(),
255            'af_public_comments' => $filter->getName(),
256            'af_comments' => $filter->getComments(),
257            'af_group' => $filter->getGroup(),
258            'af_actions' => implode( ',', $filter->getActionsNames() ),
259            'af_enabled' => (int)$filter->isEnabled(),
260            'af_deleted' => (int)$filter->isDeleted(),
261            'af_hidden' => $privacyLevel,
262            'af_global' => (int)$filter->isGlobal(),
263            'af_timestamp' => $filter->getTimestamp(),
264            'af_hit_count' => $filter->getHitCount(),
265            'af_throttled' => (int)$filter->isThrottled(),
266        ];
267    }
268}