Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.72% |
111 / 128 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
FilterStore | |
86.72% |
111 / 128 |
|
75.00% |
3 / 4 |
28.71 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
saveFilter | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
doSaveFilter | |
81.32% |
74 / 91 |
|
0.00% |
0 / 1 |
23.88 | |||
filterToDatabaseRow | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 |
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\Filter\Flags; |
10 | use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter; |
11 | use MediaWiki\Permissions\Authority; |
12 | use MediaWiki\Status\Status; |
13 | use MediaWiki\User\ActorNormalization; |
14 | use MediaWiki\User\UserIdentity; |
15 | use Wikimedia\Rdbms\LBFactory; |
16 | |
17 | /** |
18 | * @internal |
19 | */ |
20 | class 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 | } |