Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
89.68% |
113 / 126 |
|
70.00% |
7 / 10 |
CRAP | |
0.00% |
0 / 1 |
RunVariableGenerator | |
89.68% |
113 / 126 |
|
70.00% |
7 / 10 |
29.92 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
getStashEditVars | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
getEditTextForFiltering | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
6.02 | |||
newVariableHolderForEdit | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
3 | |||
getEditVars | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
setLastEditAge | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
getMoveVars | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getDeleteVars | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
getUploadVars | |
66.67% |
22 / 33 |
|
0.00% |
0 / 1 |
7.33 | |||
getAccountCreationVars | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\AbuseFilter\VariableGenerator; |
4 | |
5 | use LogicException; |
6 | use MediaWiki\Content\Content; |
7 | use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner; |
8 | use MediaWiki\Extension\AbuseFilter\TextExtractor; |
9 | use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder; |
10 | use MediaWiki\Page\WikiPageFactory; |
11 | use MediaWiki\Revision\MutableRevisionRecord; |
12 | use MediaWiki\Revision\RevisionRecord; |
13 | use MediaWiki\Revision\SlotRecord; |
14 | use MediaWiki\Title\Title; |
15 | use MediaWiki\User\User; |
16 | use MediaWiki\User\UserFactory; |
17 | use MWFileProps; |
18 | use UploadBase; |
19 | use Wikimedia\Assert\PreconditionException; |
20 | use Wikimedia\Mime\MimeAnalyzer; |
21 | use WikiPage; |
22 | |
23 | /** |
24 | * This class contains the logic used to create variable holders before filtering |
25 | * an action. |
26 | */ |
27 | class RunVariableGenerator extends VariableGenerator { |
28 | /** |
29 | * @var User |
30 | */ |
31 | private $user; |
32 | |
33 | /** |
34 | * @var Title |
35 | */ |
36 | private $title; |
37 | |
38 | /** @var TextExtractor */ |
39 | private $textExtractor; |
40 | /** @var MimeAnalyzer */ |
41 | private $mimeAnalyzer; |
42 | /** @var WikiPageFactory */ |
43 | private $wikiPageFactory; |
44 | |
45 | /** |
46 | * @param AbuseFilterHookRunner $hookRunner |
47 | * @param UserFactory $userFactory |
48 | * @param TextExtractor $textExtractor |
49 | * @param MimeAnalyzer $mimeAnalyzer |
50 | * @param WikiPageFactory $wikiPageFactory |
51 | * @param User $user |
52 | * @param Title $title |
53 | * @param VariableHolder|null $vars |
54 | */ |
55 | public function __construct( |
56 | AbuseFilterHookRunner $hookRunner, |
57 | UserFactory $userFactory, |
58 | TextExtractor $textExtractor, |
59 | MimeAnalyzer $mimeAnalyzer, |
60 | WikiPageFactory $wikiPageFactory, |
61 | User $user, |
62 | Title $title, |
63 | ?VariableHolder $vars = null |
64 | ) { |
65 | parent::__construct( $hookRunner, $userFactory, $vars ); |
66 | $this->textExtractor = $textExtractor; |
67 | $this->mimeAnalyzer = $mimeAnalyzer; |
68 | $this->wikiPageFactory = $wikiPageFactory; |
69 | $this->user = $user; |
70 | $this->title = $title; |
71 | } |
72 | |
73 | /** |
74 | * Get variables for pre-filtering an edit during stash |
75 | * |
76 | * @param Content $content |
77 | * @param string $summary |
78 | * @param string $slot |
79 | * @param WikiPage $page |
80 | * @return VariableHolder|null |
81 | */ |
82 | public function getStashEditVars( |
83 | Content $content, |
84 | string $summary, |
85 | $slot, |
86 | WikiPage $page |
87 | ): ?VariableHolder { |
88 | $filterText = $this->getEditTextForFiltering( $page, $content, $slot ); |
89 | if ( $filterText === null ) { |
90 | return null; |
91 | } |
92 | [ $oldContent, $oldAfText, $text ] = $filterText; |
93 | return $this->newVariableHolderForEdit( |
94 | $page, $summary, $content, $text, $oldAfText, $oldContent |
95 | ); |
96 | } |
97 | |
98 | /** |
99 | * Get the text of an edit to be used for filtering |
100 | * @todo Full support for multi-slots |
101 | * |
102 | * @param WikiPage $page |
103 | * @param Content $content |
104 | * @param string $slot |
105 | * @return array|null |
106 | */ |
107 | private function getEditTextForFiltering( WikiPage $page, Content $content, $slot ): ?array { |
108 | $oldRevRecord = $page->getRevisionRecord(); |
109 | if ( !$oldRevRecord ) { |
110 | return null; |
111 | } |
112 | |
113 | $oldContent = $oldRevRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW ); |
114 | if ( !$oldContent ) { |
115 | // @codeCoverageIgnoreStart |
116 | throw new LogicException( 'Content cannot be null' ); |
117 | // @codeCoverageIgnoreEnd |
118 | } |
119 | $oldAfText = $this->textExtractor->revisionToString( $oldRevRecord, $this->user ); |
120 | |
121 | // XXX: Recreate what the new revision will probably be so we can get the full AF |
122 | // text for all slots |
123 | $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevRecord ); |
124 | $newRevision->setContent( $slot, $content ); |
125 | $text = $this->textExtractor->revisionToString( $newRevision, $this->user ); |
126 | |
127 | // Don't trigger for null edits. Compare Content objects if available, but check the |
128 | // stringified contents as well, e.g. for line endings normalization (T240115). |
129 | // Don't treat content model change as null edit though. |
130 | if ( |
131 | $content->equals( $oldContent ) || |
132 | ( $oldContent->getModel() === $content->getModel() && strcmp( $oldAfText, $text ) === 0 ) |
133 | ) { |
134 | return null; |
135 | } |
136 | |
137 | return [ $oldContent, $oldAfText, $text ]; |
138 | } |
139 | |
140 | /** |
141 | * @param WikiPage $page |
142 | * @param string $summary |
143 | * @param Content $newcontent |
144 | * @param string $text |
145 | * @param string $oldtext |
146 | * @param Content|null $oldcontent |
147 | * @return VariableHolder |
148 | */ |
149 | private function newVariableHolderForEdit( |
150 | WikiPage $page, |
151 | string $summary, |
152 | Content $newcontent, |
153 | string $text, |
154 | string $oldtext, |
155 | ?Content $oldcontent = null |
156 | ): VariableHolder { |
157 | $this->addUserVars( $this->user ) |
158 | ->addTitleVars( $this->title, 'page' ); |
159 | $this->vars->setVar( 'action', 'edit' ); |
160 | $this->vars->setVar( 'summary', $summary ); |
161 | $this->setLastEditAge( $page->getRevisionRecord(), 'page' ); |
162 | |
163 | if ( $oldcontent instanceof Content ) { |
164 | $oldmodel = $oldcontent->getModel(); |
165 | } else { |
166 | $oldmodel = ''; |
167 | $oldtext = ''; |
168 | } |
169 | $this->vars->setVar( 'old_content_model', $oldmodel ); |
170 | $this->vars->setVar( 'new_content_model', $newcontent->getModel() ); |
171 | $this->vars->setVar( 'old_wikitext', $oldtext ); |
172 | $this->vars->setVar( 'new_wikitext', $text ); |
173 | |
174 | try { |
175 | $update = $page->getCurrentUpdate(); |
176 | $update->getParserOutputForMetaData(); |
177 | } catch ( PreconditionException | LogicException $exception ) { |
178 | // Temporary workaround until this becomes |
179 | // a hook parameter |
180 | $update = null; |
181 | } |
182 | $this->addEditVars( $page, $this->user, true, $update ); |
183 | |
184 | return $this->vars; |
185 | } |
186 | |
187 | /** |
188 | * Get variables for filtering an edit. |
189 | * |
190 | * @param Content $content |
191 | * @param string $summary |
192 | * @param string $slot |
193 | * @param WikiPage $page |
194 | * @return VariableHolder|null |
195 | */ |
196 | public function getEditVars( |
197 | Content $content, |
198 | string $summary, |
199 | $slot, |
200 | WikiPage $page |
201 | ): ?VariableHolder { |
202 | if ( $this->title->exists() ) { |
203 | $filterText = $this->getEditTextForFiltering( $page, $content, $slot ); |
204 | if ( $filterText === null ) { |
205 | return null; |
206 | } |
207 | [ $oldContent, $oldAfText, $text ] = $filterText; |
208 | } else { |
209 | // Optimization |
210 | $oldContent = null; |
211 | $oldAfText = ''; |
212 | $text = $this->textExtractor->contentToString( $content ); |
213 | } |
214 | |
215 | return $this->newVariableHolderForEdit( |
216 | $page, $summary, $content, $text, $oldAfText, $oldContent |
217 | ); |
218 | } |
219 | |
220 | /** |
221 | * @param RevisionRecord|Title|null $from |
222 | * @param string $prefix |
223 | */ |
224 | private function setLastEditAge( $from, string $prefix ): void { |
225 | $varName = "{$prefix}_last_edit_age"; |
226 | if ( $from instanceof RevisionRecord ) { |
227 | $this->vars->setVar( |
228 | $varName, |
229 | (int)wfTimestamp( TS_UNIX ) - (int)wfTimestamp( TS_UNIX, $from->getTimestamp() ) |
230 | ); |
231 | } elseif ( $from instanceof Title ) { |
232 | $this->vars->setLazyLoadVar( |
233 | $varName, |
234 | 'revision-age', |
235 | [ 'title' => $from, 'asof' => wfTimestampNow() ] |
236 | ); |
237 | } else { |
238 | $this->vars->setVar( $varName, null ); |
239 | } |
240 | } |
241 | |
242 | /** |
243 | * Get variables used to filter a move. |
244 | * |
245 | * @param Title $newTitle |
246 | * @param string $reason |
247 | * @return VariableHolder |
248 | */ |
249 | public function getMoveVars( |
250 | Title $newTitle, |
251 | string $reason |
252 | ): VariableHolder { |
253 | $this->addUserVars( $this->user ) |
254 | ->addTitleVars( $this->title, 'moved_from' ) |
255 | ->addTitleVars( $newTitle, 'moved_to' ); |
256 | |
257 | $this->vars->setVar( 'summary', $reason ); |
258 | $this->vars->setVar( 'action', 'move' ); |
259 | $this->setLastEditAge( $this->title, 'moved_from' ); |
260 | $this->setLastEditAge( $newTitle, 'moved_to' ); |
261 | // TODO: add old_wikitext etc. (T320347) |
262 | return $this->vars; |
263 | } |
264 | |
265 | /** |
266 | * Get variables for filtering a deletion. |
267 | * |
268 | * @param string $reason |
269 | * @return VariableHolder |
270 | */ |
271 | public function getDeleteVars( |
272 | string $reason |
273 | ): VariableHolder { |
274 | $this->addUserVars( $this->user ) |
275 | ->addTitleVars( $this->title, 'page' ); |
276 | |
277 | $this->vars->setVar( 'summary', $reason ); |
278 | $this->vars->setVar( 'action', 'delete' ); |
279 | // FIXME: this is an unnecessary round-trip, we could obtain WikiPage from |
280 | // the hook and call WikiPage::getRevisionRecord, but then ProofreadPage tests fail |
281 | $this->setLastEditAge( $this->title, 'page' ); |
282 | // TODO: add old_wikitext etc. (T173663) |
283 | return $this->vars; |
284 | } |
285 | |
286 | /** |
287 | * Get variables for filtering an upload. |
288 | * |
289 | * @param string $action |
290 | * @param UploadBase $upload |
291 | * @param string|null $summary |
292 | * @param string|null $text |
293 | * @param array|null $props |
294 | * @return VariableHolder|null |
295 | */ |
296 | public function getUploadVars( |
297 | string $action, |
298 | UploadBase $upload, |
299 | ?string $summary, |
300 | ?string $text, |
301 | ?array $props |
302 | ): ?VariableHolder { |
303 | if ( !$props ) { |
304 | $props = ( new MWFileProps( $this->mimeAnalyzer ) )->getPropsFromPath( |
305 | $upload->getTempPath(), |
306 | true |
307 | ); |
308 | } |
309 | |
310 | $this->addUserVars( $this->user ) |
311 | ->addTitleVars( $this->title, 'page' ); |
312 | $this->vars->setVar( 'action', $action ); |
313 | |
314 | // We use the hexadecimal version of the file sha1. |
315 | // Use UploadBase::getTempFileSha1Base36 so that we don't have to calculate the sha1 sum again |
316 | $sha1 = \Wikimedia\base_convert( $upload->getTempFileSha1Base36(), 36, 16, 40 ); |
317 | |
318 | // This is the same as AbuseFilterRowVariableGenerator::addUploadVars, but from a different source |
319 | $this->vars->setVar( 'file_sha1', $sha1 ); |
320 | $this->vars->setVar( 'file_size', $upload->getFileSize() ); |
321 | |
322 | $this->vars->setVar( 'file_mime', $props['mime'] ); |
323 | $this->vars->setVar( 'file_mediatype', $this->mimeAnalyzer->getMediaType( null, $props['mime'] ) ); |
324 | $this->vars->setVar( 'file_width', $props['width'] ); |
325 | $this->vars->setVar( 'file_height', $props['height'] ); |
326 | $this->vars->setVar( 'file_bits_per_channel', $props['bits'] ); |
327 | |
328 | // We only have the upload comment and page text when using the UploadVerifyUpload hook |
329 | if ( $summary !== null && $text !== null ) { |
330 | // This block is adapted from self::getEditTextForFiltering() |
331 | $page = $this->wikiPageFactory->newFromTitle( $this->title ); |
332 | if ( $this->title->exists() ) { |
333 | $revRec = $page->getRevisionRecord(); |
334 | if ( !$revRec ) { |
335 | return null; |
336 | } |
337 | |
338 | $this->setLastEditAge( $revRec, 'page' ); |
339 | $oldcontent = $revRec->getContent( SlotRecord::MAIN, RevisionRecord::RAW ); |
340 | '@phan-var Content $oldcontent'; |
341 | $oldtext = $this->textExtractor->contentToString( $oldcontent ); |
342 | |
343 | // Page text is ignored for uploads when the page already exists |
344 | $text = $oldtext; |
345 | } else { |
346 | $oldtext = ''; |
347 | $this->setLastEditAge( null, 'page' ); |
348 | } |
349 | |
350 | // Load vars for filters to check |
351 | $this->vars->setVar( 'summary', $summary ); |
352 | $this->vars->setVar( 'old_wikitext', $oldtext ); |
353 | $this->vars->setVar( 'new_wikitext', $text ); |
354 | // TODO: set old_content_model and new_content_model vars, use them |
355 | $this->addEditVars( $page, $this->user, true ); |
356 | } |
357 | return $this->vars; |
358 | } |
359 | |
360 | /** |
361 | * Get variables for filtering an account creation |
362 | * |
363 | * @param User $createdUser This is the user being created, not the creator (which is $this->user) |
364 | * @param bool $autocreate |
365 | * @return VariableHolder |
366 | */ |
367 | public function getAccountCreationVars( |
368 | User $createdUser, |
369 | bool $autocreate |
370 | ): VariableHolder { |
371 | // generateUserVars records $this->user->getName() which would be the IP for unregistered users |
372 | if ( $this->user->isRegistered() ) { |
373 | $this->addUserVars( $this->user ); |
374 | } else { |
375 | // Set the user_type for IP users, so that filters can distinguish between account |
376 | // creations from temporary accounts and those from IP addresses. |
377 | $this->vars->setLazyLoadVar( |
378 | 'user_type', |
379 | 'user-type', |
380 | [ 'user-identity' => $this->user ] |
381 | ); |
382 | } |
383 | |
384 | $this->vars->setVar( 'action', $autocreate ? 'autocreateaccount' : 'createaccount' ); |
385 | $this->vars->setVar( 'accountname', $createdUser->getName() ); |
386 | return $this->vars; |
387 | } |
388 | } |