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