Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.12% covered (warning)
85.12%
103 / 121
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterStore
85.12% covered (warning)
85.12%
103 / 121
75.00% covered (warning)
75.00%
3 / 4
30.58
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
80.65% covered (warning)
80.65%
75 / 93
0.00% covered (danger)
0.00%
0 / 1
25.51
 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::isSuppressed( $newRow['af_hidden'] ) ) {
162            $flags[] = 'suppressed';
163        }
164        if ( FilterUtils::isHidden( $newRow['af_hidden'] ) ) {
165            $flags[] = 'hidden';
166        }
167        if ( FilterUtils::isProtected( $newRow['af_hidden'] ) ) {
168            $flags[] = 'protected';
169        }
170        if ( $newRow['af_enabled'] ) {
171            $flags[] = 'enabled';
172        }
173        if ( $newRow['af_deleted'] ) {
174            $flags[] = 'deleted';
175        }
176        if ( $newRow['af_global'] ) {
177            $flags[] = 'global';
178        }
179
180        $afhRow['afh_flags'] = implode( ',', $flags );
181
182        $afhRow['afh_filter'] = $filterId;
183
184        // Do the update
185        $dbw->newInsertQueryBuilder()
186            ->insertInto( 'abuse_filter_history' )
187            ->row( $afhRow )
188            ->caller( __METHOD__ )
189            ->execute();
190        $historyID = $dbw->insertId();
191        if ( !$isNew ) {
192            $dbw->newDeleteQueryBuilder()
193                ->deleteFrom( 'abuse_filter_action' )
194                ->where( [ 'afa_filter' => $filterId ] )
195                ->caller( __METHOD__ )
196                ->execute();
197        }
198        if ( $actionsRows ) {
199            $dbw->newInsertQueryBuilder()
200                ->insertInto( 'abuse_filter_action' )
201                ->rows( $actionsRows )
202                ->caller( __METHOD__ )
203                ->execute();
204        }
205
206        $dbw->endAtomic( __METHOD__ );
207
208        // Invalidate cache if this was a global rule
209        if ( $originalFilter->isGlobal() || $newRow['af_global'] ) {
210            $this->filterLookup->purgeGroupWANCache( $newRow['af_group'] );
211        }
212
213        // Logging
214        $logEntry = new ManualLogEntry( 'abusefilter', $isNew ? 'create' : 'modify' );
215        $logEntry->setPerformer( $userIdentity );
216        $logEntry->setTarget( SpecialAbuseFilter::getTitleForSubpage( (string)$filterId ) );
217        $logEntry->setParameters( [
218            'historyId' => $historyID,
219            'newId' => $filterId
220        ] );
221        $logid = $logEntry->insert( $dbw );
222        $logEntry->publish( $logid );
223
224        // Purge the tag list cache so the fetchAllTags hook applies tag changes
225        if ( isset( $actions['tag'] ) ) {
226            $this->tagsManager->purgeTagCache();
227        }
228
229        $this->filterProfiler->resetFilterProfile( $filterId );
230        if ( $newRow['af_enabled'] ) {
231            $this->emergencyCache->setNewForFilter( $filterId, $newRow['af_group'] );
232        }
233        return [ $filterId, $historyID ];
234    }
235
236    /**
237     * @todo Perhaps add validation to ensure no null values remained.
238     * @note For simplicity, data about the last editor are omitted.
239     * @param Filter $filter
240     * @return array
241     */
242    private function filterToDatabaseRow( Filter $filter, Filter $originalFilter ): array {
243        // T67807: integer 1's & 0's might be better understood than booleans
244
245        // If the filter is already protected, it must remain protected even if
246        // the current filter doesn't use a protected variable anymore
247        // FIXME: Responsibility for this is currently unclear. It should be
248        // enforced prior to the FilterCompare::compareVersions call to avoid
249        // dummy filter versions.
250        $privacyLevel = $filter->getPrivacyLevel();
251        if ( $originalFilter->isProtected() ) {
252            $privacyLevel |= Flags::FILTER_USES_PROTECTED_VARS;
253        }
254
255        return [
256            'af_id' => $filter->getID(),
257            'af_pattern' => $filter->getRules(),
258            'af_public_comments' => $filter->getName(),
259            'af_comments' => $filter->getComments(),
260            'af_group' => $filter->getGroup(),
261            'af_actions' => implode( ',', $filter->getActionsNames() ),
262            'af_enabled' => (int)$filter->isEnabled(),
263            'af_deleted' => (int)$filter->isDeleted(),
264            'af_hidden' => $privacyLevel,
265            'af_global' => (int)$filter->isGlobal(),
266            'af_timestamp' => $filter->getTimestamp(),
267            'af_hit_count' => $filter->getHitCount(),
268            'af_throttled' => (int)$filter->isThrottled(),
269        ];
270    }
271}