Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
81.13% |
86 / 106 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
FilterStore | |
81.13% |
86 / 106 |
|
75.00% |
3 / 4 |
26.55 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
saveFilter | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
doSaveFilter | |
71.83% |
51 / 71 |
|
0.00% |
0 / 1 |
25.24 | |||
filterToDatabaseRow | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter; |
4 | |
5 | use ManualLogEntry; |
6 | use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagsManager; |
7 | use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry; |
8 | use MediaWiki\Extension\AbuseFilter\Filter\Filter; |
9 | use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter; |
10 | use MediaWiki\Permissions\Authority; |
11 | use MediaWiki\User\UserIdentity; |
12 | use Status; |
13 | use Wikimedia\Rdbms\ILoadBalancer; |
14 | |
15 | /** |
16 | * @internal |
17 | */ |
18 | class 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 | } |