Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
80.95% |
85 / 105 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
FilterStore | |
80.95% |
85 / 105 |
|
75.00% |
3 / 4 |
27.98 | |
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 | |
71.83% |
51 / 71 |
|
0.00% |
0 / 1 |
27.07 | |||
filterToDatabaseRow | |
100.00% |
15 / 15 |
|
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\ActorMigrationBase; |
12 | use MediaWiki\User\UserIdentity; |
13 | use Status; |
14 | use Wikimedia\Rdbms\LBFactory; |
15 | |
16 | /** |
17 | * @internal |
18 | */ |
19 | class 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 | } |