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