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