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